feat: 实现 C 端完整认证系统(client-auth-system)

实现面向个人客户的 7 个认证接口(A1-A7),覆盖资产验证、
微信公众号/小程序登录、手机号绑定/换绑、退出登录完整流程。

主要变更:
- 新增 PersonalCustomerOpenID 模型,支持多 AppID 多 OpenID 管理
- 实现有状态 JWT(JWT + Redis 双重校验),支持服务端主动失效
- 扩展微信 SDK:小程序 Code2Session + 3 个 DB 动态工厂函数
- 实现 A1 资产验证 IP 限流(30/min)和 A4 三层验证码限流
- 新增 7 个错误码(1180-1186)和 6 个 Redis Key 函数
- 注册 /api/c/v1/auth/* 下 7 个端点并更新 OpenAPI 文档
- 数据库迁移 000083:新建 tb_personal_customer_openid 表
This commit is contained in:
2026-03-19 11:33:41 +08:00
parent ec86dbf463
commit df76e33105
35 changed files with 4348 additions and 1362 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,171 @@
# 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 七个接口:资产验证、微信登录(公众号/小程序)、验证码发送、手机号绑定、手机号换绑、退出登录。
- 建立有状态 JWTJWT + Redis机制支持服务端主动失效。
- 建立 OpenID 多记录模型,兼容公众号与小程序不同 AppID 场景。
### Non-Goals
- 不实现业务域 API如充值、套餐、订单等
- 不包含兑换系统exchange相关设计与实现。
## Decisions
### 1) asset_token 设计
- 方案:`asset_token` 使用短时效 JWT5 分钟payload 仅包含 `asset_type` + `asset_id`,并使用独立于主登录 JWT 的签名密钥。
- WhyA1 是无认证接口,若直接暴露内部 `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
- A1IP 级限频 `30/min`
- A4手机号维度 `60s` 冷却 + IP 维度 `20/hour` + 手机号维度 `10/day`
- WhyA1 主要防资产暴力枚举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/*`

View File

@@ -0,0 +1,135 @@
## Why
系统需要一套面向个人客户C 端)的完整认证体系,替代已删除的旧 H5 登录接口。客户端(微信公众号 H5 / 微信小程序)的登录流程与 B 端完全不同:基于**资产标识符**而非用户账号密码,先验证资产 → 再微信授权 → 自动绑定资产 → 可选绑定手机号。同时,公众号和小程序可能使用不同 AppID 且不一定绑定同一微信开放平台,需要支持多 OpenID 管理。
**前置依赖**:提案 0`client-api-data-model-fixes`)已完成 PersonalCustomer.wx_open_id 索引变更和旧接口删除。
## What Changes
### 新增模型
- **PersonalCustomerOpenID**:个人客户 OpenID 关联表,支持同一客户在不同 AppID公众号/小程序)下的多 OpenID 记录。唯一索引 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
### 认证接口(`/api/c/v1/auth/`
- **A1 验证资产标识符** `POST /verify-asset`:无需认证。输入 SN/IMEI/虚拟号/ICCID/MSISDN → 返回 `asset_token`(短时效 JWT5 分钟过期payload 含 asset_type + asset_id。IP 级别限频30 次/分钟)防暴力枚举。不暴露内部 asset_id
- **A2 微信公众号登录** `POST /wechat-login`:无需认证。用微信 OAuth code + asset_token → 查找/创建客户 → 绑定资产 → 签发有状态 JWT TokenRedis 存储)→ 返回 token + 是否需要绑定手机号
- **A3 微信小程序登录** `POST /miniapp-login`:无需认证。用小程序 jscode2session + asset_token → 同 A2 后续流程
- **A4 发送验证码** `POST /send-code`:无需认证。限频:同手机号 60s、同 IP 20 次/小时、每手机号 10 次/天
- **A5 绑定手机号** `POST /bind-phone`:需 JWT。首次绑定检查重复
- **A6 换绑手机号** `POST /change-phone`:需 JWT。双重验证码旧+新手机)
- **A7 退出登录** `POST /logout`:需 JWT。删除 Redis token 记录
### 基础设施
- **有状态 JWT Token 管理**JWT payload 仅含 `customer_id` + `exp`Redis 存储 token 有效状态,支持服务端主动失效(封禁/强制下线)
- **PersonalAuthMiddleware 增强**:增加 Redis 有效性检查token 不在 Redis 中则拒绝
- **统一资产解析公共方法** `resolveAssetFromIdentifier()`:个人客户调用不走 shop_id 数据权限过滤
- **OpenID 安全规范**:所有需要 OpenID 的接口支付、充值OpenID 由后端根据 `customer_id` + `app_type` 查 PersonalCustomerOpenID 表获取,禁止客户端传入
- **手机号绑定配置**:通过 Viper 配置 `client.require_phone_binding`boolean登录时检查并返回 `need_bind_phone` 标识
### 登录完整流程
```
用户打开客户端
输入资产标识符SN/IMEI/虚拟号/ICCID
[A1] POST /verify-asset ──→ 返回 asset_token5分钟有效
微信授权(前端完成)
├─── 公众号 ──→ [A2] POST /wechat-login (code + asset_token)
└─── 小程序 ──→ [A3] POST /miniapp-login (code + asset_token)
┌──────────────────┐
│ 解析 asset_token │
│ 获取微信 openid │
│ 查找/创建客户 │
│ 绑定资产 │
│ 签发 JWT + Redis │
└──────┬───────────┘
返回 { token, need_bind_phone, is_new_user }
need_bind_phone == true?
│ │
YES NO
│ │
▼ ▼
[A4] 发送验证码 进入主页面
[A5] 绑定手机号
进入主页面
```
### 客户查找/创建逻辑A2/A3 共享)
```
收到 openid + (可选)unionid
查 PersonalCustomerOpenID WHERE app_id=当前AppID AND open_id=openid
├── 找到 → 获取 customer_id → 已有客户
└── 没找到
有 unionid
├── YES → 查 PersonalCustomerOpenID WHERE union_id=unionid
│ │
│ ├── 找到 → 获取 customer_id → 新增当前 AppID 的 openid 记录
│ │
│ └── 没找到 → 创建新客户 + openid 记录
└── NO → 创建新客户 + openid 记录
```
## Capabilities
### New Capabilities
- `client-asset-token`资产验证令牌机制。A1 接口、asset_token JWT 生成/验证、IP 限频、安全规范(不暴露 asset_id
- `client-wechat-login`:微信登录(公众号+小程序。A2/A3 接口、OAuth/jscode2session 对接、客户查找/创建/合并逻辑、资产绑定(**首次绑定时触发 `asset_status` 从 1→2**、OpenID 多记录管理
- `client-phone-bindng`:手机号绑定/换绑。A4/A5/A6 接口、验证码发送/校验、限频规则、绑定/换绑逻辑
- `client-token-management`:有状态 JWT Token 管理。签发、Redis 存储、有效性检查、退出登录A7、服务端主动失效
- `personal-customer-openid`PersonalCustomerOpenID 模型定义、唯一索引、与 PersonalCustomer 的关系
### Modified Capabilities
- `personal-customer`PersonalCustomer 模型行为变化——登录逻辑从手机号+验证码改为微信授权wx_open_id 字段保留但逻辑迁移到 PersonalCustomerOpenID 表
- `asset-lifecycle-status`:首次客户绑定资产时,`asset_status` 从 1在库自动更新为 2已销售使用条件更新确保幂等
- `wechat-official-account`OAuth 配置来源变化——从 YAML 静态配置改为从 WechatConfig 表动态读取公众号/小程序 AppID+AppSecret
### 微信 SDK 使用说明
本提案使用项目中已有的微信 SDK`pkg/wechat/`,基于 PowerWeChat v3同时需要扩展小程序能力
| 场景 | SDK 方法 | 文件 | 状态 |
|------|---------|------|------|
| A2 公众号登录 | `OfficialAccountService.GetUserInfoDetailed(code)` | `pkg/wechat/official_account.go:69` | ✅ 已有,直接复用 |
| A3 小程序登录 | `MiniAppService.Code2Session(code)` | `pkg/wechat/miniapp.go` | ❌ **需新建**,直接 HTTP 调用微信 jscode2session |
| SDK 实例创建 | `NewOfficialAccountAppFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增**,从 DB 动态创建 |
| SDK 实例创建 | `NewMiniAppServiceFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增** |
| SDK 实例创建 | `NewPaymentAppFromConfig(wechatConfig, appID)` | `pkg/wechat/config.go` | ❌ **需新增**,供提案 2 支付使用 |
**现有 `NewOfficialAccountApp(cfg)` 从 YAML 创建实例,客户端场景需要从 `tb_wechat_config` DB 动态加载。**
## Impact
- **新增文件**`internal/model/personal_customer_openid.go`(模型)、`internal/handler/app/client_auth.go`(认证 Handler`internal/service/client_auth/service.go`(认证 Service`internal/store/postgres/personal_customer_openid_store.go`Store、**`pkg/wechat/miniapp.go`(小程序 SDK 封装)**、DTO 文件、迁移文件、常量定义
- **修改文件**`internal/middleware/personal_auth.go`(增加 Redis 检查)、`internal/routes/personal.go`(新增路由)、`internal/bootstrap/`(注册新模块)、`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)、`pkg/config/defaults/config.yaml`(新增 client 配置节)、`internal/model/system.go`AutoMigrate 注册新模型)、**`pkg/wechat/config.go`(新增 3 个 DB 动态工厂函数)**、**`pkg/wechat/wechat.go`(新增 MiniAppServiceInterface**
- **新增 API 路由**`/api/c/v1/auth/` 下 7 个端点
- **数据库变更**:新建 `tb_personal_customer_openid`
- **新增依赖**:无(微信 SDK 已有 PowerWeChat v3小程序 jscode2session 为纯 HTTP 调用)
- **配置变更**config.yaml 新增 `client.require_phone_binding` 配置项

View File

@@ -0,0 +1,71 @@
# client-asset-token Specification
## ADDED Requirements
### Requirement: A1 资产标识符验证接口
系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`
- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset`
- 请求体字段:
- `identifier` stringMUST资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
- 响应体字段:
- `asset_token` stringMUST5 分钟有效
- `expires_in` intMUST单位秒
- 错误码:
- `1006` 参数错误(标识符为空或格式非法)
- `1404` 资产不存在
- `1003` 请求过于频繁
#### Scenario: 资产验证成功并返回 asset_token
- **WHEN** 客户端提交合法且存在的资产标识符
- **THEN** 系统 SHALL 解析并定位资产
- **THEN** 系统 SHALL 签发 5 分钟有效的 `asset_token`
- **THEN** 系统 SHALL 返回 `{asset_token, expires_in}`
#### Scenario: 输入参数非法
- **WHEN** 客户端提交空字符串或不支持格式的标识符
- **THEN** 系统 MUST 返回参数错误码 `1006`
### Requirement: A1 输入校验与安全约束
系统 SHALL 对标识符进行白名单校验,并在 A1 响应中禁止暴露内部 `asset_id`
- 输入校验规则:
- MUST 去除前后空格并做长度限制
- MUST 仅允许预定义字符集(数字、字母、必要分隔符)
- MUST 拒绝 SQL 片段/控制字符
- 输出安全规则:
- MUST NOT 返回 `asset_id`
- MUST NOT 返回内部表名/字段名
#### Scenario: 防止内部主键泄露
- **WHEN** A1 接口返回成功响应
- **THEN** 返回体 MUST 只包含 `asset_token` 与有效期信息
- **THEN** 返回体 MUST NOT 包含 `asset_id`
### Requirement: A1 资产令牌签发规范
`asset_token` SHALL 使用独立签名密钥签发,且 payload 仅包含 `asset_type``asset_id`
- JWT 约束:
- `exp` = 当前时间 + 5 分钟
- payload MUST 包含 `asset_type``asset_id`
- payload MUST NOT 包含手机号、OpenID 等敏感信息
#### Scenario: token 结构与时效符合规范
- **WHEN** 服务端签发 `asset_token`
- **THEN** token MUST 使用资产令牌专用签名密钥
- **THEN** token MUST 在 5 分钟后过期
### Requirement: A1 IP 级限频
系统 SHALL 对 A1 实施 IP 维度限频:`30 次/分钟`
#### Scenario: 限频内请求通过
- **WHEN** 同一 IP 在 1 分钟内请求次数不超过 30 次
- **THEN** 系统 SHALL 正常处理请求
#### Scenario: 超过限频阈值
- **WHEN** 同一 IP 在 1 分钟内请求次数超过 30 次
- **THEN** 系统 MUST 返回错误码 `1003`

View File

@@ -0,0 +1,94 @@
# client-phone-binding Specification
## ADDED Requirements
### Requirement: A4 发送验证码接口
系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。
- HTTP Method + Path: `POST /api/c/v1/auth/send-code`
- 请求体字段:
- `phone` stringMUST手机号
- `scene` stringMUST业务场景`bind_phone` / `change_phone_old` / `change_phone_new`
- 响应体字段:
- `cooldown_seconds` intMUST本次发送后的冷却秒数
- 错误码:
- `1006` 参数错误
- `1003` 请求过于频繁(触发任一限流)
- `1050` 短信发送失败
#### Scenario: 发送成功
- **WHEN** 手机号格式合法且未触发限流
- **THEN** 系统 SHALL 发送验证码并返回冷却时间
### Requirement: A4 限频规则
系统 SHALL 对 A4 实施三层限频:手机号 60 秒冷却、同 IP 每小时 20 次、同手机号每日 10 次。
#### Scenario: 60 秒内重复发送
- **WHEN** 同一手机号在 60 秒冷却内再次请求
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同 IP 超过小时阈值
- **WHEN** 同一 IP 在 1 小时内发送次数超过 20
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同手机号超过日阈值
- **WHEN** 同一手机号在当日发送次数超过 10
- **THEN** 系统 MUST 返回 `1003`
### Requirement: A5 首次绑定手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/bind-phone`,仅允许首次绑定。
- HTTP Method + Path: `POST /api/c/v1/auth/bind-phone`
- 请求体字段:
- `phone` stringMUST新手机号
- `code` stringMUST验证码
- 响应体字段:
- `phone` stringMUST已绑定手机号
- `bound_at` stringMUST绑定时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 手机号已被绑定
- `1038` 已绑定手机号不可重复绑定
#### Scenario: 首次绑定成功
- **WHEN** 客户已登录、验证码正确且手机号未被占用
- **THEN** 系统 SHALL 完成手机号首次绑定并返回绑定信息
#### Scenario: 已绑定用户再次调用绑定
- **WHEN** 当前客户已存在绑定手机号
- **THEN** 系统 MUST 返回 `1038`
### Requirement: A6 换绑手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/change-phone`,并执行旧手机号与新手机号双验证码校验。
- HTTP Method + Path: `POST /api/c/v1/auth/change-phone`
- 请求体字段:
- `old_phone` stringMUST旧手机号
- `old_code` stringMUST旧手机号验证码
- `new_phone` stringMUST新手机号
- `new_code` stringMUST新手机号验证码
- 响应体字段:
- `phone` stringMUST换绑后的手机号
- `changed_at` stringMUST换绑时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 新手机号已被绑定
- `1039` 旧手机号不匹配
#### Scenario: 换绑成功
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
#### Scenario: 旧手机号校验失败
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码

View File

@@ -0,0 +1,57 @@
# client-token-management Specification
## ADDED Requirements
### Requirement: 登录 JWT 签发与 Redis 状态存储
系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT并将 token 状态写入 Redis。
- JWT payload 字段:
- `customer_id` uintMUST
- `exp` int64MUST
- Redis Key`RedisPersonalCustomerTokenKey(customerID)`
- Redis Value当前有效 token或 token 集合,取决于实现)
- TTLMUST 与 JWT 过期时间一致
#### Scenario: 登录成功写入 Redis
- **WHEN** 客户完成微信登录
- **THEN** 系统 SHALL 签发 JWT
- **THEN** 系统 SHALL 将 token 写入 Redis 并设置 TTL
### Requirement: PersonalAuthMiddleware 双重校验
系统 SHALL 在个人客户认证中间件执行双重校验JWT 解析校验 + Redis 状态校验。
#### Scenario: JWT 与 Redis 均有效
- **WHEN** 请求携带有效 JWT 且 Redis 中存在有效状态
- **THEN** 中间件 SHALL 放行并写入 `customer_id` 到上下文
#### Scenario: JWT 有效但 Redis 不存在
- **WHEN** JWT 仍在有效期但 Redis 中不存在该客户 token 状态
- **THEN** 中间件 MUST 返回未认证错误 `1002`
### Requirement: A7 退出登录接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/logout`,用于删除 Redis token 状态。
- HTTP Method + Path: `POST /api/c/v1/auth/logout`
- 请求体字段:无
- 响应体字段:
- `success` boolMUST
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
#### Scenario: 退出登录成功
- **WHEN** 登录客户调用 A7
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
- **THEN** 系统 SHALL 返回成功
### Requirement: 服务端主动失效能力
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
#### Scenario: 服务端主动踢出
- **WHEN** 管理动作触发客户强制下线
- **THEN** 系统 SHALL 删除对应 Redis token 状态
- **THEN** 该客户后续请求 MUST 被中间件拒绝

View File

@@ -0,0 +1,105 @@
# client-wechat-login Specification
## ADDED Requirements
### Requirement: A2 微信公众号登录接口
系统 MUST 提供 `POST /api/c/v1/auth/wechat-login`,使用公众号 OAuth code + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/wechat-login`
- 请求体字段:
- `code` stringMUST微信 OAuth 授权码
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST是否需要绑定手机号
- `is_new_user` boolMUST是否新创建用户
- 错误码:
- `1002` token 无效或过期asset_token/JWT
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 公众号登录成功
- **WHEN** 客户端提交有效 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用公众号 OAuth 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行客户查找/创建/合并逻辑
- **THEN** 系统 SHALL 绑定资产并签发登录 token
### Requirement: A3 微信小程序登录接口
系统 MUST 提供 `POST /api/c/v1/auth/miniapp-login`,使用小程序 `jscode2session` + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/miniapp-login`
- 请求体字段:
- `code` stringMUST小程序登录凭证
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST
- `is_new_user` boolMUST
- 错误码:
- `1002` token 无效或过期
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 小程序登录成功
- **WHEN** 客户端提交有效小程序 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用 `jscode2session` 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行与 A2 一致的客户查找/创建/合并、资产绑定与签发逻辑
### Requirement: asset_token 校验与资产解析
系统 SHALL 在 A2/A3 登录前强制校验 `asset_token`,并解析出 `asset_type` + `asset_id`
#### Scenario: asset_token 无效
- **WHEN** `asset_token` 签名不合法或已过期
- **THEN** 系统 MUST 拒绝登录并返回 `1002`
#### Scenario: asset_token 有效
- **WHEN** `asset_token` 可被成功解析
- **THEN** 系统 SHALL 使用解析出的资产信息继续登录流程
### Requirement: 客户查找/创建/合并逻辑
系统 MUST 按以下顺序处理客户归属:
1. 先查 `PersonalCustomerOpenID``(app_id, open_id)`
2. 未命中且存在 `unionid` 时按 `unionid` 回查并复用客户;
3. 仍未命中时创建新 `PersonalCustomer` 与 OpenID 记录。
#### Scenario: openid 命中既有客户
- **WHEN** `(app_id, open_id)` 已存在
- **THEN** 系统 SHALL 直接复用对应 `customer_id`
#### Scenario: openid 未命中但 unionid 命中
- **WHEN** `(app_id, open_id)` 不存在且 `unionid` 命中历史记录
- **THEN** 系统 SHALL 复用已存在客户
- **THEN** 系统 SHALL 新增当前 `app_id + open_id` 记录
#### Scenario: openid/unionid 均未命中
- **WHEN** 无任何匹配记录
- **THEN** 系统 SHALL 创建新客户并写入 OpenID 记录
### Requirement: 登录后资产绑定
系统 SHALL 在 A2/A3 每次登录时创建一条 `PersonalCustomerDevice` 绑定记录,且 MUST 允许同一资产被多个客户绑定。
#### Scenario: 已有绑定时再次登录
- **WHEN** 同一客户再次登录同一资产
- **THEN** 系统 SHALL 记录本次登录绑定关系(按实现可去重或追加历史)
#### Scenario: 不同客户绑定同一资产
- **WHEN** 资产已被其他客户绑定
- **THEN** 系统 MUST 允许新增绑定,不得覆盖已有客户绑定关系
### Requirement: 登录响应与手机号绑定开关
系统 MUST 在登录响应中返回 `need_bind_phone`,该值由 `client.require_phone_binding` 与客户手机号绑定状态共同决定。
#### Scenario: 要求手机号绑定且未绑定
- **WHEN** 配置 `client.require_phone_binding=true` 且客户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
#### Scenario: 已绑定手机号或配置关闭
- **WHEN** 客户已绑定手机号或 `client.require_phone_binding=false`
- **THEN** 登录响应 MUST 返回 `need_bind_phone=false`

View File

@@ -0,0 +1,37 @@
# personal-customer-openid Specification
## ADDED Requirements
### Requirement: PersonalCustomerOpenID 模型定义
系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。
- 关键字段:
- `id` uint主键
- `customer_id` uintMUST关联个人客户 ID
- `app_id` stringMUST微信应用标识
- `open_id` stringMUST当前应用下 OpenID
- `union_id` string可选开放平台统一标识
- `created_at`/`updated_at`/`deleted_at`
- 索引约束:
- MUST 存在唯一索引 `UNIQUE(app_id, open_id)`(软删条件下唯一)
#### Scenario: 新增 OpenID 记录成功
- **WHEN** 登录流程创建新 OpenID 关系
- **THEN** 系统 SHALL 插入一条包含 `customer_id/app_id/open_id` 的记录
#### Scenario: 重复 app_id + open_id 被拒绝
- **WHEN** 试图插入已存在的 `(app_id, open_id)` 组合
- **THEN** 系统 MUST 触发唯一约束并拒绝写入
### Requirement: 与 PersonalCustomer 的关系约束
系统 SHALL 通过 `customer_id``PersonalCustomer` 建立逻辑关联(不使用数据库外键约束)。
#### Scenario: 根据 customer_id 查询 OpenID 列表
- **WHEN** 业务根据 `customer_id` 查询 OpenID
- **THEN** 系统 SHALL 返回该客户在多 AppID 下的全部有效记录
#### Scenario: 软删除客户后的记录处理
- **WHEN** 客户逻辑删除或状态失效
- **THEN** 系统 MUST 支持按业务策略同步停用或软删除 OpenID 记录

View File

@@ -0,0 +1,52 @@
# personal-customer Specification
## MODIFIED Requirements
### Requirement: 个人客户登录主流程改为微信授权
系统 SHALL 将个人客户登录主流程从“手机号 + 验证码登录”调整为“资产验证 + 微信授权登录”。
- 新登录入口:
- `POST /api/c/v1/auth/verify-asset`A1无认证
- `POST /api/c/v1/auth/wechat-login`A2无认证
- `POST /api/c/v1/auth/miniapp-login`A3无认证
- 请求与响应要点:
- A2/A3 请求体 MUST 包含 `code``asset_token`
- A2/A3 响应体 MUST 包含 `token``need_bind_phone``is_new_user`
- 错误码:
- `1006` 参数错误
- `1002` token 无效或过期
- `1040` 微信授权失败
#### Scenario: 通过微信授权完成登录
- **WHEN** 用户先完成 A1再提交 A2 或 A3
- **THEN** 系统 SHALL 完成客户识别/创建、资产绑定并返回登录 token
#### Scenario: 不再支持旧手机号直登入口
- **WHEN** 客户端调用旧手机号登录路径(如 `/api/c/v1/login`
- **THEN** 系统 MUST 按新路由规范拒绝或迁移提示,不再作为主登录路径
### Requirement: 手机号从“登录凭据”调整为“登录后补充资料”
系统 MUST 将手机号能力调整为登录后绑定/换绑,而非登录入口。
- 相关接口:
- `POST /api/c/v1/auth/send-code`A4无认证
- `POST /api/c/v1/auth/bind-phone`A5需认证
- `POST /api/c/v1/auth/change-phone`A6需认证
- 响应字段:
- A5/A6 MUST 返回绑定后的 `phone`
#### Scenario: 首次登录后要求绑定手机号
- **WHEN** `client.require_phone_binding=true` 且用户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
- **THEN** 用户通过 A4+A5 完成绑定后进入业务页面
### Requirement: 微信身份字段迁移到 OpenID 关联能力
系统 SHALL 保留 `PersonalCustomer.wx_open_id``wx_union_id` 字段的兼容性,但新登录链路 MUST 以 `PersonalCustomerOpenID` 为主。
#### Scenario: 读取用户微信身份
- **WHEN** 登录流程需要按微信身份识别客户
- **THEN** 系统 MUST 优先查询 `PersonalCustomerOpenID`
- **THEN** 不再依赖 `PersonalCustomer` 单字段承载多 AppID 场景

View File

@@ -0,0 +1,47 @@
# wechat-official-account Specification
## MODIFIED Requirements
### Requirement: 微信配置源从 YAML 改为数据库动态读取
系统 MUST 将公众号/小程序授权配置源从 YAML 静态配置切换为数据库 `tb_wechat_config` 动态读取(`is_active=true`)。
- 配置读取规则:
- 公众号登录A2使用 `app_id` + `app_secret`
- 小程序登录A3使用 `miniapp_app_id` + `miniapp_app_secret`
- 适配接口:
- `POST /api/c/v1/auth/wechat-login`
- `POST /api/c/v1/auth/miniapp-login`
#### Scenario: 公众号登录读取数据库配置
- **WHEN** 调用 A2 执行 OAuth code 换取 OpenID
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活公众号配置
#### Scenario: 小程序登录读取数据库配置
- **WHEN** 调用 A3 执行 jscode2session
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活小程序配置
### Requirement: 配置缺失或无激活记录时失败
系统 MUST 在缺少有效数据库配置时拒绝微信登录请求,并返回统一错误。
- 错误码:
- `1041` 微信配置不可用
- `1040` 微信授权失败(第三方调用失败)
#### Scenario: 无激活配置
- **WHEN** `tb_wechat_config` 中不存在 `is_active=true` 记录
- **THEN** 系统 MUST 返回 `1041`
#### Scenario: 配置存在但第三方调用失败
- **WHEN** 已获取数据库配置但调用微信接口失败
- **THEN** 系统 MUST 返回 `1040`
### Requirement: 旧 YAML 配置不再作为登录凭据来源
系统 SHALL 停止在登录链路中使用 `wechat.official_account.*` 静态配置作为 AppID/AppSecret 来源。
#### Scenario: 配置切换后行为一致
- **WHEN** 运维在数据库中更新激活配置
- **THEN** 后续登录请求 SHALL 使用新配置生效
- **THEN** 无需重启服务加载 YAML

View File

@@ -0,0 +1,70 @@
# client-auth-system 实施任务清单
## 1. 模型与迁移
- [x] 1.1 新增 `internal/model/personal_customer_openid.go`,定义 PersonalCustomerOpenID 模型与 TableName
- [x] 1.2 创建迁移文件,新增 `tb_personal_customer_openid` 表及 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL` 索引
- [x] 1.3 在 `internal/model/system.go` 注册新模型以纳入 AutoMigrate
- [x] 1.4 更新 `pkg/config/defaults/config.yaml`,新增 `client.require_phone_binding` 配置项
## 2. PersonalAuthMiddleware 增强
- [x] 2.1 在 `pkg/constants/redis.go` 新增 `RedisPersonalCustomerTokenKey(customerID)` 常量函数
- [x] 2.2 增强 `internal/middleware/personal_auth.go`,增加 JWT + Redis 双重校验
- [x] 2.3 完成 token 不在 Redis 时的拒绝逻辑与统一错误返回
## 3. 资产验证令牌(A1)
- [x] 3.1 新增认证 DTOA1 请求/响应)并补齐 OpenAPI 标签
- [x] 3.2 新增 `internal/handler/app/client_auth.go``VerifyAsset` Handler
- [x] 3.3 新增 `internal/service/client_auth/service.go` 的资产解析与 `asset_token` 签发逻辑5 分钟)
- [x] 3.4 实现 A1 IP 限流30/min与错误码映射
## 4. 微信 SDK 扩展(小程序 + 动态配置工厂)
- [x] 4.1 新增 `pkg/wechat/miniapp.go`:定义 `MiniAppService` 结构体 + `MiniAppServiceInterface` 接口 + `Code2Session(ctx, code)` 方法(直接 HTTP 调用微信 `jscode2session` 接口,不依赖 PowerWeChat SDK
- [x] 4.2 在 `pkg/wechat/wechat.go` 中新增 `MiniAppServiceInterface` 接口定义和编译时类型检查 `var _ MiniAppServiceInterface = (*MiniAppService)(nil)`
- [x] 4.3 在 `pkg/wechat/config.go` 中新增 `NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache, logger)` 工厂函数——从 DB 记录的 `oa_app_id` + `oa_app_secret` 创建公众号实例(复用 PowerWeChat `officialAccount.NewOfficialAccount`
- [x] 4.4 在 `pkg/wechat/config.go` 中新增 `NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger)` 工厂函数——从 DB 记录的 `miniapp_app_id` + `miniapp_app_secret` 创建小程序服务
- [x] 4.5 在 `pkg/wechat/config.go` 中新增 `NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache, logger)` 工厂函数——从 DB 记录创建支付实例,`appID` 参数决定关联应用(公众号/小程序)
## 5. 微信登录(A2+A3)
- [x] 5.1 新增 A2/A3 请求响应 DTO公众号与小程序
- [x] 5.2 在 `client_auth/service.go` 中实现动态读取 `tb_wechat_config WHERE is_active=true` 的配置加载逻辑(优先走 WechatConfigService 的 Redis 缓存)
- [x] 5.3 实现公众号登录(A2):调用 `NewOfficialAccountAppFromConfig``NewOfficialAccountService``GetUserInfoDetailed(code)` 获取 openid+unionid+昵称+头像(复用现有 `official_account.go` 的方法,不重新实现)
- [x] 5.4 实现小程序登录(A3):调用 `NewMiniAppServiceFromConfig``Code2Session(code)` 获取 openid+unionid+sessionKey昵称/头像从请求体获取
- [x] 5.5 实现客户查找/创建/合并逻辑openid 优先unionid 回退)
- [x] 5.6 新增 `internal/store/postgres/personal_customer_openid_store.go` 与相关查询/写入方法
- [x] 5.7 实现每次登录创建 PersonalCustomerDevice 绑定记录(允许同资产多客户);**首次绑定时**(该资产此前无任何 PersonalCustomerDevice 记录),将资产的 `asset_status` 从 1在库更新为 2已销售使用条件更新 `WHERE asset_status = 1` 确保幂等(已是 2 或其他状态则不变)
- [x] 5.8 实现登录 JWT 签发、Redis 存储与 `need_bind_phone` 计算
## 6. 验证码与手机号(A4+A5+A6)
- [x] 6.1 复用现有验证码服务(`internal/service/verification/service.go``SendCode`)实现 A4 发送验证码
- [x] 6.2 实现 A4 限流:手机号 60s、IP 20/hour、手机号 10/day
- [x] 6.3 实现 A5 首次绑定手机号逻辑(已绑定拒绝)
- [x] 6.4 实现 A6 双验证码换绑逻辑(旧手机号+新手机号)
- [x] 6.5 增补手机号绑定/换绑错误码与中文错误信息
## 7. 退出登录(A7)
- [x] 7.1 新增 A7 请求响应 DTO
- [x] 7.2 实现 `POST /api/c/v1/auth/logout` Handler 与 Service
- [x] 7.3 在 A7 中删除 `RedisPersonalCustomerTokenKey(customerID)` 完成服务端失效
## 8. 路由注册与文档
- [x] 8.1 在 `internal/bootstrap/types.go` 增加 ClientAuth Handler 字段
- [x] 8.2 在 `internal/bootstrap/handlers.go` 实例化 ClientAuth Handler
- [x] 8.3 在 `internal/routes/personal.go` 使用 `Register()` 注册 `/api/c/v1/auth/*` 七个端点
- [x] 8.4 在 `cmd/api/docs.go` 注册新 Handler 供文档生成器使用
- [x] 8.5 在 `cmd/gendocs/main.go` 注册新 Handler 供文档生成器使用
- [x] 8.6 执行 `go run cmd/gendocs/main.go` 并确认新接口出现在 OpenAPI 文档
## 9. 验证
- [x] 9.1 执行 `go build ./...`,确保构建通过
- [x] 9.2 运行 `lsp_diagnostics`,确保修改文件无错误
- [x] 9.3 按数据库验证规范检查新表与索引存在且结构正确
- [x] 9.4 在 `docs/client-auth-system/` 补充中文功能总结文档