← 返回文档索引

Pangolin 统一支付编排层设计

支付方式无关(provider-agnostic)架构 · client↔控制面协议固定 · 加渠道不改客户端 · 2026-07-09

一句话:控制面新增一个支付编排层,把不同支付平台(哪吒聚合支付、加密货币、未来 Stripe/信用卡/epay)统一到「Order 业务订单 + PaymentAttempt 支付尝试 + Provider 适配器」模型;客户端只认服务端下发的少数几种渲染形态(render_type),不认具体平台。同形态的新网关 = 零改客户端;只有全新交互形态才动客户端。

📋 实现计划:P1 数据层 + 开通重构(7 阶段之一;P2–P7 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。

1. 背景与目标

Pangolin 当前没有 App 内支付:用户变 pro 的唯一路径是兑换激活码(POST /v1/redeem),客户端"购买"卡片是 onTap: () {} 空实现。控制面没有订单系统(migration 000001–000021 无 orders/payment 表),只有 codes 激活码履约。这是一块绿地。

目标:

2. 可行性结论 可行

调研(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。

3. 架构总览

客户端Flutter 四端 控制面编排层 :8080 Provider适配器 外部平台pangolin-pay · 哪吒 GET /v1/pay/methods 启用渠道 [{id,name,render_type}] POST /v1/pay/orders {sku, method} 落库 Order(谁/买啥/几天)+ Attempt(provider/单号) adapter.Create(order) 下单(/order | /pay/create) 地址+金额 | payurl Session{render_type, payload} {order_no, session} 客户端按 render_type 渲染:redirect→浏览器 / display_details→地址卡 用户付款(链上转账 | 支付宝跳转) 轮询 GET /v1/pay/orders/{no} pending… webhook:/webhooks/pay/crypto(HMAC) | /nazha(RSA·GET) 验签 → 定位订单 → 幂等 → 金额核对 → 开通订阅(source=pay) GET /v1/pay/orders/{no} paid ✓ 已开通 兜底:控制面 →(Query 主动查单) 外部平台 —— crypto 靠链上轮询;哪吒回调可能被 GFW/CF 吞

为什么 Order 与 PaymentAttempt 拆两层:同一笔订单,用户可能先扫哪吒超时、再换 crypto 付成功。换渠道 = 新建一个 Attempt,Order 不变。只有一个对象会让"换渠道重试"污染状态机。

4. 数据模型

控制面新增两张表(mysql + sqlite 双套 migration,金额一律 int64 最小单位,禁 float)。

4.1 pay_orders — 业务订单(购买/权益账本)

字段说明
order_no幂等主键(UNIQUE,形如 PAY2026…)
user_idFK users.id(内部关联订阅)
user_uuid对外/跨系统标识(= pay 侧 user_ref)
skupro-month / pro-quarter / pro-year
plan_codepro(建单时锁定,回调改不了)
duration_days30 / 90 / 365(建单时锁定)
statuscreated | pending | paid | expired | canceled
subscription_id开通后回填(审计,可空)
created_at / paid_at / expires_at时间戳

建单时就把 order_no → user / plan / 天数 落库。回调只能推进已存在的订单、开通其中预先锁定的套餐——回调无法指定"给谁开多久"。这是防回调伪造的纵深。

4.2 pay_attempts — 支付尝试(收款账本)

字段说明
id主键
order_noFK pay_orders
methodusdt_trc20 / alipay_nazha
providercrypto / nazha
provider_refpangolin-pay 单号 / 哪吒 trade_no
render_typeredirect / display_details
amount_minor该尝试应收(按币种最小单位)
currencyUSDT / CNY
statuscreated | pending | paid | failed | expired
expires_at本次尝试超时(哪吒 payurl / crypto 15min)
created_at / paid_at时间戳
UNIQUE(provider, provider_ref)回调幂等的命门

⚠️ 超时挂在 attempt 上:某 attempt 超时只把它自己置 expired,orderpending → 用户可再建 attempt 换渠道。order.expires_at整体购买窗口(较长,如 1h),到点才整单 expired。一笔 order 最多一个 attempt 能成功 —— MarkOrderPaid 只在 order=pending 时原子成立,paid 后续 attempt 全部失效。

4.3 两表分工与创建时机(一对多)

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),取消单仍在订单列表可见。拆两层:换渠道重试不污染订单状态机 + 订单是开通谁的唯一真相。

4.4 SKU 目录 + 定价 已定

SKU → (plan, 天数, 各币种价格)。哪吒收人民币(支付宝)、crypto 收 USDT,故按币种各定一价(不做实时汇率换算,固定价是产品决策)。首版 SKU 目录作为控制面代码常量(后续可迁 DB)。

SKUplan天数USDT(crypto)CNY(哪吒/支付宝)
pro-monthpro30$3.99¥29.99
pro-quarterpro90$9.99¥68.88
pro-yearpro365$29.99¥199.99

USDT 价与 CNY 价均已拍板。金额一律 int64 最小单位存(USDT 微单位 1e-6、CNY 分),哪吒 money 参数按元、2 位小数下发。

5. Provider 适配器接口

每个支付平台实现同一个接口,控制面只依赖接口。三个动作是最小完备集。

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"

5.1 crypto adapter(包裹已部署的 pangolin-pay)

pay-server 保持独立进程(持 xpub、看链,最小权限),控制面 crypto adapter 只是它的 HTTP 客户端:

5.2 nazha adapter(哪吒聚合支付)

6. 客户端契约

6.1 REST 端点(控制面,全部 Bearer)

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)

6.2 render_type 联合体(写死在协议里)

render_typepayload首版
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"。

6.3 客户端改动:一次 vs 永不

动作
一次(本轮做)新增 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。

7. 统一开通管线

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; 审计 }

7.1 复用并重构现有开通逻辑

开通能力现埋在 codes.Service.applySubscription(server/internal/codes/service.go:235),仅作为 Redeem 事务的一步、且 CreateSubscriptionsource 硬编码 'code'。改动:

8. 哪吒合规风险 务必知情

调研明确:哪吒是匿名注册(Telegram 开户、无 KYC)、USDT 结算、带代付出款、仅支付宝单通道、无公司主体 / ICP / 牌照的四方聚合/代收平台。风险:资金冻结、平台跑路(无主体可追索)、商户帮信罪敞口。

缓解 = 本架构天生解耦:哪吒只是一个 redirect adapter,将来换成持牌支付宝直连 / Stripe / epay,客户端零改、订单与开通逻辑零改,只换服务端一个 adapter。可"先用它跑量、随时替换",不锁死。crypto(USDT 自托管)作为干净主轨并存。

9. 错误处理与边界

10. 测试策略

11. 首版范围与实施阶段

范围:后端编排层 + crypto & 哪吒两 adapter + 四端 Flutter 统一支付页 + 用户中心订单历史页一次到位。实施在计划里分阶段:

  1. 控制面:schema(pay_orders/pay_attempts)+ store + 开通重构 + source=pay migration。
  2. Provider 抽象 + 注册表 + crypto adapter + pay-server 出站 webhook。
  3. 哪吒 adapter(RSA 签/验、GET 回调、查单)。
  4. 客户端 REST(methods/orders/list/retry/webhooks)+ 统一开通管线 + Query 轮询 worker。
  5. Flutter 支付页(方法选择器 + render_type 分发 + 轮询)+ 接上购买入口。
  6. 用户中心订单历史页(读 GET /v1/pay/orders,新增导航项;return_url 跳此)。
  7. 端到端联调(两轨小额)。

12. 待确认清单(评审 gate)

状态
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 方案 · 单地址收款模型 · 加密交易引擎