支付方式无关(provider-agnostic)架构 · client↔控制面协议固定 · 加渠道不改客户端 · 2026-07-09
Order 业务订单 + PaymentAttempt 支付尝试 + Provider 适配器」模型;客户端只认服务端下发的少数几种渲染形态(render_type),不认具体平台。同形态的新网关 = 零改客户端;只有全新交互形态才动客户端。
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 TEXT UNIQUE -- 幂等主键, 形如 PAY2026... user_id INTEGER -- FK users.id(内部关联订阅) user_uuid TEXT -- 对外/跨系统标识(= pay 侧 user_ref) sku TEXT -- pro-month / pro-quarter / pro-year plan_code TEXT -- pro(建单时锁定,回调改不了) duration_days INTEGER -- 30 / 90 / 365(建单时锁定) status TEXT -- created|pending|paid|expired|canceled subscription_id INTEGER NULL -- 开通后回填(审计) created_at / paid_at / expires_at DATETIME
建单时就把 order_no → user / plan / 天数 落库。回调只能推进已存在的订单、开通其中预先锁定的套餐——回调无法指定"给谁开多久"。这是防回调伪造的纵深。
pay_attempts — 支付尝试(收款账本)id INTEGER PK order_no TEXT -- FK pay_orders method TEXT -- usdt_trc20 / alipay_nazha provider TEXT -- crypto / nazha provider_ref TEXT -- pangolin-pay 单号 / 哪吒 trade_no render_type TEXT -- redirect / display_details amount_minor INTEGER -- 该尝试的应收(按币种最小单位) currency TEXT -- USDT / CNY status TEXT -- created|pending|paid|failed|expired created_at / paid_at DATETIME UNIQUE(provider, provider_ref) -- 回调幂等的命门
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?}
POST /v1/pay/orders/{order_no}/retry {method} → 201 新 session(Order 不变)
--- 平台→控制面(公开,不带 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。范围:后端编排层 + crypto & 哪吒两 adapter + 四端 Flutter 统一支付页一次到位。用户中心(web)支付页作为快速跟进(复用同一套 REST,小改)。实施在计划里分阶段:
| 项 | 状态 |
|---|---|
| SKU 三档 + USDT 价($3.99/$9.99/$29.99) | 已定 |
| CNY 价(¥29.99/¥68.88/¥199.99) | 已定 |
| 哪吒商户注册(pid + RSA 密钥对) | 你去 Telegram 开户后给我 |
| return_url 成功落地页(用户中心 or 官网一页) | 待定 |
| Order + PaymentAttempt 两层模型 | 本设计采用 |
| 用户中心 web 支付页是否本轮做 | 提案:快速跟进,非 v1 |
相关文档:支付渠道选型总览 · 干净 USDT 方案 · 单地址收款模型 · 加密交易引擎