# 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/*`。