# client-auth-system 设计文档

## Context

当前个人客户认证状态如下：

- 个人客户当前使用纯 JWT（`pkg/auth/jwt.go`），未做 Redis token 状态存储，服务端无法主动失效。
- 微信配置已在数据库 `tb_wechat_config` 中存在（`WechatConfig`），但现有能力中仍存在 YAML 静态配置依赖。
- 个人客户相关模型已存在：`PersonalCustomer`、`PersonalCustomerPhone`、`PersonalCustomerDevice`。
- 现有 `/api/c/v1` 路由将被本次完整认证体系替换为新的 `/api/c/v1/auth/*` 七个端点。

## Goals / Non-Goals

### Goals

- 交付完整 C 端认证系统，覆盖 A1~A7 七个接口：资产验证、微信登录（公众号/小程序）、验证码发送、手机号绑定、手机号换绑、退出登录。
- 建立有状态 JWT（JWT + Redis）机制，支持服务端主动失效。
- 建立 OpenID 多记录模型，兼容公众号与小程序不同 AppID 场景。

### Non-Goals

- 不实现业务域 API（如充值、套餐、订单等）。
- 不包含兑换系统（exchange）相关设计与实现。

## Decisions

### 1) asset_token 设计

- 方案：`asset_token` 使用短时效 JWT（5 分钟），payload 仅包含 `asset_type` + `asset_id`，并使用独立于主登录 JWT 的签名密钥。
- Why：A1 是无认证接口，若直接暴露内部 `asset_id` 会造成可枚举风险；短时效 + 独立密钥可降低 token 泄露影响范围。

### 2) Stateful JWT with Redis

- 方案：登录成功签发 JWT 后，将 token 状态写入 Redis 并设置 TTL；每次请求在中间件同时校验 JWT 与 Redis 状态。
- Redis Key：`RedisPersonalCustomerTokenKey(customerID)`。
- Why：纯 JWT 无法服务端撤销；Redis 状态可支持封禁、强制下线、单点退出等主动失效场景。

### 3) OpenID multi-record strategy

- 方案：新增 `PersonalCustomerOpenID` 表，约束 `UNIQUE(app_id, open_id)`（软删条件下唯一）；客户查找逻辑采用：
  1. 先按 `(app_id, open_id)` 精确命中；
  2. 未命中时按 `unionid` 回查并合并；
  3. 仍未命中则创建新客户。
- Why：公众号与小程序可能不在同一开放平台，需支持“一客户多 OpenID 记录”。

### 4) WechatConfig dynamic loading + SDK 实例工厂

- 方案：登录时动态读取 `tb_wechat_config WHERE is_active=true`，使用工厂函数按需创建 SDK 实例：
  - OfficialAccount 使用 `oa_app_id + oa_app_secret`
  - Miniapp 使用 `miniapp_app_id + miniapp_app_secret`
  - Payment 使用 `wx_mch_id + wx_api_v3_key + wx_cert_content + wx_key_content + wx_serial_no`
- Why：避免 YAML 静态配置导致多环境切换和配置漂移，支持运营侧动态切换。

**现有 SDK 能力盘点（`pkg/wechat/`）**：

| 文件 | 已有能力 | 客户端接口需要 |
|------|---------|--------------|
| `official_account.go` | `GetUserInfo(code)`（snsapi_base）、`GetUserInfoDetailed(code)`（snsapi_userinfo）、`GetUserInfoByToken()` | A2 公众号登录 ✅ 直接复用 `GetUserInfoDetailed` |
| `payment.go` | `CreateJSAPIOrder()`、`CreateH5Order()`、`QueryOrder()`、`CloseOrder()`、`HandlePaymentNotify()` | 提案 2 支付 ✅ |
| `config.go` | `NewOfficialAccountApp(cfg)` — 仅从 YAML 创建 | ❌ 需新增 DB 动态工厂 |
| **缺失** `miniapp.go` | 无 | ❌ A3 小程序登录需要 |

**需要新增的 SDK 代码**：

1. **`pkg/wechat/miniapp.go`** — 小程序服务封装：

```go
// MiniAppService 微信小程序服务实现
type MiniAppService struct {
    appID     string
    appSecret string
    logger    *zap.Logger
}

// MiniAppServiceInterface 微信小程序服务接口
type MiniAppServiceInterface interface {
    Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
}

// Code2Session 通过小程序 login code 换取 openid + session_key
// 调用微信 https://api.weixin.qq.com/sns/jscode2session 接口
// 注意: 小程序无法通过 code 直接获取用户信息（昵称/头像由前端授权后传入）
func (s *MiniAppService) Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
```

2. **`pkg/wechat/config.go`** — 新增 DB 动态工厂函数：

```go
// NewOfficialAccountAppFromConfig 从 WechatConfig DB 记录创建公众号应用实例
func NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error)

// NewPaymentAppFromConfig 从 WechatConfig DB 记录创建支付应用实例
// appID 参数决定支付关联的应用：公众号传 oa_app_id，小程序传 miniapp_app_id
func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error)

// NewMiniAppServiceFromConfig 从 WechatConfig DB 记录创建小程序服务实例
func NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger *zap.Logger) (*MiniAppService, error)
```

3. **`pkg/wechat/wechat.go`** — 新增 `MiniAppServiceInterface` 接口定义和编译时类型检查。

**A2/A3 登录时 SDK 调用链路**：

```
A2 公众号登录:
  client_auth.Service
    → wechatConfigService.GetActiveConfig()          // 从 DB/Redis 缓存获取配置
    → wechat.NewOfficialAccountAppFromConfig(config)  // 动态创建公众号实例
    → wechat.NewOfficialAccountService(app)           // 包装为 Service
    → officialAccountService.GetUserInfoDetailed(code) // 现有方法，直接复用
    → 返回 openID + unionID + nickname + avatar

A3 小程序登录:
  client_auth.Service
    → wechatConfigService.GetActiveConfig()
    → wechat.NewMiniAppServiceFromConfig(config)      // 新增方法
    → miniAppService.Code2Session(code)                // 新增方法
    → 返回 openID + unionID + sessionKey
    → nickname/avatar 从请求体获取（前端授权后传入）
```

**关键约束**：小程序 `Code2Session` 不调用 PowerWeChat SDK（该 SDK 主要封装公众号和支付），而是直接 HTTP 请求微信 `jscode2session` 接口。这更简单可控。

### 5) Phone binding config

- 方案：手机号绑定策略使用 Viper 配置项 `client.require_phone_binding`（bool），在登录时实时读取，不新增 DB 配置表。
- Why：该策略属于部署级开关，配置中心化更轻量，减少数据库复杂度。

### 6) Asset binding on login

- 方案：每次登录都创建 `PersonalCustomerDevice` 绑定记录；同一资产允许被多个客户绑定，不做覆盖写入。
- Why：业务上存在转手、共用、历史归属追踪需求，强唯一会丢失使用关系。

### 7) Rate limiting strategy

- A1：IP 级限频 `30/min`。
- A4：手机号维度 `60s` 冷却 + IP 维度 `20/hour` + 手机号维度 `10/day`。
- Why：A1 主要防资产暴力枚举；A4 主要防短信轰炸与资源滥用，采用多维限流降低绕过概率。

## Risks / Trade-offs

1. **Redis 强依赖风险**
   - 风险：Redis 异常会导致 token 校验失败、登录态不可用。
   - 缓解：中间件区分“无效 token”与“Redis 不可用”并记录告警；部署 Redis 高可用；关键路径加入超时与重试上限。

2. **OpenID 合并误关联风险**
   - 风险：若第三方返回异常 unionid，可能出现错误合并。
   - 缓解：仅在 unionid 非空且满足格式校验时启用回退合并；记录合并审计日志（customer_id、app_id、openid、unionid）。

3. **资产多人绑定带来的业务歧义**
   - 风险：后续业务查询若默认“单资产单用户”，可能读取歧义。
   - 缓解：规范下游以“当前登录 customer_id + asset”联合查询；在文档中明确“资产可多客户绑定”语义。

4. **动态微信配置切换风险**
   - 风险：运营误切换 `is_active` 导致登录瞬时失败。
   - 缓解：限制仅单条激活、增加配置健康检查与缓存短 TTL、错误回退到最近一次可用配置。

## Migration Plan

1. **数据库迁移**
   - 新增 `tb_personal_customer_openid` 表（含 `customer_id/app_id/open_id/union_id` 等字段）。
   - 创建唯一索引：`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`。

2. **配置更新**
   - 在 `pkg/config/defaults/config.yaml` 增加：
     - `client.require_phone_binding: true|false`

3. **灰度切换顺序**
   - 先上线迁移与新配置；
   - 再上线新认证接口与中间件增强；
   - 最后切换前端调用到 `/api/c/v1/auth/*`。
