d5836fd190
订阅制砍掉(等付费用户拉动),充值积分包复用现成 credit_ledger/GrantCredits; 指出必须先补的闸:GrantCredits 对 ref 无唯一约束,支付回调 at-least-once 会重复入账——订单状态机 CAS 主闸 + (kind,ref) 部分唯一索引兜底。 P5.1 用零资质的兑换码渠道先跑通全闭环,真渠道(支付宝/微信/Stripe)等拍板。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.3 KiB
6.3 KiB
支付设计(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. 渠道决策(用户拍板,技术侧全兼容)
| 渠道 | 前提 | 形态 | 备注 |
|---|---|---|---|
| 兑换码/人工核销(P5.1 内置) | 无 | admin 生成码 → 用户在 Web 面输码入账 | 零资质立即可用;也是线下打款的核销通道 |
| 支付宝(当面付/电脑网站支付) | 企业主体 + 签约 | 二维码/跳转 | 国内首选,个体户也可签当面付 |
| 微信支付(Native) | 企业主体 + 商户号 | 二维码 | 与支付宝同形态,adapter 并列 |
| Stripe Checkout | 海外主体 | 跳转托管页 | 出海再接;回调形态同构(webhook+签名) |
Adapter 接口(internal/payment/channel.go):
type Channel interface {
Name() string
// CreatePay 依据订单生成支付凭据(二维码内容/跳转 URL/兑换码提示)
CreatePay(ctx, o *PaymentOrder) (PayIntent, error)
// VerifyCallback 验签并解析回调 → (orderID, channelTxnID, paidAmountFen, error)
VerifyCallback(req *http.Request) (CallbackResult, error)
// QueryOrder 主动查单(掉单补偿用)
QueryOrder(ctx, orderID string) (OrderStatus, error)
}
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 首个真渠道(等拍板,推荐支付宝当面付):adapter + 回调路由 + 轮询查单。
- P5.3 对账与观测:admin「支付订单」流 + 日终对账 + 掉单补偿定时器。
- P5.4 按需:退款流程化、发票、微信/Stripe 并列。
7. 明确不做(本期)
订阅/自动续费、套餐权益(plan 仍是展示字段)、多币种、自动退款、发票自动化、 渠道分账。等真实付费流量拉动。