3a4e1d53a5
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>
132 lines
8.3 KiB
Markdown
132 lines
8.3 KiB
Markdown
# 支付设计(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-only,kind: 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 adapter(Native 下单 code_url →
|
||
Web 面二维码)+ APIv3 回调验签解密 + 轮询查单。商户号/证书经 env 注入,
|
||
未配置时渠道自动隐藏(只剩兑换码),别让半配置状态把下单路由搞出 5xx。
|
||
- **P5.3 对账与观测**:admin「支付订单」流 + 日终对账 + 掉单补偿定时器。
|
||
- **P5.4 按需**:退款流程化、发票、微信/Stripe 并列。
|
||
|
||
## 7. 明确不做(本期)
|
||
|
||
订阅/自动续费、套餐权益(plan 仍是展示字段)、多币种、自动退款、发票自动化、
|
||
渠道分账。等真实付费流量拉动。
|