## 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` 均为笔误,以本文档为准),存储完整配置 JSON,TTL 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-4KB,Base64 后约 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 字段纳入同一配置,切换配置时原子性同步