Files
junhong_cmp_fiber/openspec/changes/add-payment-config-management/design.md
huang 63ca12393b docs: 新增 OpenSpec 提案 add-payment-config-management
包含 proposal.md、design.md、tasks.md 及各模块 spec 文件(微信配置管理、富友支付、代理充值、订单支付、资产充值适配、微信支付留桩)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:39 +08:00

365 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
当前系统通过环境变量配置微信参数(`pkg/config/config.go` 中的 `WechatConfig` 结构体,包含 `OfficialAccountConfig``PaymentConfig`),在启动时创建全局单例 `PaymentService`,注入到 `order.Service` 中使用。这种模式无法动态切换支付凭证,也不支持富友支付渠道。
公众号 OAuth 配置同样硬编码在环境变量中,与支付配置相互独立。业务上,一套"微信身份"包含公众号 AppID、小程序 AppID 和支付凭证,三者必须原子性切换,否则会出现 OpenID 与 AppID 不匹配的问题。
代理充值模块目前只有 Store 层(`internal/store/postgres/agent_recharge_store.go`),缺少 Service 层和 Handler 层,无法通过 API 发起充值。
## Goals / Non-Goals
**Goals:**
- 在管理后台 CRUD 管理多套微信配置OAuth + 支付),支持微信直连和富友两种渠道
- 全局唯一激活约束:任意时刻最多一个配置生效
- 配置切换秒级生效,不影响在途支付
- 无生效配置时系统降级为仅支持钱包/线下支付
- 接入富友支付的公众号 JSAPI + 小程序支付能力
- 支付回调按订单关联的 `payment_config_id` 验签,解决切换期间的竞态问题
- 代理充值完整实现 Service/Handler/回调
- 所有配置操作记录审计日志
**Non-Goals:**
- 不支持同时激活多个配置进行负载均衡或路由
- 不支持支付宝直连(现有支付宝代码保留不改造)
- 不加密存储敏感字段(明文存储,接口返回时脱敏)
- 不自动检测配置有效性(如证书过期、商户号封禁)
- 不实现富友退款功能(后续按需扩展)
- 不动态加载 OAuth 配置(本次只存不用,`OfficialAccountService` 暂时继续从环境变量读取)
- 不实现客户端支付发起(留桩,另一个会话讨论)
## Decisions
### Decision 1: 表名和提案名称
**选择:`tb_wechat_config`(非 `tb_payment_config`**
OAuth 配置纳入管理后,每套配置代表一套完整的微信身份(公众号 + 小程序 + 支付),切换配置等于原子性切换一切。用 `payment_config` 命名会遗漏 OAuth 语义,用 `wechat_config` 更准确地反映其职责范围。
### Decision 2: 扁平字段按前缀分组
**选择:扁平字段,按前缀分组(`oa_` / `miniapp_` / `wx_` / `fy_`**
替代方案为 JSONB 字段存储不同渠道的参数。选择扁平字段的理由:系统使用者为非技术人员,前端表单可直接映射字段,无需 JSON 编辑器;字段级别的 NOT NULL 约束和验证更明确;不适用的字段留空即可(`provider_type=wechat``fy_*` 字段全部为空)。
### Decision 3: Payment 实例生命周期管理
**选择:按需创建 + Service 层缓存,配置变更时清除**
- 替代方案 A启动时创建单例当前方案—— 无法动态切换
- 替代方案 B每次支付请求都创建新实例 —— 性能浪费
- 选择方案:`PaymentConfigService` 维护当前生效配置的 Payment 实例内存缓存,配置切换时清除缓存,下次请求时按新配置重建实例
**「留桩」期间的过渡说明:**
本次实现后,`WechatPayJSAPI`/`WechatPayH5` 方法加 TODO 注释但保留对 `s.wechatPayment` 单例的调用。这意味着在「留桩」期间:
- `internal/bootstrap/dependencies.go``WechatPayment` 字段**暂时保留**
- `internal/bootstrap/services.go``handlers.go``deps.WechatPayment` 注入**暂时保留**
- 等 WechatPayJSAPI/WechatPayH5 完成动态加载改造后,再统一删除上述字段和注入点
**回调callback不属于留桩范围**:任务 1.6.1 的「留桩」仅指验签逻辑,回调的订单分发(按 payment_config_id 路由)必须完整实现,详见 Decision 5。
### Decision 4: 生效配置缓存策略
**选择Redis 缓存 + 主动失效**
- 缓存 Key**`wechat:config:active`**全项目统一使用此命名spec 文档中任何地方写 `payment:config:active` 均为笔误,以本文档为准),存储完整配置 JSONTTL 5 分钟
- 主动失效:激活/停用/更新/删除配置时主动 DEL 缓存
- 读取流程Redis GET → 命中返回 → MISS 则查 DB → SET 缓存
- 空标记:无配置时缓存 `"none"` TTL 1 分钟,防止缓存穿透
- Redis Key 定义在 `pkg/constants/redis.go``RedisWechatConfigActiveKey()`
### Decision 5: 配置切换时在途订单处理
**选择:不取消在途订单,自然完成或过期**
替代方案为切换时批量取消所有待支付的第三方订单,但用户可能已拉起支付正在输密码,取消订单会导致钱扣了但订单已取消,需要人工退款。
实现方式:
1. `tb_order` 新增 `payment_config_id` 字段nullable钱包/线下支付不需要)
2. `tb_asset_recharge_record` 新增 `payment_config_id` 字段
3. `tb_agent_recharge_record` 新增 `payment_config_id` 字段
4. 下单时记录当前使用的配置 ID
5. 回调处理时,按 `payment_config_id` 加载对应配置进行验签
6. 未支付的旧订单靠现有的 30 分钟超时自动取消机制清理
7. 有待支付订单引用的配置不允许删除(软删除后仍可用于回调验签)
### Decision 6: 富友支付接入方案
**选择:基于 `wxPreCreate` 接口,支持公众号 JSAPI 和小程序支付**
- 接口地址:`POST /wxPreCreate`(生产地址从配置读取)
- 公众号 JSAPI`trade_type=JSAPI``sub_appid=公众号AppID``sub_openid=用户公众号OpenID`
- 小程序支付:`trade_type=LETPAY``sub_appid=小程序AppID``sub_openid=用户小程序OpenID`
- 签名算法RSA + MD5字典序排列参数 → GBK 编码 → MD5 哈希 → RSA 签名 → Base64
- 通信协议XML + GBK 编码 + 双重 URL 编码
- 回调处理GBK → UTF-8 转换 → XML 解析 → RSA 验签
- 代码组织:`pkg/fuiou/` 包,从 cc-coding 项目移植核心逻辑,适配本项目的日志和错误处理规范
### Decision 7: 模块分层设计
**遵循 Handler → Service → Store → Model 分层:**
```
internal/
├── model/
│ ├── wechat_config.go # WechatConfig 模型 + 常量
│ └── dto/
│ ├── wechat_config_dto.go # 配置管理 DTO
│ └── agent_recharge_dto.go # 代理充值 DTO
├── store/postgres/
│ ├── wechat_config_store.go # CRUD + 激活/停用
│ └── agent_recharge_store.go # 已有,需扩展
├── service/
│ ├── wechat_config/service.go # 配置管理业务逻辑
│ └── agent_recharge/service.go # 代理充值业务逻辑(新建)
├── handler/
│ ├── admin/wechat_config.go # 配置管理 Handler
│ ├── admin/agent_recharge.go # 代理充值 Handler新建
│ └── callback/payment.go # 改造:支持富友回调 + 按配置验签
├── routes/
│ ├── wechat_config.go # 配置管理路由
│ └── agent_recharge.go # 代理充值路由(新建)
└── bootstrap/ # 注册新模块
pkg/
└── fuiou/
├── client.go # 富友 HTTP 客户端
└── types.go # 请求/响应结构体
```
### Decision 8: 接口脱敏策略
**敏感字段在 API 响应中脱敏,数据库明文存储:**
| 字段类型 | 脱敏规则 | 示例 |
|---------|---------|------|
| Secret/Key | 显示前4后4中间 `***` | `secr****7890` |
| 证书/私钥(长) | 仅显示状态 | `[已配置]` / `[未配置]` |
更新时:不传或传空字符串 = 不修改,传新值 = 替换。
### Decision 9: 前端支付方式展示
富友支付对用户透明,前端统一显示"微信支付"。后端根据生效配置的 `provider_type` 自动路由到微信直连或富友,前端不感知具体支付渠道。
### Decision 10: OAuth 配置管理策略
OAuth 字段(公众号 AppID/AppSecret、小程序 AppID/AppSecret存入 `tb_wechat_config`。本次只存不用——`OfficialAccountService` 暂时继续从环境变量读取。H5/小程序重构时切换为从数据库动态加载。保证切换配置时 OAuth 和支付 AppID 原子性同步。
**因此,以下代码本次不删除:**
- `pkg/config/config.go` 中的 `OfficialAccountConfig` 结构体(`WechatConfig.OfficialAccount` 字段仍需使用)
- `pkg/config/defaults/config.yaml` 中的 `wechat.official_account` 配置节(`OfficialAccountService` 仍从中读取)
- `cmd/api/main.go``validateWechatConfig` 里对公众号配置的校验逻辑(仅删除支付相关校验)
**以下代码本次必须删除Payment 相关的 YAML 方案完全废弃):**
- `pkg/config/config.go``PaymentConfig` 结构体 + `WechatConfig.Payment` 字段
- `pkg/config/defaults/config.yaml``wechat.payment` 配置节
- `pkg/wechat/config.go``NewPaymentApp(cfg *config.Config, ...)` 函数(从 YAML/CertPath 创建 Payment 实例,被 DB 方案彻底取代)
- `cmd/api/main.go` `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关校验
### Decision 11: 代理充值设计
- 仅充到余额钱包(`wallet_type=main`
- 代理只能充自己店铺,平台可指定任意店铺
- 支付方式:`wechat`(在线)/ `offline`(线下,仅平台,需操作密码)
- 回调处理完整实现,客户端支付发起留桩
### Decision 12: Card → Asset 常量重命名
`pkg/constants/wallet.go``Card*` 前缀统一改为 `Asset*`,原 `Card*` 常量保留为废弃别名(向后兼容)。
影响范围:`CardWalletResourceType*``CardWalletStatus*``CardTransactionType*``CardRechargeOrderPrefix``CardRechargeMinAmount``CardRechargeMaxAmount`
## tb_wechat_config 表结构
```sql
CREATE TABLE tb_wechat_config (
-- 基础字段
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- 配置名称
description TEXT, -- 配置描述
provider_type VARCHAR(20) NOT NULL, -- 支付渠道: wechat / fuiou
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 是否激活
-- OAuth 公众号
oa_app_id VARCHAR(100) NOT NULL, -- 公众号 AppID
oa_app_secret VARCHAR(200) NOT NULL, -- 公众号 AppSecret
oa_token VARCHAR(200), -- 消息验证 Token
oa_aes_key VARCHAR(200), -- 消息加密 AESKey
oa_oauth_redirect_url VARCHAR(500), -- OAuth 回调地址
-- OAuth 小程序
miniapp_app_id VARCHAR(100), -- 小程序 AppID
miniapp_app_secret VARCHAR(200), -- 小程序 AppSecret
-- 支付-微信直连 (provider_type=wechat 时使用)
wx_mch_id VARCHAR(100), -- 商户号
wx_api_v3_key VARCHAR(200), -- API V3 密钥
wx_api_v2_key VARCHAR(200), -- API V2 密钥
wx_cert_content TEXT, -- 证书内容 (Base64)
wx_key_content TEXT, -- 私钥内容 (Base64)
wx_serial_no VARCHAR(200), -- 证书序列号
wx_notify_url VARCHAR(500), -- 支付回调地址
-- 支付-富友 (provider_type=fuiou 时使用)
fy_ins_cd VARCHAR(50), -- 机构号
fy_mchnt_cd VARCHAR(50), -- 商户号
fy_term_id VARCHAR(50), -- 终端号
fy_private_key TEXT, -- 商户 RSA 私钥 (Base64)
fy_public_key TEXT, -- 富友 RSA 公钥 (Base64)
fy_api_url VARCHAR(500), -- API 地址
fy_notify_url VARCHAR(500), -- 回调地址
-- 审计字段
creator BIGINT NOT NULL DEFAULT 0, -- 创建人 ID
updater BIGINT NOT NULL DEFAULT 0, -- 更新人 ID
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- 软删除
);
CREATE INDEX idx_wechat_config_is_active ON tb_wechat_config(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_wechat_config_provider_type ON tb_wechat_config(provider_type) WHERE deleted_at IS NULL;
```
## 流程图
### 配置切换流程
```
管理员调用 PUT /api/admin/wechat-configs/:id/activate
|
+--① BEGIN 事务
| +-- UPDATE tb_wechat_config SET is_active=false WHERE is_active=true
| +-- UPDATE tb_wechat_config SET is_active=true WHERE id=:id
+--② COMMIT
+--③ DEL Redis "wechat:config:active"
+--④ 清除内存中的 Payment 实例缓存
+--⑤ 记录审计日志
|
+-- 新订单 --> 使用新配置
+-- 旧订单(待支付) --> 回调时按 payment_config_id 加载旧配置验签
+-- 30分钟超时自动取消
```
### 改造后的第三方支付流程(两步走)
```
步骤1: H5 创建订单
POST /api/h5/orders
+-- payment_method = "wechat"
+-- 系统查询 active 配置
+-- IF 有配置 --> 记录 payment_config_id 到订单
+-- 返回 order (payment_status=1 待支付)
步骤2: 发起微信支付(留桩)
POST /api/h5/orders/:id/wechat-pay/jsapi
+-- 加载 order.payment_config_id 对应配置
+-- 按 provider_type 分发:
| +-- wechat --> PaymentService.CreateJSAPIOrder()
| +-- fuiou --> FuiouClient.WxPreCreate()
+-- 返回支付参数给前端(本次留桩)
步骤3: 支付回调
POST /api/callback/wechat-pay 或 /api/callback/fuiou-pay
+-- 解析订单号
+-- 按订单号前缀分发:
| +-- "ORD" --> 套餐订单 --> 查 tb_order
| +-- "CRCH" --> 资产充值 --> 查 tb_asset_recharge_record
| +-- "ARCH" --> 代理充值 --> 查 tb_agent_recharge_record
+-- 按 payment_config_id 加载配置
+-- 用对应凭证验签
+-- 调用对应 Service.HandlePaymentCallback()
```
### 代理预充值流程
```
代理/平台 --> POST /api/admin/agent-recharges
|
+-- 验证权限: 代理只能充自己店铺,平台可指定店铺
+-- 验证金额范围 (100元~100万元)
+-- 查找目标店铺的 main 钱包
|
+-- IF payment_method = "wechat"
| +-- 查询 active 配置 --> 无配置则拒绝
| +-- 记录 payment_config_id
| +-- 创建充值订单 (status=1 待支付)
| +-- 返回订单信息(客户端支付发起留桩)
|
+-- IF payment_method = "offline"
+-- 验证是否平台账号 --> 非平台拒绝
+-- 返回订单信息status=1 待支付,等待线下确认)
平台确认线下充值 --> POST /api/admin/agent-recharges/:id/offline-pay
+-- 验证操作密码
+-- 事务内:
| +-- 更新充值订单状态 (status=2 已支付)
| +-- 增加余额钱包余额(乐观锁)
| +-- 创建钱包交易记录
+-- 记录审计日志
```
### 回调统一分发流程
```
回调到达
|
+-- 微信回调 POST /api/callback/wechat-pay
| +-- PowerWeChat SDK 解析 + 取 out_trade_no
|
+-- 富友回调 POST /api/callback/fuiou-pay
| +-- GBK->UTF-8 --> XML解析 --> 取 mchnt_order_no
|
+-- 按订单号前缀分发
|
+-- "ORD" --> 套餐订单
| +-- 查询 tb_order --> 取 payment_config_id
| +-- 加载配置验签
| +-- orderService.HandlePaymentCallback()
|
+-- "CRCH" --> 资产充值(修复:当前代码用废弃的 "RCH"
| +-- 查询 tb_asset_recharge_record --> 取 payment_config_id
| +-- 加载配置验签
| +-- rechargeService.HandlePaymentCallback()
|
+-- "ARCH" --> 代理充值(全新)
+-- 查询 tb_agent_recharge_record --> 取 payment_config_id
+-- 加载配置验签
+-- agentRechargeService.HandlePaymentCallback()
```
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 配置切换后旧回调验签失败 | 用户付了钱但订单未处理 | 订单记录 `payment_config_id`,回调按该字段加载配置验签 |
| Redis 缓存与 DB 不一致 | 短暂使用旧配置 | TTL 5 分钟兜底 + 切换时主动失效 |
| 富友 SDK 是自研非开源 | 维护成本 | 从已验证的 cc-coding 项目移植,核心逻辑已线上运行 |
| 证书 Base64 存 DB 体量大 | 单行数据量增大 | 证书文件通常 2-4KBBase64 后约 3-6KB可接受 |
| 多实例部署缓存一致性 | 某实例使用旧缓存 | Redis 是共享的DEL 操作对所有实例生效;内存缓存通过 Redis MISS 时重建 |
| Card→Asset 常量重命名 | 引用处需要更新 | 旧常量保留为废弃别名,渐进迁移 |
| OAuth 只存不用 | 数据在但不生效 | 明确标注为预留H5 重构时切换 |
## Migration Plan
1. **数据库迁移**:新建 `tb_wechat_config` 表 + `tb_order``tb_asset_recharge_record``tb_agent_recharge_record` 各新增 `payment_config_id`nullable不影响存量数据
2. **常量重命名**`Card*``Asset*`,旧名保留为废弃别名
3. **删除 YAML 支付配置基础设施**(以下操作必须作为独立任务明确执行,不可遗漏):
- `pkg/config/config.go`:删除 `PaymentConfig` 结构体、删除 `WechatConfig.Payment` 字段
- `pkg/config/defaults/config.yaml`:删除 `wechat.payment:` 整个配置节(保留 `wechat.official_account:`OAuth 继续使用)
- `pkg/wechat/config.go`:删除 `NewPaymentApp(cfg *config.Config, ...)` 函数(从文件路径创建 Payment 的方式已被 DB Base64 方案取代)
- `cmd/api/main.go`:删除 `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关的校验代码(保留公众号校验部分)
4. **代码部署**:新代码兼容无配置场景(无生效配置 = 降级为纯钱包支付)
5. **配置迁移**:将当前环境变量中的微信参数手动录入为第一个配置并激活
6. **回滚策略**:回滚代码后,支付流程回退到读取环境变量的单例模式(若 `deps.WechatPayment` 仍存在),新表数据不影响旧逻辑
## Closed Questions
- ~~富友支付是否需要退款?~~ → 暂不包含Non-Goals 已声明)
- ~~是否需要配置变更审计日志?~~ → 必须纳入,复用 `AuditServiceInterface`
- ~~充值模块是否需要改造?~~ → 全部纳入,资产充值 + 代理充值都加 `payment_config_id`
- ~~支付宝如何处理?~~ → 保留不改造
- ~~OpenID 与 AppID 一致性?~~ → OAuth 字段纳入同一配置,切换配置时原子性同步