Files
Blizzard 3a4e1d53a5 refactor(payment): 渠道抽象成 Channel 接口 —— 接新渠道=加 adapter 不动骨架
PAYMENT_DESIGN §3 承诺的 internal/payment/channel.go 适配器接口此前不存在,微信硬编码在
manager/handler 里。补齐抽象:

- channel.go:Channel 接口(Name/CreatePay/QueryOrder/VerifyCallback)+ 统一 PayIntent/PayResult
  + 渠道名常量。入参用基本类型不吃 *store.PaymentOrder,payment 包不反依赖 store。
- Wechat 实现 Channel(编译期断言 var _ Channel);QueryResult 归一为 PayResult;CreatePay 返回 PayIntent。
- Manager 从「持一个 *Wechat」改为渠道注册表:Get(name)/Available()/Status(name)/ReloadWechat;
  按渠道名持有已装配实例,热重载不变。
- 回调路由收敛 /billing/callback/wechat → /billing/callback/:channel 按名路由(旧 notify URL 仍匹配);
  查单/掉单补偿据 order.Channel 路由,不再写死微信。下单支持可选 channel(缺省 wechat)。
- 支付宝/Stripe 现在真·只差一个 adapter+注册。唯一未泛化:回调 ack 应答格式(现微信态,注释标明)。
- payment 包首个测试:Manager 注册表 4 用例(空/注册摘除/空配置/配置不全)。build/vet/test/lint 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:56:24 +08:00

132 lines
8.3 KiB
Markdown
Raw Permalink 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.
# 支付设计(SaaS P5)—— 充值积分包,不做订阅
> 2026-07-17。前置已就绪:P2 计量(`usage_event`/`credit_ledger`/物化余额/rollup)、
> P4 积分环(`GrantCredits` + 余额硬拦截)、薄 Web 面(账单页已有充值占位)。
> 本文一页纸定路线,增量照此施工;渠道选择(§3)留用户拍板。
## 1. 结论先行
- **做「预付积分包」充值,不做订阅**。理由:积分体系是现成的——支付入账只是给
`credit_ledger` 多一个自动化的 `grant` 来源,改动集中在「订单 + 回调」;订阅制
(周期扣费/升降级/按比例退差)是另一个量级的状态机,等有付费用户再议。
`Tenant.Plan`free/pro/enterprise)字段留着,将来订阅直接长在上面。
- **渠道做成适配器接口,先上一个「零资质渠道」跑通闭环**(兑换码/人工转账核销),
真渠道(支付宝/微信/Stripe)接进来只是多一个 adapter,不动骨架。
- **金额一律服务端说了算**:前端只传「包 ID」,价格/积分数由服务端订单锁定,
回调按订单校验金额,不信任任何客户端传值。
## 2. 与现有账本的对接点(都是现成的)
| 现有件 | 位置 | 支付怎么用 |
|---|---|---|
| `credit_ledger`append-onlykind: grant/usage/adjust`Ref` 字段) | `store/credit.go` | 支付入账 = `kind=grant, ref=order_id` |
| `GrantCredits(ctx, tenant, kind, micro, ref, memo)`(事务内:分录+物化余额) | `store/credit.go:151` | 回调确认后调用;**内部已 WithoutTenant**(跨租户写) |
| 余额硬拦截 `credit_enforce` | `store/setting.go` | 不动;充值到账即自然解封 |
| 薄 Web 面「用量与账单」页 | `sundynix-web/src/pages/Usage.tsx` | 占位文案换成真充值入口 |
| admin 计费页 + `/admin/usage` | 观测侧 | 加支付订单流观测(P5.3) |
**⚠️ 必须先补的闸:`GrantCredits``ref` 无唯一约束。** admin 手工充值无所谓;
支付回调会重复推送(渠道明文保证 at-least-once),不闸就是重复入账事故。
双保险:① 订单状态机 CAS 是主闸(见 §5);② `credit_ledger``(kind, ref)`
部分唯一索引(`WHERE kind='grant' AND ref<>''`)兜底,两道闸缺一不可。
## 3. 渠道决策(用户拍板,技术侧全兼容)
**已拍板(2026-07-17):真渠道用微信支付 Native(P5.2)。** 需企业主体 + 微信商户号
mchid + APIv3 密钥 + 商户证书);形态 = 下单得 code_url → Web 面渲染二维码 →
用户扫码付 → 回调(APIv3 签名验签 + AES-GCM 解密)。SDK 用官方
`github.com/wechatpay-apiv3/wechatpay-go`。其余渠道留 adapter 位,不做。
| 渠道 | 前提 | 形态 | 状态 |
|---|---|---|---|
| **兑换码/人工核销**P5.1 内置) | 无 | admin 生成码 → 用户在 Web 面输码入账 | 先做,零资质跑通闭环 |
| **微信支付 Native**(P5.2) | 企业主体 + 商户号 | 二维码(code_url | ✅ 已拍板 |
| 支付宝 / Stripe | — | — | 不做,留 adapter 位 |
Adapter 接口(`internal/payment/channel.go`)✅ **已落地**
```go
type Channel interface {
Name() string // 与 store.Channel*wechat/alipay/stripe)一致,按订单渠道路由
// CreatePay 依据订单生成支付凭据(微信 Native 出 code_url;其它渠道可出跳转 URL)
CreatePay(ctx, orderID, description string, amountFen int64) (PayIntent, error)
// QueryOrder 主动查单(前端轮询确认 + 掉单补偿共用)
QueryOrder(ctx, orderID string) (PayResult, error)
// VerifyCallback 验签并解析回调 → 统一 PayResult(验签失败必返 error
VerifyCallback(req *http.Request) (PayResult, error)
}
```
实现说明(2026-07-18):入参用基本类型(orderID/amountFen)而非 `*store.PaymentOrder`,让
`payment` 包不反依赖 `store`,渠道适配层保持纯粹。`Manager` 是渠道注册表(`Get(name)`/`Available()`/
`Status(name)`/`ReloadWechat`),回调路由收敛为 `POST /billing/callback/:channel` 按名路由;
查单/补偿据 `order.Channel` 路由。微信是当前唯一真渠道,支付宝/Stripe = 实现本接口 + 注册即接入。
唯一未泛化的是回调 **ack 应答格式**(现为微信 `{code:SUCCESS}`,其它渠道接入时按渠道分应答)。
## 3b. 两层汇率,各管各的(用户 2026-07-17 强调:积分↔token 必须可动态调)
```
人民币 ──(第一层: 积分包定价 price_fen→credits_micro, admin 配包/上下架)──> 积分
积分 ──(第二层: tokens_per_credit, admin 计费页动态调, DB 热生效 ✅已存在)──> token
```
- **第二层是现成的**`SettingTokensPerCredit`DB 优先→env→1000),admin「计费 & 用量」
页可改,对后续任务实时生效(P2 期 live 验证过:改 500 → 新任务 credits=tok/500)。
支付不碰它。
- **第一层是本期新增**`sundynix_credit_pack` 表,admin 可改价/加量/上下架。
- 解耦收益:促销只动包;模型成本变了要调积分购买力只动汇率;互不牵连。
订单锁定的是**下单当时**的包价与积分数(写死在订单行),之后改包不影响已付订单。
## 4. 数据模型(新增两件)
```
sundynix_payment_order # 订单:支付侧事实源(与 credit_ledger 对账的另一条腿)
id 雪花 (= 对外 order_id, ledger.ref)
tenant_id 计费租户(下单时用 ResolveBillingTenantID 解析并锁定)
user_id 操作人(审计)
pack_id 积分包 ID
amount_fen 应付金额(分) # 服务端按包锁定
credits_micro 到账积分(micro) # 服务端按包锁定
channel redeem / alipay / wechat / stripe
status pending → paid | failed | expired ; paid → refunded(人工)
channel_txn 渠道流水号(回调带回)
paid_at
sundynix_credit_pack # 积分包配置(admin 可改,别硬编码)
id / name / credits_micro / price_fen / active / sort
```
## 5. 流程与幂等(核心就这一张图)
```
Web面账单页 → GET /billing/packs → 选包 → POST /billing/orders {pack_id}
→ 服务端建 pending 订单(锁价) → 返回 PayIntent(二维码/跳转/输码框)
用户支付 → 渠道回调 POST /billing/callback/:channel(公开路由,验签是唯一门)
→ VerifyCallback 验签 + 金额与订单核对
→ 一个事务内:UPDATE payment_order SET status='paid' WHERE id=? AND status='pending'
RowsAffected==0 → 已处理过,直接 200(幂等闸①)
==1 → GrantCredits(kind=grant, ref=order_id)(唯一索引兜底,幂等闸②)
→ 前端轮询 GET /billing/orders/:id 到 paid → 刷余额
掉单补偿:pending 超 15min 的订单定时 QueryOrder 补态;过期置 expired
```
- 回调路由是**公开**的(渠道服务器打不了 Bearer),安全完全靠验签 + 金额核对 +
订单状态机;这与 `/reports/:id/export` 公开的先例同构,但多了签名门。
- 退款先只做人工:admin 发起 → `adjust` 负分录 + 订单置 refunded;自动退款不做。
- 对账三条腿:渠道账单 ↔ `payment_order(paid)``ledger(kind=grant)`
日终脚本比对(P5.3,先出 admin 页面人肉看,再自动化)。
## 6. 分增量(每步「机制→单测→live」)
- **P5.1 订单骨架 + 兑换码渠道**(不依赖任何外部资质,全链路即刻可 live 验证):
两张表 + ledger 唯一索引 + Channel 接口 + redeem adapter + `/billing/*` 路由
(下单≥billing_admin? 不——**充值谁都该能充,挂 ≥member**viewer 只读仍拦)+
Web 面账单页真充值 UI + admin 生成兑换码。
- **P5.2 微信支付 Native(已拍板)**wechatpay-go adapterNative 下单 code_url →
Web 面二维码)+ APIv3 回调验签解密 + 轮询查单。商户号/证书经 env 注入,
未配置时渠道自动隐藏(只剩兑换码),别让半配置状态把下单路由搞出 5xx。
- **P5.3 对账与观测**:admin「支付订单」流 + 日终对账 + 掉单补偿定时器。
- **P5.4 按需**:退款流程化、发票、微信/Stripe 并列。
## 7. 明确不做(本期)
订阅/自动续费、套餐权益(plan 仍是展示字段)、多币种、自动退款、发票自动化、
渠道分账。等真实付费流量拉动。