支付方式无关(provider-agnostic)架构 · client↔控制面协议固定 · 加渠道不改客户端 · 2026-07-09
Order 业务订单 + PaymentAttempt 支付尝试 + Provider 适配器」模型;客户端只认服务端下发的少数几种渲染形态(render_type),不认具体平台。同形态的新网关 = 零改客户端;只有全新交互形态才动客户端。
📋 实现计划:P1 数据层 + 开通重构(7 阶段之一;P2–P7 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。
Pangolin 当前没有 App 内支付:用户变 pro 的唯一路径是兑换激活码(POST /v1/redeem),客户端"购买"卡片是 onTap: () {} 空实现。控制面没有订单系统(migration 000001–000021 无 orders/payment 表),只有 codes 激活码履约。这是一块绿地。
目标:
调研(Stripe next_action 模型 + 多 PSP 编排实践)确认:不同支付平台的交互形态是有限的,而平台是无限的。把形态收敛成客户端认识的 render_type 联合体,即可让客户端 provider 无关。
首批两渠道的形态都落在客户端已有能力上:
| 渠道 | render_type | 客户端如何渲染 | 客户端现状 |
|---|---|---|---|
| 哪吒(支付宝) | redirect | 拿 payurl → 外部浏览器打开 | 已有 url_launcher + SSO 跳转范式 |
| 加密货币(USDT) | display_details | 展示地址+精确金额卡片 | 已有 就是把 /_test 卡搬进 App |
边界(诚实说):客户端改动频率 = 新 render_type 出现频率(极低)+ OS scheme 白名单变化,不是 provider 频率。真正要碰客户端的只有:全新交互形态、拉起 App 的 scheme 白名单(Info.plist/manifest)、信用卡 3DS / Apple·Google Pay 的专有 SDK。
为什么 Order 与 PaymentAttempt 拆两层:同一笔订单,用户可能先扫哪吒超时、再换 crypto 付成功。换渠道 = 新建一个 Attempt,Order 不变。只有一个对象会让"换渠道重试"污染状态机。
控制面新增两张表(mysql + sqlite 双套 migration,金额一律 int64 最小单位,禁 float)。
pay_orders — 业务订单(购买/权益账本)| 字段 | 说明 |
|---|---|
| order_no | 幂等主键(UNIQUE,形如 PAY2026…) |
| user_id | FK users.id(内部关联订阅) |
| user_uuid | 对外/跨系统标识(= pay 侧 user_ref) |
| sku | pro-month / pro-quarter / pro-year |
| plan_code | pro(建单时锁定,回调改不了) |
| duration_days | 30 / 90 / 365(建单时锁定) |
| status | created | pending | paid | expired | canceled |
| subscription_id | 开通后回填(审计,可空) |
| created_at / paid_at / expires_at | 时间戳 |
建单时就把 order_no → user / plan / 天数 落库。回调只能推进已存在的订单、开通其中预先锁定的套餐——回调无法指定"给谁开多久"。这是防回调伪造的纵深。
pay_attempts — 支付尝试(收款账本)| 字段 | 说明 |
|---|---|
| id | 主键 |
| order_no | FK pay_orders |
| method | usdt_trc20 / alipay_nazha |
| provider | crypto / nazha |
| provider_ref | pangolin-pay 单号 / 哪吒 trade_no |
| render_type | redirect / display_details |
| amount_minor | 该尝试应收(按币种最小单位) |
| currency | USDT / CNY |
| status | created | pending | paid | failed | expired |
| expires_at | 本次尝试超时(哪吒 payurl / crypto 15min) |
| created_at / paid_at | 时间戳 |
| UNIQUE(provider, provider_ref) | 回调幂等的命门 |
⚠️ 超时挂在 attempt 上:某 attempt 超时只把它自己置 expired,order 仍 pending → 用户可再建 attempt 换渠道。order.expires_at 是整体购买窗口(较长,如 1h),到点才整单 expired。一笔 order 最多一个 attempt 能成功 —— MarkOrderPaid 只在 order=pending 时原子成立,paid 后续 attempt 全部失效。
pay_orders 业务订单 | pay_attempts 支付尝试 | |
|---|---|---|
| 回答 | 谁买了什么、开通谁(购买/权益) | 用哪个平台怎么付、平台单号(收款) |
| 何时创建 | 用户选套餐点「购买」确认那刻 —— POST /v1/pay/orders,一次购买一行 | 每次选定支付方式建 provider 会话 —— 同一 POST /v1/pay/orders(首选渠道),或 /retry(换渠道) |
| 数量 | 1 笔购买 = 1 行(order_no 唯一) | 1 笔购买 = N 行(每次换渠道重试各一行) |
| 锁定 | 建单即锁 user/plan/天数/金额 → 回调改不了(防伪造) | 绑 provider + provider_ref → UNIQUE 幂等 |
| 生命周期 | created→pending→paid / expired(整体窗口到点) / canceled(用户取消)(长) | created→pending→paid / failed / expired(单次超时)(短,可弃) |
| 超时 | 整体购买窗口(如 1h)到点 → order expired | 各自超时(哪吒 payurl / crypto 15min);超时只弃本 attempt,order 不变 |
典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → attempt#1 expired,order 仍 pending;点「换 crypto」→ order 不变,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。一笔 order 最多一个 attempt 成功(MarkOrderPaid 只在 order=pending 原子成立)。用户也可 POST /orders/{no}/cancel 主动取消(pending→canceled),取消单仍在订单列表可见。拆两层:换渠道重试不污染订单状态机 + 订单是开通谁的唯一真相。
SKU → (plan, 天数, 各币种价格)。哪吒收人民币(支付宝)、crypto 收 USDT,故按币种各定一价(不做实时汇率换算,固定价是产品决策)。首版 SKU 目录作为控制面代码常量(后续可迁 DB)。
| SKU | plan | 天数 | USDT(crypto) | CNY(哪吒/支付宝) |
|---|---|---|---|---|
pro-month | pro | 30 | $3.99 | ¥29.99 |
pro-quarter | pro | 90 | $9.99 | ¥68.88 |
pro-year | pro | 365 | $29.99 | ¥199.99 |
USDT 价与 CNY 价均已拍板。金额一律 int64 最小单位存(USDT 微单位 1e-6、CNY 分),哪吒 money 参数按元、2 位小数下发。
每个支付平台实现同一个接口,控制面只依赖接口。三个动作是最小完备集。
type Provider interface { Method() MethodInfo // 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max} Create(ctx, o Order) (Session, error) // ① 下单→返回渲染指令 HandleCallback(ctx, r *http.Request) (CallbackResult, error) // ② 验签+解析+归一化 Query(ctx, providerRef string) (PaymentStatus, *PaidEvent, error) // ③ 主动查单兜底 } type Session struct { // Create 的返回,客户端按 RenderType 分发 ProviderRef string RenderType string // redirect / display_details / ... Payload json.RawMessage // 按 RenderType 定 shape ExpiresAt time.Time } type PaidEvent struct { OrderNo string; ProviderRef string; AmountMinor int64; Currency string; PaidAt time.Time; TxRef string } type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } // AckBody: 哪吒要回 "success"
pay-server 保持独立进程(持 xpub、看链,最小权限),控制面 crypto adapter 只是它的 HTTP 客户端:
POST pangolin-pay /order {user_ref=uuid, sku, amount=USDT微单位} → 得地址+精确金额 → render_type=display_details,provider_ref=pay 单号。POST 控制面 /v1/webhooks/pay/crypto(HMAC 签名、失败重试)。GET pangolin-pay /order/{provider_ref}(链上侦测无 webhook 时兜底)。POST nzzf.org/api/pay/create,参数 pid / type=alipay / out_trade_no=尝试号 / notify_url / return_url / name / money=元 / timestamp / sign / sign_type=RSA,签名 SHA256WithRSA(商户私钥、参数 ASCII 升序拼 k=v&、Base64)→ 得 payurl → render_type=redirect,provider_ref=trade_no。notify_url,平台公钥 RSA 验签 → trade_status==TRADE_SUCCESS → PaidEvent;响应纯文本 success。POST /api/pay/query,status==1 为已支付(回调可能被吞,轮询兜底)。pid + 商户 RSA 私钥 + 平台 RSA 公钥,走 Bitwarden/env。前置:你需在哪吒(Telegram 开户)拿到 pid 与密钥。GET /v1/pay/methods → 200 [{id,name,icon,render_type,currency,enabled,min,max}] POST /v1/pay/orders {sku, method} → 201 {order_no, status, session:{render_type,payload,expires_at}} GET /v1/pay/orders/{order_no} → 200 {status, plan, expires_at, session?} GET /v1/pay/orders?limit&cursor → 200 {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} (历史订单含 canceled,用户中心订单页用) POST /v1/pay/orders/{order_no}/retry {method} → 201 新 attempt/session(Order 不变) POST /v1/pay/orders/{order_no}/cancel → 200 {status:"canceled"}(仅 pending 可取消;paid/已取消返 409) --- 平台→控制面(公开,不带 Bearer)--- POST /v1/webhooks/pay/crypto (pangolin-pay, HMAC 验签) GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)
| render_type | payload | 首版 |
|---|---|---|
redirect | {url, return_hint} | v1 哪吒 |
display_details | {fields:[{label,value,copyable}], amount, currency, expires_at, poll_interval_sec} | v1 crypto |
qr_code | {qr_content, display_amount, expires_at} | 预留 |
redirect_native | {app_scheme, universal_link, fallback_url} | 预留 |
sdk_handoff | {sdk, params} | 预留(逃逸口) |
护栏:客户端遇到未知 render_type → 不显示该方法 / 提示"请升级 App"。无论哪种形态,客户端最终都收敛到同一个"轮询 GET /orders/{no} 到 paid"。
| 动作 | |
|---|---|
| 一次(本轮做) | 新增 PaymentClient(Dart, 四端共享)+ 支付页:方法选择器(读 /v1/pay/methods)→ 建单 → 按 render_type 分发(redirect=url_launcher 外部浏览器;display_details=地址卡)→ 轮询到 paid → 成功页。接上现有 no-op 购买入口。无需新增原生依赖(url_launcher 已在,无需 webview)。 |
| 永不(加渠道零改) | 新增 provider 若落在已有 render_type(epay/Stripe Checkout = redirect;另一条链 = display_details)→ 仅服务端加 adapter + 下发一项。 |
| 未来才碰 | 全新交互形态(新 render_type);拉起 App 的 scheme 白名单;信用卡 3DS / Apple·Google Pay 专有 SDK。 |
webhook 与 Query 轮询最终都产出 PaidEvent,走同一条幂等开通逻辑:
| 步骤 | 动作 |
|---|---|
| ① 验签 | adapter.HandleCallback / Query(各平台不同:HMAC / RSA / 链上确认) |
| ② 定位订单 | provider_ref → pay_attempts → pay_orders |
| ③ 幂等 | attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通 |
| ④ 金额/币种核对 | PaidEvent.amount == attempt.amount_minor && currency 一致 |
| ⑤ 事务开通 | { order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 } |
开通能力现埋在 codes.Service.applySubscription(server/internal/codes/service.go:235),仅作为 Redeem 事务的一步、且 CreateSubscription 把 source 硬编码 'code'。改动:
source 的开通方法(codes.Redeem 与 pay 管线共用),避免两套开通逻辑漂移。subscriptions.source 的 CHECK 枚举现只允许 ('trial','code') → 加 'pay'(migration:sqlite 需表重建、mysql alter,双套)。EntitlementForUser)、/me、免费门 无需改——它们只看"最新未过期订阅",source 对它们透明。缓解 = 本架构天生解耦:哪吒只是一个 redirect adapter,将来换成持牌支付宝直连 / Stripe / epay,客户端零改、订单与开通逻辑零改,只换服务端一个 adapter。可"先用它跑量、随时替换",不锁死。crypto(USDT 自托管)作为干净主轨并存。
paid。UNIQUE(provider, provider_ref) + order granted 标记,已开通再来直接 200。MarkOrderPaid 原子守卫 order=pending,paid 后所有 attempt/retry 失效。POST /orders/{no}/cancel 仅在 pending 生效(→canceled);已 paid/已取消返 409。canceled 单仍在订单列表可见。取消后若链上迟到付款到账 → 进 orphan 人工处理。范围:后端编排层 + crypto & 哪吒两 adapter + 四端 Flutter 统一支付页 + 用户中心订单历史页一次到位。实施在计划里分阶段:
GET /v1/pay/orders,新增导航项;return_url 跳此)。| 项 | 状态 |
|---|---|
| SKU 三档 + USDT 价($3.99/$9.99/$29.99) | 已定 |
| CNY 价(¥29.99/¥68.88/¥199.99) | 已定 |
哪吒商户 pid + RSA 密钥对(Bitwarden nzzf:shop_id/ShopPrivateKey/PublicPlateKey) | 已定 |
| return_url → 跳用户中心订单页 | 已定 |
| Order + PaymentAttempt 两层模型 | 本设计采用 |
| 用户中心订单历史页 | 已定:本轮做 |
实现计划:P1 数据层 + 开通重构(真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md)。
相关文档:支付渠道选型总览 · 干净 USDT 方案 · 单地址收款模型 · 加密交易引擎