把 pay 做成 pangolin / jiu / dudu / 未来产品共用的支付层:国内外常见支付 + 加密货币,统一 Order+Attempt(一单 N 渠道只一个成功 / 可取消 / 可退款),render_type 多态(加渠道不改 client),多账户路由,配置驱动渠道开关,国内外双部署。2026-07-10
📋 实现计划:P1 核心数据模型 + 配置账户注册表(8 阶段之一;P2–P8 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。
Provider.verify_callback;开通/权益各产品自留,只共享"入账成功"事件契约 + 时长叠加算法。加同形态新渠道 = 服务端零改客户端。
调研三个项目后的定位:
| 项目 | 角色 | 现状 |
|---|---|---|
| pay | 收款中枢 | 已是多租户网关(Channel 接口 + biz.<name> 配置 + HMAC 双向 webhook + 查单兜底),但只支付宝/微信、只 CNY、只国内、单一 pay_url、无 attempts/退款主动流/订阅 |
| jiu | pay 的瘦接入方 | 零渠道代码,下单→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)。
"统一支付 + 履约层"拆成三块,各自成中枢,不混成一坨:
| 关注点 | 是什么 | 统一? |
|---|---|---|
| ① pay(在线收款) | 实时收款网关 | ✅ 统一(本设计核心) |
| ② codes(离线兑换) | 激活码/兑换券(发卡店灌码、用户兑换) | ✅ 抽共享内核,与 pay 平级、不并入 |
| ③ entitlement(开通) | 权益记账(pangolin per-user 订阅 / jiu 门店 license) | ❌ 各产品自留 |
统一的是两条入账通道(pay 收款 / codes 兑换)+ 一份"入账成功"事件契约 + 时长叠加算法;开通落库因数据形状不同(user 多行 vs shop 单行)各产品自己做。
| 字段 | 说明 |
|---|---|
| out_trade_no | 幂等主键 |
| product_id | pay 套餐(金额权威,不接受裸传金额) |
| 金额 / 币种 | amount_minor(int64) + presentment / charge / settlement_currency 三种货币快照 |
| biz_system / biz_ref | 接入方业务系统 + 业务单号(原样回传) |
| status | created | pending | paid | canceled | expired | refunding | partially_refunded | refunded |
| discount | base_amount + discount + campaign_id + reason(可选,业务授权的折后价,记账留痕) |
| created_at / paid_at / expires_at | 时间戳;expires_at = 整体购买窗口 |
建单锁定 product/金额/biz_ref,回调只能推进已存在订单——防伪造纵深。可 POST /orders/{no}/cancel(pending→canceled),取消单仍在订单列表。
| 字段 | 说明 |
|---|---|
| attempt_id | 主键 |
| out_trade_no | FK → Order |
| channel | 支付渠道 |
| account_id | 选中的收款账户(§3.3) |
| provider_ref | 渠道单号 |
| render_type | 渲染形态(§4.2) |
| amount_minor + currency | 该尝试应收 |
| status | created | pending | paid | failed | expired |
| expires_at | 本次尝试超时 |
| UNIQUE(channel, provider_ref) | 回调幂等命门 |
一单 N attempt、只一个成功:标付原子守卫 order=pending。attempt 超时只弃本 attempt,order 仍 pending → 换渠道重试(POST /orders/{no}/retry)。你担心的「N 渠道只一个成功 / 可取消 / 加渠道不改 client」全在这两层实现,接入方零感知。
分三层:渠道类型(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 地址池 | 每单不同地址,或单地址 + 唯一金额 |
create() 里。| 字段 | 说明 |
|---|---|
| refund_id | 主键 |
| out_trade_no | 关联订单 |
| attempt / provider_ref | 针对哪笔支付(原路退回) |
| amount_minor + currency | 退款额 |
| reason | 退款原因 |
| status | requested | processing | succeeded | failed |
| provider_refund_ref | 渠道退款号 |
| initiated_by | business(业务发起) | platform(平台发起) |
| created_at / completed_at | 时间戳 |
一单可多次部分退,累计 ≤ 已付。详见 §6。
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 是一等动作。
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 | 系统 Pay | payment_request(PKPaymentRequest/GPay), gateway, gateway_token | 唤起 Apple/Google Pay 系统 Sheet,token 回传 verify | 操作系统 |
sdk_handoff | 原生 SDK | sdk_name, client_secret/prepay_params | 调 SDK(Stripe PaymentSheet / 微信 JSAPI) | SDK/平台 |
iap_receipt | 苹果/谷歌内购 | product_id, store | 调 StoreKit/Play Billing,签名交易/token 回传 verify | App 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 自渲染。
redirect(服务端返 wap URL,client launchUrl,系统经 alipays:///weixin:// scheme 自动拉起 App,付完经 return_url/return_scheme 回跳;无需 SDK、完全开放,推荐,jiu 现行做法);②原生 App 支付 SDK→ sdk_handoff(服务端返订单串/prepay 参数,client 调渠道原生 SDK 拉起+回调;需集成 SDK、半开放)。回跳拿不到也没事,client 始终轮询订单状态收敛。v2 断代:webhook 加 event_type,接入方显式声明支持的事件集。
| event_type | 本轮 | 说明 |
|---|---|---|
payment.succeeded | 先做 | 收款成功 → 接入方开通 |
refund.succeeded / refund.failed | 卡类要真做 | 退款结果 → 接入方冲正权益 |
subscription.created | ✅ P8 | 订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions) |
subscription.renewed | ✅ P8 | 续费成功(invoice.paid,下一期扣款到账) |
subscription.past_due | ✅ P8 | 续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡) |
subscription.canceled | ✅ P8 | 订阅取消(业务方发起或渠道侧取消) |
chargeback.received | ✅ P8 | 信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流) |
签名沿用双向 HMAC(system\ntimestamp\nnonce\nrawBody,±5min,nonce 防重放);payload 幂等键 out_trade_no。接入方收 payment.succeeded → 验签 → 幂等 → 金额核对 → max(现到期,now)+时长 叠加(此算法抽成共享工具);非 200/不含 SUCCESS → 60s 重试 24h。
gateway_scheduled(Stripe)P8 已实现,其余仍设计进模型本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":
| recurring_kind | 谁驱动 | 代表 | pay 如何推进 |
|---|---|---|---|
token_offsession | 我方 | Stripe自建/支付宝周期扣/微信papay | cron 到期主动 charge(能力位,未实现) |
gateway_scheduled | 网关 | Stripe Subscriptions / PayPal | 监听 invoice.paid webhook(✅ P8 已实现,Stripe;PayPal 未接) |
store_managed | 平台 | Apple IAP / Google Play | 被动接 Server Notification/RTDN(能力位,未实现) |
none | 无 | 所有 crypto / 单笔 | 到期提醒用户手动再买(伪续订) |
统一 subscription 实体 + 状态机(active→past_due→canceled,internal/model/subscription.go);单笔层与订阅层解耦。crypto 天生无 recurring(SupportsRecurring=false,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → subscription.created;invoice.paid → 续费单 + subscription.renewed;invoice.payment_failed → subscription.past_due;取消(业务方 API 或渠道侧)→ subscription.canceled;charge.dispute.created → Chargeback 记录 + 原单打 disputed 标 + chargeback.received。token_offsession/store_managed/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。
BizSystemConfig.SupportedEvents(config.yaml 的 biz.<name>.supported_events):接入方在配置里显式声明自己能处理哪些 event_type。空 = 只收 payment.succeeded(v1/P2 老接入方向后兼容,不会突然收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:event_type 不在声明集 → 不 POST、直接标 delivered(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。
门禁旁路:subscription.past_due/subscription.canceled 挂在已 paid 的首购单(out_trade_no)上,既有的 orderPaid 投递门禁天然放行,无需额外旁路。
| event_type | payload 关键字段 |
|---|---|
subscription.created | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/created_at |
subscription.renewed | out_trade_no(续费单号,与首购单号不同)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/paid_at |
subscription.past_due | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/failed_at |
subscription.canceled | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/canceled_at |
chargeback.received | out_trade_no/dispute_ref/biz_system/biz_ref/product_biz_code/amount_minor/currency/reason/received_at |
所有事件的 event_type 字段本身也回显在 payload 顶层(与 X-Pay-Event 头一致),便于业务方单点分发。subscription.renewed 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + event_type(同订阅只会 created 一次、canceled 一次;past_due 当前实现下同订阅多次失败只保证首次必达,见 internal/gateway/subscription.go::markSubscriptionPastDue 注释)。
退款政策归业务,退款机制归 pay(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。
| 渠道 | 退款怎么走 |
|---|---|
| 支付宝/微信/Stripe/PayPal | 业务发起 → POST /refunds → provider.refund() 调渠道退款 API → webhook refund.succeeded |
| crypto(自托管) | 无退款 API → pay 记"待人工"退款单,运营用 sweep 反向链上转账、回填状态 |
| IAP / 信用卡拒付 | 平台发起 → pay 收平台通知 → 转发 refund.succeeded/chargeback 给业务 |
refund.succeeded → 自己冲正 entitlement(缩订阅/降级/标记),因为 entitlement 归业务。paid → refunding →(全额)refunded /(部分)partially_refunded;一单可多次部分退,累计 ≤ 已付。促销"逻辑"(谁有资格/限几次/什么活动/券码/A-B)全在业务侧;pay 只按被授权的最终金额收款 + 记账留痕。
| 场景 | 机制 | pay 怎么做 |
|---|---|---|
| 结构性稳定价(首月¥1、年付折扣) | 做成 pay 定价档 product | 业务按资格选 product_id,pay 收该档权威价 |
| 动态活动(限时8折/满减/优惠券) | 业务算折后价,带签名折扣明细(base/discount/campaign_id/reason) | pay 记账(留痕对账),按最终价收;不判断折扣合理性 |
out_trade_no 幂等 + 可选去重键 + settle 事务二次查)。presentment(展示价)/ charge(扣款币种)/ settlement(落袋币种),便于对账与按原价退款。config.biz 范式扩展 config.channels/config.accounts。pay 启动加载注册表,方法发现端点只下发已启用渠道。加/停/切换渠道 = 改配置重启(可选热重载),不改代码。与 pay 平级的共享兑换内核(以 pangolin codes 为基:哈希存储 + 发卡店 webhook 灌码 + Redis 限流 + 三层防双花)。抽公共:码模型 / 状态机(unused/redeemed/void)/ 生成器 / 兑换事务骨架(锁 + CAS + 幂等)/ 批次 / 审计 + max(现到期,now)+时长 叠加算法。不并入 pay(激活码携带业务语义、由发卡店注入、与支付渠道正交)。归属维度(user/shop)与"最终开通落库"用回调接口留给各产品。
决定性技术点:兑码 = "标记码已用 + 开该产品权益" 要原子,而开权益在各产品自己的库。只要码数据不在产品库(独立部署 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。
pay 中性、履约归业务 → 业务的履约 handler 收到 payment.succeeded 后,动作是多态的:
| 履约动作 | 场景 |
|---|---|
| 直接开权益 | 普通订阅购买(自己用) |
| mint + 发码 | 购买激活码:调 codes 内核 mint 一个码(状态 unused、记来源订单 out_trade_no)→ 交付买家(邮件/页面/reseller 回调)。以后买家 redeem → 开权益(价值买时已预付) |
| 加余额(ledger) | 充值(dudu 秒数 / 流量包) |
pay 不知道这是"码商品",只卖 product、发事件;"这笔钱换成什么"由业务履约 handler 决定。天然支持送人 / 换账号设备兑 / iOS 合规(站外买码站内兑 3.1.3b)/ reseller 分销——即自建版"独角数卡发卡"。退款:码未兑 → void;已兑 → 走权益冲正。
render_type / channel / 折扣明细:向后兼容加字段(照 client_type v1.1.0「可选加字段 + 缺省兜底 + 非法报错」范式)。event_type(退款 / 订阅 / 拒付多事件):断代 v2.0.0,接入方显式声明支持事件集。GET /products 进 openapi.yaml(现仅散落 README/看板);README 版本号收口到 v2。cancel,取消单在列表可见。压力测试:未来若不仅卖时长、还卖流量包(数据额度),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_at | max(现到期,now)+天数 | pangolin 订阅 / jiu license |
| 额度型 | ledger + 余额 | 余额 += 数量(GB/秒/次) | dudu 已有(充值秒数)、未来流量包 |
daily_minutes + daily_mb)。gateway_scheduled(Stripe)订阅状态机 + chargeback 拒付记录 + 业务方事件集声明/过滤;token_offsession/store_managed 仍留能力位、未实现。