# 微信小程序登录体系

> 状态：**定稿（2026-08-19）**
> 关联文档：[token.md](./token.md)（token 令牌体系，底座）、[gen-login.md](./gen-login.md)（登录链生成器统一设计）、[docs/third-party.md](../../docs/third-party.md)（第三方调用模式）
> 定位：token 令牌体系在微信小程序端的登录入口具体化——两种登录形态（用户端静默登录 / 商户端静默+账密登录），均走 app 级登录入口（token.md 决策 #14：入口即登录）

## 1. 背景：小程序登录的两种形态

微信小程序有两种业务身份，登录形态不同：

| 形态 | 用户 | 凭据 | 交互 |
|------|------|------|------|
| **形态 1：静默登录** | C 端用户 | `wx.login()` 的 code（自动获取） | 无感，无任何输入 |
| **形态 2：静默 + 账密登录** | B 端商户 | 用户名 + 密码 + code | 登录页输入账号密码 |

两个形态共用同一套 token 登录链（token.md），差别只在**登录入口的凭据与校验逻辑**；code（静默凭证）两个形态都走 `wx.login()` 获取。

## 2. 与 token 体系的关系

- **入口即登录（token.md 决策 #14）**：微信登录端点就是小程序 app 的登录入口——login 直接生成 token hash + secret + refreshToken + 附着身份，无公共签发接口；
- **会话凭据写账户表（决策 #13 硬约束）**：登录链的账户表必须含 `token` + `refresh_token` + `login_at` 三列；
- **单点确认（决策 #11）**：登录时查账户表旧 token → 删 Redis 旧对象 → 生成新 token + refreshToken 写回（后登录踢掉前登录）；
- **refreshToken 7 天滑动窗口（决策 #9/#11）**：`login_at` 在 login 与每次 refresh 时更新；
- **refresh 每次轮换（决策 #11）**：refresh 重新生成 token + refreshToken 写回账户表，旧 refreshToken 立即作废不可重放；
- **wx 场景的推论**：小程序**每次进入都先登录**（形态 1 无感、形态 2 有态），refreshToken 始终是最新——7 天过期路径在 wx 端实际不可达，只在非 wx 客户端（admin/node）有意义。

## 3. 小程序表标准（xx_wx）

### 3.1 微信绑定表（xx_wx）标准

**职责**：微信 openid 绑定表——把"微信身份"（openid/unionid）与"业务身份"（业务账号）解耦。**不是身份表**：业务字段（余额/状态等）留在业务账号表，xx_wx 只做绑定。

**命名**：**`{app.name}_wx`**——xx = `project.config.ts` 的 **app name**（FrontAppSchema，kebab-case；表名 snake 化，如 app `mini-user` → 表 `mini_user_wx`、app `mini-verify` → 表 `mini_verify_wx`）。**一个小程序（app）只有一张 openid 表**——表与 app 一一对应，不按业务身份拆多张表。

**主键**：`(appid, openid)` **联合主键**——一张表一个主键，由两列组成（`PRIMARY KEY (appid, openid)`）。openid 唯一性以 appid 为命名空间：同一 appid 内唯一，跨 appid 微信不保证唯一，所以 appid 必须纳入主键。

| 列 | 类型 | 约束 | 说明 |
|----|------|------|------|
| `appid` | STRING(32) | **联合主键** | 微信小程序 appid（wx 开头）——openid 的唯一性以 appid 为命名空间；同一个小程序 app 可对应多个微信 appid（多环境/多入口） |
| `openid` | STRING(64) | **联合主键** | 微信身份锚点——**只在同一 appid 内唯一**（跨 appid 微信不保证唯一） |
| `{业务}_id` | 业务表 PK 类型 | 业务表 FK | 该 app 对应的业务身份，如 `user_id` / `merchant_id`；命名 = 业务表名单数 + `_id` |
| `unionid` | STRING(64) | 可空 | 跨小程序/公众号统一身份（绑定开放平台后才有）——多 appid 下同一自然人靠它关联 |
| `session_key` | STRING(255) | **可空** | 微信会话密钥，**加密落库**（见 3.3） |
| `nickname` | STRING(100) | 可空 | 微信昵称（最小化采集，可空） |
| `avatar_url` | STRING(500) | 可空 | 微信头像地址（最小化采集，可空） |
| `created_at` | DATETIME | 非空 | 创建时间 |
| `updated_at` | DATETIME | 非空 | 更新时间 |

**约定**：
- **一个小程序一张表**（表名 = app name + `_wx`）——即使该 app 下存在多个业务身份形态（如 C 端用户 + 平台运营同一个小程序），也收敛到一张 openid 表，用 `{业务}_id` 列区分/绑定；
- **主键 = `(appid, openid)` 联合**——openid 唯一性以 appid 为命名空间：同一 appid 内唯一，跨 appid 微信不保证（同一用户不同 appid 值不同）；查 openid 必须带 appid 条件；同一个小程序 app 对应多个微信 appid 时（多环境/多入口），同一用户在每个 appid 下各自一行，按 unionid 关联同一自然人；
- 一个业务账号可有多行 openid（同一 user 多微信）？**否——默认一行一身份**（`(appid, openid)` 联合主键唯一，业务侧按需加唯一索引）；
- 表必须 `paginated: true`（与其它业务表一致）。

### 3.2 业务账号表三列硬约束（token 决策 #13）

身份表（业务账号表，如 `user` / `merchant`）**必须包含**：

| 列 | 类型 | 说明 |
|----|------|------|
| `token` | STRING(64) | 当前会话 token hash（可空——未登录） |
| `refresh_token` | STRING(64) | 当前会话 refreshToken（可空） |
| `login_at` | DATETIME | 最近一次登录/刷新时间（7 天滑动窗口锚点，at 后缀命名约定） |

定义期由 `defineToken` / `gen login` 硬校验（缺列报错）。

### 3.3 session_key 加密落库（已决策）

- **落库**：微信官方建议不落库，但业务需要 getPhoneNumber 等开放数据解密时，session_key 是解密密钥——**必须落库**（否则每次 code2Session 换新值，解密时拿不到）；
- **加密**：AES-256-GCM 加密后落库，**不落明文**（等保三级 8.1.4.8 存储加密要求）；密钥由服务端配置管理；
- **不下发**：session_key 属于微信侧的敏感数据，**绝不返回前端**（微信官方约束，等保 8.1.4.10 最小化）；前端只持有 code，解密在服务端完成；
- 每次登录更新（code2Session 每次返回新 session_key）——`updateSessionKey` 幂等写回。

## 4. code2Session 标准库（@pylonts/wechat）

### 4.1 定位

**纯微信对接库**，与本地业务、表、token 体系无关：

- 只对接微信服务端 API：`code2Session` + 开放数据解密（getPhoneNumber 等）；
- 无 DSL schema、无生成器——直接可落地的 npm 包（src 直发模式，同 @pylonts/event / @pylonts/mock）；
 - 内部错误统一抛 `BusinessException`（与 @pylonts/fastify 桥接；ServiceException 保留给系统级重大问题，业务不抛）。

### 4.2 API 面

```ts
createWechatClient({ appId, appSecret, mock? }) → {
  code2Session(code: string, appId?: string): Promise<{ openid: string; unionid: string | null; sessionKey: string }>;
  // 多微信小程序 appid 时，code2Session 第二个参数覆盖默认 appId（表内 appid 列来自它）

  // 手机号获取（扩展，非登录必需）：POST /wxa/business/getuserphonenumber?access_token=...
  getAccessToken(): Promise<string>;                                        // 稳定版 stable_token，缓存 7200s
  getPhoneNumber(code: string, openid?: string): Promise<{
    phoneNumber: string;        // 用户绑定的手机号（国外手机号带区号）
    purePhoneNumber: string;    // 无区号手机号
    countryCode: string;        // 区号（如 86）
  }>;                           // code 一次性、5min 有效；openid 填了则校验 code 绑定关系

  encryptSessionKey(plain: string, key: string): string;  // session_key 落库加密辅助（AES-256-GCM，可选）
  decryptSessionKey(enc: string, key: string): string;
}
```

**getAccessToken 缓存设计（稳定版 stable_token）**：
- **接口**：`POST /cgi-bin/stable_token`（官方推荐——与旧 `GET /cgi-bin/token` 互相隔离、限频宽（1 万次/分钟、50 万次/天）、**有效期内重复调用不更新 token**）；请求体 `{ grant_type: 'client_credential', appid, secret, force_refresh: false }`；
- **本地缓存（进程内 `Map<appId, { token, expiresAt }>`）**：命中未过期直接返回；未命中/过期才刷新——避免每次调用都走 HTTPS 往返 + 限频消耗；
- **不需要外部 store**：stable_token 有效期内重复调用不更新 token → 多实例各自本地缓存同一 token，天然不冲突（旧 getAccessToken 才需要中控/共享 store 防覆盖）；
- **提前过期**：`expires_in - 300`（提前 5 分钟刷新，对齐官方"5 分钟内新旧 token 都可用"）；
- **并发去重**：single-flight——一个刷新进行中，其他调用等同一个 Promise；
- **多 appid**：缓存 key = appId（每个小程序一个 access_token）；
- **force_refresh**：默认 false，暴露 `forceRefresh()` 可选（手动强制刷新场景，`force_refresh: true` 会使旧 token 失效）。

**TokenStore 接口**：不需要——stable_token 有效期内幂等，本地进程内 Map 缓存即可，多实例各自缓存同一 token 不冲突。

**手机号获取（getPhoneNumber）说明**：
- **服务器端调用**——前端 `<button open-type="getPhoneNumber">` 用户同意后拿 code，回传服务端换取手机号；不可前端直调微信接口；
- **不走解密**——新接口是 code + access_token 换取，**不需要 session_key / encryptedData 解密链路**（旧 `decryptPhoneNumber` 不做）；
- **依赖 getAccessToken**（内部缓存 7200s）；权限需非个人开发者 + 认证小程序，2023-08-28 起计费（0.03 元/次）；
- 错误码：-1 重试 / 40013 appid 不匹配 / 40029 code 无效 / 45011 频率限制；

### 4.3 错误码处理（吸收 discount-mall WechatClient 教训）

| errcode | 含义 | 处理 |
|---------|------|------|
| -1 | 系统繁忙 | 自动重试一次（再失败抛 BusinessException） |
| 40029 | code 无效/过期 | BusinessException（登录凭证无效或已过期） |
| 45011 | 频率限制 | BusinessException（请求过于频繁，请稍后重试） |
| 40125 | AppSecret 错误 | BusinessException（小程序 AppSecret 配置错误） |
| 其他 | — | BusinessException（微信登录失败: errmsg） |

- code 一次性 + 5 分钟有效（微信侧约束，库侧只需透传失败）；
- `grant_type` 固定 `authorization_code`（库内部处理，调用方不感知）。

### 4.4 mock 模式

`mock: true` 时 code2Session 返回确定性结果（`openid = 'mock_openid_' + code`、固定 sessionKey、unionid null），decrypt 返回可预测结构——本地开发/测试/CI 无真实 AppSecret 可用（同 discount-mall 现有实现，收编进库）。

### 4.5 边界（不做）

- 不做旧开放数据解密（encryptedData + iv + session_key——微信已不推荐，新接口 code 换取不需要）；
- 不做支付（@pylonts 支付能力独立评估）；
- 不做云开发/云调用（绑定微信云托管，不符合自建服务器 + ts-libs 体系）；
- 不定义 DSL schema（无生成器需求——调用方三行代码直连）；
- 头像昵称：微信已取消静默获取（2022-10 后 getUserProfile 返回匿名），现行方案是前端 `open-type="chooseAvatar"` + `input type="nickname"` 用户主动填写——无服务端接口可对接，不在库范围内。

## 5. 形态 1：C 端静默登录（定稿）

### 流程

```
进入小程序（冷启动/onLoad）
  → wx.login() 拿 code（一次性，5 分钟有效）
  → POST /api/{app}/login/login  { code }                 ← app 级登录入口（@LoginEntry('{app}')）
  → 服务端：@pylonts/wechat code2Session(code, appId) 换 openid/session_key（appId 来自小程序配置）
  → 按 openid + appid 查 {app}_wx（如 `mini_user_wx`）→ 无则创建业务账号 + openid 行（自动建档），有则更新 session_key（加密落库）
  → 单点确认（查 user 表旧 token → 删 Redis 旧对象）
  → 生成 token + secret + refreshToken → 写账户表（token/refresh_token/login_at）+ Redis
  → 返回 { token, refreshToken, secret, user? }
```

### 凭据与身份

| 项 | 值 |
|----|----|
| 凭据 | code（微信侧一次性，5 分钟有效，不可重放） |
| 身份锚点 | openid（{app}_wx PK，唯一） |
| 校验 | code2Session 失败 → 抛错（422）；无用户 → 自动建档（静默注册） |

### 会话生命周期

- **每次进入都登录** → refreshToken 恒新 → 7 天过期不可达；
- 运行中 token 30 分钟过期（Redis TTL）→ 自动 refresh（refreshToken 有效）→ 无感续期；
- refreshToken 失效（理论场景）→ 客户端 `onLoginRequired` → reLaunch 首页 → 重走静默登录 → 无感恢复，**不需要用户输入任何东西**。

## 6. 形态 2：B 端静默 + 账密登录（定稿）

### 流程

```
商户登录页：输入用户名 + 密码（页面 onLoad 同时 wx.login() 拿 code）
  → POST /api/{app}/login/login  { code, username, password }
  → 服务端：@pylonts/wechat code2Session(code) 验证小程序环境 + 拿 openid
  → 校验用户名 + 密码（bcrypt，复用 admin-login 链）
  → 校验商户状态（非正常状态拒绝）
  → openid 绑定 {app}_wx（如 `mini_verify_wx`）（bind 幂等：存在更新 session_key，不存在插入）
  → 单点确认 → 生成 token + secret + refreshToken → 写账户表 + Redis
  → 返回 { token, refreshToken, secret, user? }
```

### 凭据与身份

| 项 | 值 |
|----|----|
| 凭据 | 用户名 + 密码（主）+ code（环境验证 + openid 绑定） |
| 身份锚点 | 商户账号 id（商户表主键） |
| 校验 | 密码 bcrypt（失败锁定等保约束见 token.md §6a） |

### code 的作用（已决策：环境验证 + 身份绑定）

- **环境验证**：code2Session 证明"请求来自微信小程序环境"（code 只能由 wx.login 在真机小程序环境产生）；
- **身份绑定**：openid 写 merchant_wx 表（`openid` PK + `merchant_id` FK）——该微信与商户账号绑定，后续支持免密快捷登录。

## 7. 登录入口与 refresh（定稿）

- **路径约定（app 前缀区分形态）**：`POST {contextPath}/api/{app}/login/login`（登录）+ `POST {contextPath}/api/{app}/login/refresh`（刷新）——app 即模块（`miniuser` / `miniverify`），C/B 端只是 app 不同，**路径模板同一**；
- **Controller**：`@Rpc('login')` + `@LoginEntry('{app}')`，两个方法 `login` / `refresh`（与 gen-admin-login 生成物同构——微信变体 = LoginRequest 增加 code 字段 + 登录服务前段插入 code2Session）；
- **refresh 语义（token.md 决策 #11）**：请求体上送 refreshToken → 按 refreshToken 查账户表定位用户 → 校验账号状态 + 7 天滑动窗口 → 派生密钥验签 → 重新生成 token + refreshToken 写回 → 返回新会话；refresh 为客户端内部固定流程（api-client 自动处理，不暴露前端函数）；
- **响应**：`{ token, refreshToken, secret, user? }`——三元组 + 业务数据（token.md 决策 #15：login 下发 secret，客户端持 secret 签名业务请求）。

## 8. wx 客户端约束（进入必登录）

1. **首页 onLoad / app onLaunch 固定走登录**（形态 1 无条件 wx.login → code → 登录端点；形态 2 无本地 session 时进登录页）——不能只在"本地无 session"时登录，否则 session 过期但被 store 保留时会拿旧 refreshToken 白试一次；
2. **登录成功即整体替换会话**（token + refreshToken + secret 同构响应，extractSession 天然采纳）；
3. **`onLoginRequired` → reLaunch 首页** → 首页重走登录（wx 端是静默恢复，不是让用户输密码）；
4. **refreshToken 恒新假设成立的前提是"进入必登录"**——违反则 wx 端退化到 7 天过期路径；
5. **会话存储**：storage 存三元组 `{ token, refreshToken, secret }`（业务请求 secret 签名 + token 上送；不再有旧 HMAC key/issue 凭证——token 体系无公共签发接口）。

## 9. 与现有实现的差距（discount-mall 基线）

| 能力 | 现状（discount-mall） | 需要 |
|------|----------------------|------|
| code2Session 调用 | 手写 `WechatClient`（mock/错误码/重试已具备） | 收编为 `@pylonts/wechat`（标准库） |
| session_key 落库 | 明文（`session_key` 列） | AES-256-GCM 加密落库 |
| 登录入口 | `@Public()` + `signToken()`（JWT 旧体系） | `@LoginEntry('{app}')` + token 登录链（login/refresh 两方法，`gen login` 生成） |
| 登录响应 | `{ token }`（JWT 字符串） | `{ token, refreshToken, secret }` 三元组 |
| 前端会话 | `key/issue` + `key/refresh`（旧 HMAC 凭证）+ JWT token | 三元组存储 + secret 签名 + 自动 refresh |
| user/merchant 表 | 无三列 | 补 `token` + `refresh_token` + `login_at` |

## 10. 开放问题（收敛后）

1. ~~形态 2 的 code 用途~~ → **已决策：环境验证 + 身份绑定（选项 B）**；
2. ~~形态 1 的身份表~~ → **已决策：xx_wx 只做绑定（表名 = app name + `_wx`，一个小程序一张），业务账号表是身份表**（user / merchant 等）；
3. ~~生成器形态~~ → **已决策：无独立 wx-login 生成器——`gen login` 升级为 `gen login`（[gen-login.md](./gen-login.md)），按 app 类型分叉（admin 账密 / wxmini silent / wxmini password）**；
4. ~~形态 2 的商户表~~ → **已决策：复用 admin-login 的账户表（加 code 校验）**，不独立商户登录表；
5. ~~`@pylonts/wechat` 扩展范围~~ → **已决策：MVP 只做登录必需（code2Session）；手机号获取（getAccessToken + getPhoneNumber）接口已确认（官方文档），列为扩展——实现排在登录之后**；头像昵称无服务端接口，不做。