支付与订单子系统
支付与网关用量计费是两条不同链路:网关计费结算一次模型请求;支付子系统把外部资金转换为用户余额或订阅权益。
支付设计的核心不是“生成二维码”,而是确保回调可信、订单状态单向推进、重复通知不重复履约、错过回调仍可对账、退款能够追溯。
组件地图
用户支付页面
│ 创建订单
▼
Payment Handler ──▶ PaymentService ──▶ payment_orders
│
├─ Provider Registry / Load Balancer
│ └─ 支付 Provider API
│
支付平台 Webhook ──────────┤ 验签、金额与订单校验
│
├─ Balance Fulfillment ──▶ 用户余额
├─ Subscription Fulfillment ──▶ 用户订阅
└─ Audit / Refund / Reconcile主要代码位于:
backend/internal/payment/:Provider 接口、类型和具体实现。backend/internal/service/payment_*.go:订单、履约、回调、退款、配置和统计。backend/internal/handler/payment*.go:用户与 Webhook HTTP 适配。backend/internal/handler/admin/payment_handler.go:后台订单和 Provider 管理。backend/internal/server/routes/payment.go:三类路由边界。
三类路由
| 路由 | 调用者 | 鉴权 |
|---|---|---|
/api/v1/payment/* | 已登录用户 | JWT + backend mode guard |
/api/v1/payment/public/* | 支付结果恢复页 | 签名 resume token 或兼容查询约束 |
/api/v1/payment/webhook/* | 支付平台 | Provider 签名,不使用用户 JWT |
/api/v1/admin/payment/* | 管理员 | AdminAuth + compliance guard |
公开结果页不应因此拥有任意订单查询能力。新增恢复字段时必须签名或从服务端持久状态解析,不能把用户 ID、金额等客户端参数直接当成事实。
Provider 实例
支付方式不是一个全局静态配置。payment_provider_instances 可以保存多个 Provider 实例,运行时通过 Load Balancer 选择,并解密实例配置创建 Provider 对象。
PaymentService 维护进程内 Provider Registry:
- 首次使用时 lazy load 已启用实例。
- 后台增删改 Provider 后调用
RefreshProviders()。 - Webhook 会优先根据订单绑定/快照选择能够验签的 Provider。
多实例环境要特别关注 Registry 刷新传播。一次后台保存直接刷新的是处理该请求的进程;没有通用跨实例事件时,其他副本可能仍持有旧 Registry。高风险 Provider 变更应验证每个副本,或通过滚动重启确保一致。
订单创建
创建订单前会检查:
- 支付系统与对应方式是否启用。
- 订单类型是余额充值还是订阅购买。
- 金额/套餐、币种、费率和最小最大限制。
- 用户待支付订单数量等限制。
- Provider 可用性和终端环境,如移动端、微信浏览器。
订单写入 PostgreSQL 后再调用 Provider 创建支付。关键字段包括:
- 内部订单 ID。
out_trade_no外部订单号。- 用户、金额、实付金额和币种。
order_type与 plan ID。- payment type/provider instance。
- Provider 快照和返回的 trade/intent 信息。
- 过期时间和恢复 token 所需上下文。
订单快照很重要:管理员后续修改 Provider 配置时,历史订单仍要知道由哪个实例创建和验签。
正向状态机
pending
├─ 用户取消 ─────────────▶ cancelled
├─ 超时任务 ─────────────▶ expired
├─ 创建/处理失败 ─────────▶ failed
└─ 支付确认 ──────────────▶ paid
│ 条件更新获得履约权
▼
recharging
├─ 成功 ─▶ completed
└─ 失败 ─▶ failed ──管理员 retry──┐
└─▶ recharging状态转换使用条件更新防止多个回调或查询同时取得履约权。completed 是余额/订阅已经实际交付,不只是支付平台说“成功”。
取消订单前会主动查询 Provider,避免“平台已经支付、Webhook 尚未到达”时错误取消。本地过期也不等于支付平台永远未支付;系统保留有限 grace 处理晚到的成功通知。
Webhook 处理链
不同 Provider 的 Handler 负责读取原始请求并验签,统一通知进入:
Provider 验签
→ 标准化 PaymentNotification
→ 按 out_trade_no 查询订单
→ 校验订单绑定的 Provider
→ 校验 Provider metadata
→ 校验 amount 有效且与 pay_amount 匹配
→ 条件更新为 paid
→ 写 ORDER_PAID 审计
→ 执行余额或订阅履约金额比较按币种 minor unit 使用容差,而不是直接比较未经约束的浮点输入。无效、NaN/Inf、Provider 不匹配和 metadata 不匹配都会拒绝并写审计。
找不到本环境订单的通知属于终止型配置错误。Webhook Handler 应避免让支付平台无限重试,同时在审计/日志中留下 out_trade_no 以排查跨环境回调地址。
幂等与重复通知
支付平台可能重复通知,用户也可能同时点击“查询支付结果”。幂等依赖:
- 订单唯一外部编号。
- 允许前态的条件更新。
paid/recharging/completed/failed状态检查。- 履约记录和支付审计。
- 用户余额/订阅写入的事务与重复检查。
completed 或已退款订单再次收到相同通知应直接成功;failed 可以进入受控重试;paid/recharging 表示另一个处理者可能正在履约,不能重复充值。
新增 Provider 时必须用“同一 Webhook 并发提交两次”测试,而不只是顺序调用两次。
两种履约
余额充值
余额订单在取得 recharging 状态后,通过事务更新用户余额、总充值等相关记录,再把订单标记为 completed。如果中途失败,订单进入可重试状态;不能只看 Provider 已支付就手工再加一遍余额。
订阅购买
订阅订单根据创建时的 plan/金额上下文创建或扩展订阅,并处理邀请返利等附属效果。订阅履约也必须幂等,旧成功审计可用于避免历史订单重复工作。
新增履约类型时,不要在 Webhook Handler 直接写用户表;应扩展订单类型、状态机、事务履约、重试、退款和审计。
主动查询与补偿
Webhook 可能因 DNS、WAF、证书或 Provider 重试策略丢失。系统提供订单 verify/reconcile:
- 用户或后台提供受约束的订单引用。
- 服务端根据订单绑定的 Provider 主动查询。
- Provider 返回已支付时,重新进入统一 notification/fulfillment 链。
- 仍未支付时保持 pending,或按明确操作取消。
补偿不能绕过金额、Provider 和幂等校验。对账脚本也应调用领域 Service 或受保护 API,而不是直接把数据库状态改成 completed。
退款状态机
completed
→ refund_requested
→ refunding
├─ Provider 已受理、等待结果 ─▶ refund_pending
├─ 部分退款 ─────────────────▶ partially_refunded
├─ 全额退款 ─────────────────▶ refunded
└─ 失败 ─────────────────────▶ refund_failed退款不只是调用 Provider:还要判断订单类型、可退金额、余额/订阅是否已消费、是否需要强制扣回、Provider 查询结果和最终审计。订阅与余额订单的回收方式不同。
管理员重试退款前应先查询 Provider 侧状态,避免上游已成功但本地响应丢失造成二次退款。
支付变更测试
新增 Provider 或修改状态机至少覆盖:
- 创建订单与 Provider 返回各种 result type。
- 签名失败、Provider 不匹配和金额不匹配。
- 相同 Webhook 顺序/并发重复。
- Webhook 与主动 verify 同时发生。
- pending 取消时 Provider 已支付。
- expired 后 grace 内/外收到成功通知。
- 余额和订阅履约中途失败后重试。
- Provider 配置修改后历史订单仍可验签/查询。
- 退款成功、pending、部分成功和查询补偿。
- 多实例 Registry 配置一致性。