All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
1. 修正 retail_price 架构:
- 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
(上级只能改下级成本价,不能改零售价)
- 新增 PATCH /api/admin/packages/:id/retail-price 接口
(代理自己改自己的零售价,校验 retail_price >= cost_price)
2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
- 删除 config.yaml 中 wechat.official_account 配置节
- 删除 NewOfficialAccountApp() 旧工厂函数
- 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
- 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释
3. 归档四个已完成提案到 openspec/changes/archive/
4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)
5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
172 lines
8.4 KiB
Markdown
172 lines
8.4 KiB
Markdown
# 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/*`。
|