← 返回文档索引
DESIGN · v2

pay · 多产品统一支付 + 履约网关设计

把 pay 做成 pangolin / jiu / dudu / 未来产品共用的支付层:国内外常见支付 + 加密货币,统一 Order+Attempt(一单 N 渠道只一个成功 / 可取消 / 可退款),render_type 多态(加渠道不改 client),多账户路由,配置驱动渠道开关,国内外双部署。2026-07-10

📋 实现计划:P1 核心数据模型 + 配置账户注册表(8 阶段之一;P2–P8 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。

一句话:pay 是中性在线收款中枢——只管"按被授权的金额收款/退款 + 记账 + 发事件",不碰业务语义。客户端只认 6 个 render_type(付款意图数据,非 UI);渠道差异全封死在服务端 Provider.verify_callback;开通/权益各产品自留,只共享"入账成功"事件契约 + 时长叠加算法。加同形态新渠道 = 服务端零改客户端。

1. 背景:三方现状 → 收口到 pay

调研三个项目后的定位:

项目角色现状
pay收款中枢已是多租户网关(Channel 接口 + biz.<name> 配置 + HMAC 双向 webhook + 查单兜底),但只支付宝/微信、只 CNY、只国内、单一 pay_url、无 attempts/退款主动流/订阅
jiupay 的瘦接入方零渠道代码,下单→pay_url→轮询→webhook 开通(门店 license)。iOS 隐藏购买 UI 走 3.1.1 合规,无 IAP。是理想接入方样板
pangolin待接入曾想自建编排层(重复造轮子,已废),已部署 pangolin-pay(USDT 单地址收款)→ 转为 pay 的 crypto adapter 后端。站外分发不受 IAP 约束

决策:pay 干净 v2 重设计(pay 才 ~1700 行、jiu 未上线,趁早把模型做对);pangolin/jiu 作为接入方收口 —— pay 定稿后再动(记录在 brain todo #5)。

2. 架构与分层:3 个正交关注点

"统一支付 + 履约层"拆成三块,各自成中枢,不混成一坨:

关注点是什么统一?
① pay(在线收款)实时收款网关✅ 统一(本设计核心)
② codes(离线兑换)激活码/兑换券(发卡店灌码、用户兑换)✅ 抽共享内核,与 pay 平级、不并入
③ entitlement(开通)权益记账(pangolin per-user 订阅 / jiu 门店 license)❌ 各产品自留

统一的是两条入账通道(pay 收款 / codes 兑换)+ 一份"入账成功"事件契约 + 时长叠加算法;开通落库因数据形状不同(user 多行 vs shop 单行)各产品自己做。

接入方(多产品 · 客户端 + 后端) pangolin(VPN·订阅) · jiu(酒库·门店license) · dudu(充值秒数) · 未来… ——— client 只认 render_type,后端签名调 pay pay 网关(中性收款中枢 · 多租户 biz.<name>) Order 业务订单 out_trade_no 幂等 · 金额权威 created→pending→paid→(退款态) Attempt 支付尝试(N 个/单) 绑 channel + account + render_type 一单 N 渠道 · 只一个成功 · 各自超时 Account 路由(多账户/单渠道) 轮询/加权/限额/地址池 → 选一账户 Refund 退款子实体 可多次部分退 · 原账户原路退 Provider adapter 注册表 capabilities / create / verify_callback / query / refund (+recurring) verify_callback = 渠道差异垃圾回收站(RSA/证书/链上确认/收据校验) · 配置驱动启停 支付渠道(按部署/区域启用) 🇨🇳 支付宝 · 微信 · 银联 · 哪吒聚合 🌍 Stripe · PayPal · Apple/Google Pay 🔗 crypto 自托管(USDT-TRC20)   = 抗风控 · 始终兜底通道 📱 Apple IAP / Google Play(半开放) 每渠道可多账户(多主体/多地址) 回调/退款/结算走同一账户 cn / global 双部署 · 同码两配置 codes 兑换内核(与 pay 平级 · 共享库/契约) 码模型/状态机/生成/防双花兑换/批次/审计 + 时长叠加算法(激活码 · 优惠券未来) entitlement 开通(各产品自留) 收「入账成功」事件 → 验签/幂等/金额核对 → max(现到期,now)+时长;退款→冲正 ① 签名下单 POST /orders create verify ② webhook(event_type) 开通/冲正

3. 数据模型:Order + Attempt + Account + Refund

3.1 Order 业务订单(购买账本)

字段说明
out_trade_no幂等主键
product_idpay 套餐(金额权威,不接受裸传金额)
金额 / 币种amount_minor(int64) + presentment / charge / settlement_currency 三种货币快照
biz_system / biz_ref接入方业务系统 + 业务单号(原样回传)
statuscreated | pending | paid | canceled | expired | refunding | partially_refunded | refunded
discountbase_amount + discount + campaign_id + reason(可选,业务授权的折后价,记账留痕)
created_at / paid_at / expires_at时间戳;expires_at = 整体购买窗口

建单锁定 product/金额/biz_ref,回调只能推进已存在订单——防伪造纵深。可 POST /orders/{no}/cancel(pending→canceled),取消单仍在订单列表。

3.2 Attempt 支付尝试(收款账本)

字段说明
attempt_id主键
out_trade_noFK → Order
channel支付渠道
account_id选中的收款账户(§3.3)
provider_ref渠道单号
render_type渲染形态(§4.2)
amount_minor + currency该尝试应收
statuscreated | pending | paid | failed | expired
expires_at本次尝试超时
UNIQUE(channel, provider_ref)回调幂等命门

一单 N attempt、只一个成功:标付原子守卫 order=pending。attempt 超时只弃本 attempt,order 仍 pending → 换渠道重试(POST /orders/{no}/retry)。你担心的「N 渠道只一个成功 / 可取消 / 加渠道不改 client」全在这两层实现,接入方零感知。

3.3 Account 收款账户(同一渠道多账户 + 路由)

分三层:渠道类型(channel) → 收款账户(account,N 个) → 每单按策略选一个。

Account 字段说明
channel渠道类型
account_id账户标识
凭证密钥 / xpub / merchant_id(从 env 取,不入库明文)
weight路由权重
enabled启停
daily_limit日限额
region区域
subject收款主体
路由策略说明
round_robin轮询分摊
weighted按权重
limit_aware分摊避免单账户超额触风控冻结
by_region / amount / biz按区域 / 金额 / 业务系统
crypto 地址池每单不同地址,或单地址 + 唯一金额

3.4 Refund 退款子实体

字段说明
refund_id主键
out_trade_no关联订单
attempt / provider_ref针对哪笔支付(原路退回)
amount_minor + currency退款额
reason退款原因
statusrequested | processing | succeeded | failed
provider_refund_ref渠道退款号
initiated_bybusiness(业务发起) | platform(平台发起)
created_at / completed_at时间戳

一单可多次部分退,累计 ≤ 已付。详见 §6。

4. Provider adapter + render_type 与渲染归属

4.1 Provider 接口(每渠道实现)

interface PaymentProvider {
  capabilities() → { render_types, supports_refund, supports_recurring,
                     recurring_kind, settle_currencies, regions }
  create(order, account, ctx) → { provider_ref, render_type, payload, expires_at }
  verify_callback(raw|chain_event|receipt) → { order_ref, status, paid_amount, paid_currency }
  query(provider_ref) → { status, paid_amount }
  refund(provider_ref, amount, reason) → { refund_ref, status }   // 不支持则 capabilities=false
}
interface RecurringProvider { create_agreement / charge / cancel_agreement / sync_from_notification }  // 可选,§5

verify_callback 是渠道差异的垃圾回收站:验签(Stripe sig / 微信证书 / 支付宝 RSA)、链上确认数、IAP 的 JWS/purchaseToken 校验全封死在这,对外只吐统一 {order_ref, status, paid_amount}。crypto 与国内扫码无可靠 push 时靠 query 轮询兜底,故 query 是一等动作。

4.2 render_type = 6 个(client 只认形态,不认渠道)

pay 下发的是 付款意图数据 {render_type, payload},不含任何 UI/CSS/布局

render_type含义payload 关键字段client 怎么渲染样式谁控
redirect跳第三方收银台 / 拉起 App(WAP)url · return_url · return_scheme(回跳你的 app)打开 URL(浏览器/webview);移动端 WAP 时系统经 scheme 自动拉起支付宝/微信 App;回跳不可信→轮询状态支付平台(收银台页)
qr展示二维码qr_content, display_amount, expires_at本地把 qr_content 渲成码 + 金额/倒计时业务方
crypto_address链上收款address, amount, asset, chain, expires_at, min_confirmations展示地址+精确金额(可复制)+确认进度,轮询业务方
native_pay系统 Paypayment_request(PKPaymentRequest/GPay), gateway, gateway_token唤起 Apple/Google Pay 系统 Sheet,token 回传 verify操作系统
sdk_handoff原生 SDKsdk_name, client_secret/prepay_params调 SDK(Stripe PaymentSheet / 微信 JSAPI)SDK/平台
iap_receipt苹果/谷歌内购product_id, store调 StoreKit/Play Billing,签名交易/token 回传 verifyApp Store/Play

样式布局归属:pay 管付款意图数据,client 管样式与布局。完全开放形态(qr/crypto_address)业务方用自己设计系统全权渲染(pangolin 暖色 clay 风、jiu 自己的风,各画各的);redirect 的收银台、native_pay/sdk_handoff/iap_receipt 的系统/SDK/平台弹窗,样式归平台,业务方只控触发按钮。

"加渠道不改 client"的边界:redirect/qr/crypto_address = 完全开放(加渠道纯服务端);native_pay/sdk_handoff/iap_receipt = 半开放(复用已集成原生能力时零改,引入新 SDK/store 需发版)。对外承诺须按此措辞。

pay 可可选提供服务端兜底渲染(如 GET /qrcode?text= 码串转 PNG)给纯 web 无 client 场景;规范路径是 client 自渲染。

常见案例:移动端拉起支付宝/微信 App —— 不需要单独 render_type,两种落法:①手机网站/H5(WAP)redirect(服务端返 wap URL,client launchUrl,系统经 alipays:///weixin:// scheme 自动拉起 App,付完经 return_url/return_scheme 回跳;无需 SDK、完全开放,推荐,jiu 现行做法);②原生 App 支付 SDKsdk_handoff(服务端返订单串/prepay 参数,client 调渠道原生 SDK 拉起+回调;需集成 SDK、半开放)。回跳拿不到也没事,client 始终轮询订单状态收敛。

5. webhook v2 事件模型 + 开通

v2 断代:webhook 加 event_type,接入方显式声明支持的事件集。

event_type本轮说明
payment.succeeded先做收款成功 → 接入方开通
refund.succeeded / refund.failed卡类要真做退款结果 → 接入方冲正权益
chargeback.created设计进·later信用卡拒付(平台/发卡行发起)
subscription.renewed|failed|canceled设计进·later订阅事件(§见 recurring)

签名沿用双向 HMAC(system\ntimestamp\nnonce\nrawBody,±5min,nonce 防重放);payload 幂等键 out_trade_no。接入方收 payment.succeeded → 验签 → 幂等 → 金额核对 → max(现到期,now)+时长 叠加(此算法抽成共享工具);非 200/不含 SUCCESS → 60s 重试 24h。

5.1 自动续订(recurring):设计进模型、暂不实现

本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":

recurring_kind谁驱动代表pay 如何推进
token_offsession我方Stripe自建/支付宝周期扣/微信papaycron 到期主动 charge
gateway_scheduled网关Stripe Subscriptions / PayPal监听 invoice.paid webhook
store_managed平台Apple IAP / Google Play被动接 Server Notification/RTDN
none所有 crypto / 单笔到期提醒用户手动再买(伪续订)

统一 subscription 实体 + 状态机(active→grace→expired);单笔层与订阅层解耦。crypto 天生无 recurring、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。本轮把 event_type/subscription 实体设计进契约、避免第三次 breaking,但只建一次性付款。

6. 退款设计

退款政策归业务,退款机制归 pay(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。

渠道退款怎么走
支付宝/微信/Stripe/PayPal业务发起POST /refundsprovider.refund() 调渠道退款 API → webhook refund.succeeded
crypto(自托管)无退款 API → pay 记"待人工"退款单,运营用 sweep 反向链上转账、回填状态
IAP / 信用卡拒付平台发起 → pay 收平台通知 → 转发 refund.succeeded/chargeback 给业务
crypto 无 chargeback、无强制退款 —— 钱到账不可逆,不会被发卡行强行拉回。这是自托管抗风控的又一优势。

7. 促销 / 折扣归属

促销"逻辑"(谁有资格/限几次/什么活动/券码/A-B)全在业务侧;pay 只按被授权的最终金额收款 + 记账留痕。

场景机制pay 怎么做
结构性稳定价(首月¥1、年付折扣)做成 pay 定价档 product业务按资格选 product_id,pay 收该档权威价
动态活动(限时8折/满减/优惠券)业务算折后价,带签名折扣明细(base/discount/campaign_id/reason)pay 记账(留痕对账),按最终价收;不判断折扣合理性

8. 多币种 + 配置驱动渠道 + 国内外双部署

9. codes 兑换内核(定架构,不深实现)

与 pay 平级的共享兑换内核(以 pangolin codes 为基:哈希存储 + 发卡店 webhook 灌码 + Redis 限流 + 三层防双花)。抽公共:码模型 / 状态机(unused/redeemed/void)/ 生成器 / 兑换事务骨架(锁 + CAS + 幂等)/ 批次 / 审计 + max(现到期,now)+时长 叠加算法。不并入 pay(激活码携带业务语义、由发卡店注入、与支付渠道正交)。归属维度(user/shop)与"最终开通落库"用回调接口留给各产品。

9.1 部署选型:A 共享库嵌入各产品(已定)

决定性技术点:兑码 = "标记码已用 + 开该产品权益" 要原子,而开权益在各产品自己的库。只要码数据不在产品库(独立部署 or 跟 pay 一起),核销就是跨库分布式事务(要 saga);码嵌入各产品才是本地事务(pangolin 现行做法)。

方案redeem 原子性运维集中管码/reseller与 pay结论
A 共享库嵌入各产品本地事务无新服务各产品各一套不碰 pay现选
B 独立服务(与 pay 平级)跨库 saga多一服务✅ 集中平级中性发卡规模化后再抽
C 跟 pay 同部署跨库 saga多一部署集中污染 pay 中性/故障域不采用

已定 A:codes 内核做成 Go 共享库(带契约,像 pay-contract),各产品 import,码表落各产品库、redeem 本地事务。发卡/reseller 规模化到需要"集中管所有码 + reseller 门户 + 跨产品对账"时,再抽 B 独立服务(接受 redeem saga)。不与 pay 同部署——"平级"指逻辑与部署都独立于 pay。

9.2 履约动作多态:购买激活码 = pay 收款 + codes 发码

pay 中性、履约归业务 → 业务的履约 handler 收到 payment.succeeded 后,动作是多态的:

履约动作场景
直接开权益普通订阅购买(自己用)
mint + 发码购买激活码:调 codes 内核 mint 一个码(状态 unused、记来源订单 out_trade_no)→ 交付买家(邮件/页面/reseller 回调)。以后买家 redeem → 开权益(价值买时已预付)
加余额(ledger)充值(dudu 秒数 / 流量包)

pay 不知道这是"码商品",只卖 product、发事件;"这笔钱换成什么"由业务履约 handler 决定。天然支持送人 / 换账号设备兑 / iOS 合规(站外买码站内兑 3.1.3b)/ reseller 分销——即自建版"独角数卡发卡"。退款:码未兑 → void;已兑 → 走权益冲正。

10. 契约版本策略(pay-contract)

11. 设计决策 · 知识点(我们敲定的要点)

  1. 统一目标是 pay 网关,不是各产品共享代码 —— pay 已是通用多租户网关,pangolin/jiu 做瘦接入方,不各自造编排层。
  2. 三个正交关注点:pay 收款 / codes 兑换(平级)/ entitlement 开通(各产品自留)。只共享"入账成功"事件契约 + 时长叠加算法。
  3. 一单 N 渠道、只一个成功:Order + Attempt 两层,标付原子守卫 order=pending。这些功能归 pay 实现,不在接入方。
  4. attempt 超时 ≠ order 关闭:超时挂 attempt,order 仍 pending 可换渠道;order 另有整体购买窗口。可 cancel,取消单在列表可见。
  5. render_type 6 个;pay 下发数据不下发 UI,样式布局归业务方(除托管/系统 UI 归平台)。加同形态新渠道零改 client(redirect/qr/crypto_address 完全开放;native/sdk/iap 半开放)。
  6. verify_callback 吸收全部渠道差异;query 轮询兜底是刚需(crypto/国内扫码无可靠 push;webhook 可能被 GFW/CF 吞)。
  7. 金额权威在 pay,不信裸传金额;折后价靠"定价档 product"或"签名折扣明细"表达。促销逻辑归业务,收款+记账归 pay。
  8. 退款政策归业务,退款机制归 pay;退款有业务发起(卡/宝/微)、人工(crypto)、平台发起(IAP/拒付)三向,统一收敛成 refund 事件。开通冲正由业务做。
  9. 多账户/单渠道 + 路由策略(轮询/加权/限额/地址池);attempt 绑账户,回调/退款/结算走同一账户。用于分摊限额/抗冻/隐私/多主体。
  10. 渠道开关配置驱动,加渠道不改代码;国内外同码双部署,crypto(USDT-TRC20 自托管)是抗风控始终兜底通道。
  11. recurring 设计进模型、暂不实现(4 类 kind),避免第三次 breaking;crypto 无 recurring 用到期提醒再买。
  12. IAP 不对称:jiu 上 App Store 被迫 iOS 隐藏购买 UI(3.1.1);pangolin 站外分发不受限。统一层把"平台合规开关"当一等公民。
  13. 激活码不并入 pay(业务语义 + 发卡店注入,与支付正交),做共享 Go 库(A)嵌入各产品(redeem 本地事务),规模化再抽独立服务、不与 pay 同部署。购买激活码=pay 收款 + codes mint 发码,验证"pay 中性、履约动作多态(开权益/发码/加余额)"。优惠券=码机制同源但折扣归业务。

12. 可扩展性验证:流量包 / 额度型权益

压力测试:未来若不仅卖时长、还卖流量包(数据额度),pay 要改吗?—— pay 核心零改。

pay 是权益无关(entitlement-agnostic)的:它只卖"有价商品(product = biz_code + 金额)"、发 payment.succeeded(product_biz_code) 事件,不知道也不关心商品给的是"30 天时长"还是"100GB 流量"——那是业务侧的事。加流量包 = ①pay 加一个 product(data_100gb + 价);②业务侧把该 biz_code 映射成"+100GB"。pay 的 Order/Attempt/Account/Refund/render_type/webhook 全部不动

这恰好验证了本设计「entitlement 各产品自留、pay/codes 只透传 biz_code」的决策——正因权益形态会变(时长 / 流量 / 档位 / 多维),pay 保持权益无关,新权益类型永远碰不到 pay。若当初把 entitlement 塞进 pay,今天流量包就得改 pay。

真正要扩的是履约/开通侧(业务 + 可选共享 util),entitlement 形态从一种扩到两种:

形态模型累加方式谁在用
时长型subscription + expires_atmax(现到期,now)+天数pangolin 订阅 / jiu license
额度型ledger + 余额余额 += 数量(GB/秒/次)dudu 已有(充值秒数)、未来流量包

13. 待确认 / 后续

相关:pay 接入 jiu 对接方案 · 部署 Runbook