Files
pay/docs/pay-v2-unified-gateway-design.html

440 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pay v2 · 多产品统一支付 + 履约网关设计</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--bad:#f85149;--radius:14px;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:1000px;margin:0 auto;padding:44px 24px 96px}
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
h1{font-size:30px;margin:10px 0 8px}
.lead{color:var(--fg-soft);margin:0 0 20px}
h2{font-size:19px;margin:40px 0 12px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
h3{font-size:15px;margin:22px 0 8px;color:var(--fg)}
p{margin:10px 0}
a{color:var(--accent);text-decoration:none}
.back{display:inline-block;margin-bottom:20px;font-size:13px}
code{font-family:var(--mono);font-size:.85em;background:var(--card-2);padding:1px 6px;border-radius:5px;color:#cdd9e5}
pre{background:#0a0e14;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd9e5}
pre.code .k{color:#ff7b72}pre.code .ty{color:#79c0ff}pre.code .fn{color:#d2a8ff}pre.code .st{color:#a5d6ff}pre.code .nu{color:#79c0ff}pre.code .co{color:#8b949e;font-style:italic}
table.fields td:first-child{font-family:var(--mono);font-size:12px;color:#cdd9e5;white-space:nowrap;width:1%}
table.fields td code{background:transparent;padding:0}
b{color:#fff}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg-soft);font-weight:600;font-size:13px}
ul,ol{padding-left:22px;margin:8px 0}
li{margin:5px 0}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin:14px 0}
.lead-box{background:linear-gradient(180deg,rgba(88,166,255,.10),transparent);border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;margin:0 0 8px}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.ok{background:rgba(63,185,80,.16);color:var(--ok)}
.tag.warn{background:rgba(210,153,34,.16);color:var(--warn)}
.tag.info{background:rgba(88,166,255,.16);color:var(--accent)}
.tag.bad{background:rgba(248,81,73,.16);color:var(--bad)}
.small{color:var(--muted);font-size:13px}
.warnbox{background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.35);border-radius:12px;padding:12px 16px;margin:14px 0;font-size:13.5px}
.svgwrap{overflow-x:auto;background:#0a0e14;border:1px solid var(--border);border-radius:12px;padding:10px 6px;margin:16px 0}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<div class="eyebrow">DESIGN · v2</div>
<h1>pay · 多产品统一支付 + 履约网关设计</h1>
<p class="lead">把 pay 做成 pangolin / jiu / dudu / 未来产品共用的支付层:国内外常见支付 + 加密货币,统一 Order+Attempt(一单 N 渠道只一个成功 / 可取消 / 可退款),render_type 多态(加渠道不改 client),多账户路由,配置驱动渠道开关,国内外双部署。2026-07-10</p>
<p class="small">📋 <b>实现计划</b>:<a href="pay-v2-p1-plan.html">P1 核心数据模型 + 配置账户注册表</a>(8 阶段之一;P2–P8 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。</p>
<div class="lead-box">
<b>一句话</b>:pay 是<b>中性在线收款中枢</b>——只管"按被授权的金额收款/退款 + 记账 + 发事件",不碰业务语义。客户端只认 <b>6 个 render_type</b>(付款意图数据,非 UI);渠道差异全封死在服务端 <code>Provider.verify_callback</code>;开通/权益各产品自留,只共享"入账成功"事件契约 + 时长叠加算法。<b>加同形态新渠道 = 服务端零改客户端。</b>
</div>
<h2>1. 背景:三方现状 → 收口到 pay</h2>
<p>调研三个项目后的定位:</p>
<table>
<thead><tr><th>项目</th><th>角色</th><th>现状</th></tr></thead>
<tbody>
<tr><td><b>pay</b></td><td>收款中枢</td><td>已是多租户网关(<code>Channel</code> 接口 + <code>biz.&lt;name&gt;</code> 配置 + HMAC 双向 webhook + 查单兜底),但<b>只支付宝/微信、只 CNY、只国内、单一 pay_url、无 attempts/退款主动流/订阅</b></td></tr>
<tr><td><b>jiu</b></td><td>pay 的瘦接入方</td><td>零渠道代码,下单→pay_url→轮询→webhook 开通(门店 license)。<b>iOS 隐藏购买 UI 走 3.1.1 合规,无 IAP</b>。是理想接入方样板</td></tr>
<tr><td><b>pangolin</b></td><td>待接入</td><td>曾想自建编排层(重复造轮子,已废),已部署 pangolin-pay(USDT 单地址收款)→ 转为 pay 的 crypto adapter 后端。站外分发<b>不受 IAP 约束</b></td></tr>
</tbody>
</table>
<p><b>决策</b>:pay <b>干净 v2 重设计</b>(pay 才 ~1700 行、jiu 未上线,趁早把模型做对);pangolin/jiu 作为接入方收口 —— <b>pay 定稿后</b>再动(记录在 brain todo #5)。</p>
<h2>2. 架构与分层:3 个正交关注点</h2>
<p>"统一支付 + 履约层"拆成三块,<b>各自成中枢,不混成一坨</b>:</p>
<table>
<thead><tr><th>关注点</th><th>是什么</th><th>统一?</th></tr></thead>
<tbody>
<tr><td><b>① pay(在线收款)</b></td><td>实时收款网关</td><td><span class="tag ok">✅ 统一(本设计核心)</span></td></tr>
<tr><td><b>② codes(离线兑换)</b></td><td>激活码/兑换券(发卡店灌码、用户兑换)</td><td><span class="tag info">✅ 抽共享内核,与 pay 平级、不并入</span></td></tr>
<tr><td><b>③ entitlement(开通)</b></td><td>权益记账(pangolin per-user 订阅 / jiu 门店 license)</td><td><span class="tag warn">❌ 各产品自留</span></td></tr>
</tbody>
</table>
<p>统一的是<b>两条入账通道</b>(pay 收款 / codes 兑换)+ <b>一份"入账成功"事件契约 + 时长叠加算法</b>;开通落库因数据形状不同(user 多行 vs shop 单行)各产品自己做。</p>
<div class="svgwrap">
<svg viewBox="0 0 960 470" style="width:100%;min-width:820px;height:auto;display:block" font-family="'SF Mono',ui-monospace,'PingFang SC',sans-serif" role="img" aria-label="pay v2 分层架构图">
<defs>
<marker id="ar" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#58a6ff"/></marker>
<marker id="ag" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#3fb950"/></marker>
</defs>
<style>
.box{fill:#161b22;stroke:#283041;stroke-width:1}
.box2{fill:#1c2330;stroke:#31405a;stroke-width:1}
.t{fill:#e6edf3;font-size:12.5px;font-weight:600}
.ts{fill:#aeb9c7;font-size:10.5px}
.lbl{fill:#8aa0b8;font-size:10px;text-anchor:middle}
.flow{stroke:#58a6ff;stroke-width:1.5;fill:none}
.grant{stroke:#3fb950;stroke-width:1.5;fill:none;stroke-dasharray:5 4}
.accent{fill:#58a6ff;font-size:11px;font-weight:600}
.ok{fill:#3fb950;font-size:10.5px;font-weight:600}
</style>
<!-- 接入方层 -->
<rect class="box" x="24" y="20" width="912" height="58" rx="10"/>
<text class="accent" x="40" y="40">接入方(多产品 · 客户端 + 后端)</text>
<text class="ts" x="40" y="60">pangolin(VPN·订阅) · jiu(酒库·门店license) · dudu(充值秒数) · 未来… ——— client 只认 render_type,后端签名调 pay</text>
<!-- pay 网关层 -->
<rect class="box" x="24" y="108" width="620" height="256" rx="10"/>
<text class="accent" x="40" y="130">pay 网关(中性收款中枢 · 多租户 biz.&lt;name&gt;</text>
<rect class="box2" x="40" y="144" width="270" height="70" rx="8"/>
<text class="t" x="52" y="166">Order 业务订单</text>
<text class="ts" x="52" y="184">out_trade_no 幂等 · 金额权威</text>
<text class="ts" x="52" y="200">created→pending→paid→(退款态)</text>
<rect class="box2" x="326" y="144" width="300" height="70" rx="8"/>
<text class="t" x="338" y="166">Attempt 支付尝试(N 个/单)</text>
<text class="ts" x="338" y="184">绑 channel + account + render_type</text>
<text class="ts" x="338" y="200">一单 N 渠道 · 只一个成功 · 各自超时</text>
<rect class="box2" x="40" y="224" width="270" height="58" rx="8"/>
<text class="t" x="52" y="246">Account 路由(多账户/单渠道)</text>
<text class="ts" x="52" y="264">轮询/加权/限额/地址池 → 选一账户</text>
<rect class="box2" x="326" y="224" width="300" height="58" rx="8"/>
<text class="t" x="338" y="246">Refund 退款子实体</text>
<text class="ts" x="338" y="264">可多次部分退 · 原账户原路退</text>
<rect class="box2" x="40" y="292" width="586" height="58" rx="8"/>
<text class="t" x="52" y="314">Provider adapter 注册表 capabilities / create / verify_callback / query / refund (+recurring)</text>
<text class="ts" x="52" y="332">verify_callback = 渠道差异垃圾回收站(RSA/证书/链上确认/收据校验) · 配置驱动启停</text>
<!-- 渠道 -->
<rect class="box" x="668" y="108" width="268" height="256" rx="10"/>
<text class="accent" x="684" y="130">支付渠道(按部署/区域启用)</text>
<text class="ts" x="684" y="156">🇨🇳 支付宝 · 微信 · 银联 · 哪吒聚合</text>
<text class="ts" x="684" y="178">🌍 Stripe · PayPal · Apple/Google Pay</text>
<text class="ok" x="684" y="202">🔗 crypto 自托管(USDT-TRC20)</text>
<text class="ts" x="684" y="220">   抗风控 · 始终兜底通道</text>
<text class="ts" x="684" y="244">📱 Apple IAP / Google Play(半开放)</text>
<text class="ts" x="684" y="290">每渠道可多账户(多主体/多地址)</text>
<text class="ts" x="684" y="312">回调/退款/结算走同一账户</text>
<text class="ts" x="684" y="340">cn / global 双部署 · 同码两配置</text>
<!-- codes 平级 -->
<rect class="box" x="24" y="394" width="450" height="58" rx="10"/>
<text class="ok" x="40" y="416">codes 兑换内核(与 pay 平级 · 共享库/契约)</text>
<text class="ts" x="40" y="436">码模型/状态机/生成/防双花兑换/批次/审计 + 时长叠加算法(激活码 · 优惠券未来)</text>
<!-- entitlement -->
<rect class="box" x="498" y="394" width="438" height="58" rx="10"/>
<text class="t" x="514" y="416">entitlement 开通(各产品自留)</text>
<text class="ts" x="514" y="436">收「入账成功」事件 → 验签/幂等/金额核对 → max(现到期,now)+时长;退款→冲正</text>
<!-- flows -->
<line class="flow" x1="330" y1="78" x2="330" y2="106" marker-end="url(#ar)"/>
<text class="lbl" x="398" y="96">① 签名下单 POST /orders</text>
<line class="flow" x1="644" y1="236" x2="666" y2="236" marker-end="url(#ar)"/>
<text class="lbl" x="655" y="228">create</text>
<line class="grant" x1="668" y1="326" x2="648" y2="326" marker-end="url(#ag)"/>
<text class="lbl" x="657" y="316">verify</text>
<line class="grant" x1="648" y1="368" x2="648" y2="392" marker-end="url(#ag)"/>
<text class="lbl" x="757" y="382">② webhook(event_type) 开通/冲正</text>
</svg>
</div>
<h2>3. 数据模型:Order + Attempt + Account + Refund</h2>
<h3>3.1 Order 业务订单(购买账本)</h3>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>out_trade_no</td><td>幂等主键</td></tr>
<tr><td>product_id</td><td>pay 套餐(<b>金额权威</b>,不接受裸传金额)</td></tr>
<tr><td>金额 / 币种</td><td>amount_minor(int64) + presentment / charge / settlement_currency 三种货币快照</td></tr>
<tr><td>biz_system / biz_ref</td><td>接入方业务系统 + 业务单号(原样回传)</td></tr>
<tr><td>status</td><td>created | pending | paid | canceled | expired | refunding | partially_refunded | refunded</td></tr>
<tr><td>discount</td><td>base_amount + discount + campaign_id + reason(可选,业务授权的折后价,记账留痕)</td></tr>
<tr><td>created_at / paid_at / expires_at</td><td>时间戳;expires_at = 整体购买窗口</td></tr>
</tbody>
</table>
<p class="small">建单锁定 product/金额/biz_ref,回调只能推进已存在订单——防伪造纵深。可 <code>POST /orders/{no}/cancel</code>(pending→canceled),取消单仍在订单列表。</p>
<h3>3.2 Attempt 支付尝试(收款账本)</h3>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>attempt_id</td><td>主键</td></tr>
<tr><td>out_trade_no</td><td>FK → Order</td></tr>
<tr><td>channel</td><td>支付渠道</td></tr>
<tr><td>account_id</td><td>选中的收款账户(§3.3)</td></tr>
<tr><td>provider_ref</td><td>渠道单号</td></tr>
<tr><td>render_type</td><td>渲染形态(§4.2)</td></tr>
<tr><td>amount_minor + currency</td><td>该尝试应收</td></tr>
<tr><td>status</td><td>created | pending | paid | failed | expired</td></tr>
<tr><td>expires_at</td><td>本次尝试超时</td></tr>
<tr><td>UNIQUE(channel, provider_ref)</td><td>回调幂等命门</td></tr>
</tbody>
</table>
<p class="small"><b>一单 N attempt、只一个成功</b>:标付原子守卫 order=pending。attempt 超时只弃本 attempt,order 仍 pending → 换渠道重试(<code>POST /orders/{no}/retry</code>)。你担心的「N 渠道只一个成功 / 可取消 / 加渠道不改 client」全在这两层实现,接入方零感知。</p>
<h3>3.3 Account 收款账户(同一渠道多账户 + 路由)</h3>
<p><b>分三层:渠道类型(channel) → 收款账户(account,N 个) → 每单按策略选一个。</b></p>
<table class="fields">
<thead><tr><th>Account 字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>channel</td><td>渠道类型</td></tr>
<tr><td>account_id</td><td>账户标识</td></tr>
<tr><td>凭证</td><td>密钥 / xpub / merchant_id(从 env 取,不入库明文)</td></tr>
<tr><td>weight</td><td>路由权重</td></tr>
<tr><td>enabled</td><td>启停</td></tr>
<tr><td>daily_limit</td><td>日限额</td></tr>
<tr><td>region</td><td>区域</td></tr>
<tr><td>subject</td><td>收款主体</td></tr>
</tbody>
</table>
<table class="fields">
<thead><tr><th>路由策略</th><th>说明</th></tr></thead>
<tbody>
<tr><td>round_robin</td><td>轮询分摊</td></tr>
<tr><td>weighted</td><td>按权重</td></tr>
<tr><td>limit_aware</td><td>分摊避免单账户超额触风控冻结</td></tr>
<tr><td>by_region / amount / biz</td><td>按区域 / 金额 / 业务系统</td></tr>
<tr><td>crypto 地址池</td><td>每单不同地址,或单地址 + 唯一金额</td></tr>
</tbody>
</table>
<ul>
<li><b>Attempt 绑定选中的 account_id</b> —— 回调/退款/结算<b>必须走同一账户</b>(退款原路、结算落对应主体)。</li>
<li>用途:分摊限额、抗风控抗冻卡、隐私(crypto 多地址)、多主体收款(国内多支付宝 / 海外多 Stripe)。</li>
<li>crypto 多地址天然落此:一个 crypto account = 一个 xpub(派生 N 地址)或固定地址;地址分配在 crypto adapter <code>create()</code> 里。</li>
</ul>
<h3>3.4 Refund 退款子实体</h3>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>refund_id</td><td>主键</td></tr>
<tr><td>out_trade_no</td><td>关联订单</td></tr>
<tr><td>attempt / provider_ref</td><td>针对哪笔支付(<b>原路退回</b>)</td></tr>
<tr><td>amount_minor + currency</td><td>退款额</td></tr>
<tr><td>reason</td><td>退款原因</td></tr>
<tr><td>status</td><td>requested | processing | succeeded | failed</td></tr>
<tr><td>provider_refund_ref</td><td>渠道退款号</td></tr>
<tr><td>initiated_by</td><td>business(业务发起) | platform(平台发起)</td></tr>
<tr><td>created_at / completed_at</td><td>时间戳</td></tr>
</tbody>
</table>
<p class="small">一单可多次部分退,累计 ≤ 已付。详见 §6。</p>
<h2>4. Provider adapter + render_type 与渲染归属</h2>
<h3>4.1 Provider 接口(每渠道实现)</h3>
<pre class="code"><span class="k">interface</span> <span class="ty">PaymentProvider</span> {
<span class="fn">capabilities</span>() → { render_types, supports_refund, supports_recurring,
recurring_kind, settle_currencies, regions }
<span class="fn">create</span>(order, account, ctx) → { provider_ref, render_type, payload, expires_at }
<span class="fn">verify_callback</span>(raw|chain_event|receipt) → { order_ref, status, paid_amount, paid_currency }
<span class="fn">query</span>(provider_ref) → { status, paid_amount }
<span class="fn">refund</span>(provider_ref, amount, reason) → { refund_ref, status } <span class="co">// 不支持则 capabilities=false</span>
}
<span class="k">interface</span> <span class="ty">RecurringProvider</span> { <span class="fn">create_agreement</span> / <span class="fn">charge</span> / <span class="fn">cancel_agreement</span> / <span class="fn">sync_from_notification</span> } <span class="co">// 可选,§5</span></pre>
<p><code>verify_callback</code> 是渠道差异的<b>垃圾回收站</b>:验签(Stripe sig / 微信证书 / 支付宝 RSA)、链上确认数、IAP 的 JWS/purchaseToken 校验全封死在这,对外只吐统一 <code>{order_ref, status, paid_amount}</code>。crypto 与国内扫码无可靠 push 时靠 <code>query</code> 轮询兜底,故 query 是一等动作。</p>
<h3>4.2 render_type = 6 个(client 只认形态,不认渠道)</h3>
<p>pay 下发的是 <b>付款意图数据</b> <code>{render_type, payload}</code>,<b>不含任何 UI/CSS/布局</b></p>
<table>
<thead><tr><th>render_type</th><th>含义</th><th>payload 关键字段</th><th>client 怎么渲染</th><th>样式谁控</th></tr></thead>
<tbody>
<tr><td><code>redirect</code></td><td>跳第三方收银台 / 拉起 App(WAP)</td><td>url · return_url · return_scheme(回跳你的 app)</td><td>打开 URL(浏览器/webview);移动端 WAP 时系统经 scheme 自动拉起支付宝/微信 App;回跳不可信→轮询状态</td><td><b>支付平台</b>(收银台页)</td></tr>
<tr><td><code>qr</code></td><td>展示二维码</td><td>qr_content, display_amount, expires_at</td><td>本地把 qr_content 渲成码 + 金额/倒计时</td><td><span class="tag ok">业务方</span></td></tr>
<tr><td><code>crypto_address</code></td><td>链上收款</td><td>address, amount, asset, chain, expires_at, min_confirmations</td><td>展示地址+精确金额(可复制)+确认进度,轮询</td><td><span class="tag ok">业务方</span></td></tr>
<tr><td><code>native_pay</code></td><td>系统 Pay</td><td>payment_request(PKPaymentRequest/GPay), gateway, gateway_token</td><td>唤起 Apple/Google Pay 系统 Sheet,token 回传 verify</td><td><b>操作系统</b></td></tr>
<tr><td><code>sdk_handoff</code></td><td>原生 SDK</td><td>sdk_name, client_secret/prepay_params</td><td>调 SDK(Stripe PaymentSheet / 微信 JSAPI)</td><td><b>SDK/平台</b></td></tr>
<tr><td><code>iap_receipt</code></td><td>苹果/谷歌内购</td><td>product_id, store</td><td>调 StoreKit/Play Billing,签名交易/token 回传 verify</td><td><b>App Store/Play</b></td></tr>
</tbody>
</table>
<p><b>样式布局归属</b>:<b>pay 管付款意图数据,client 管样式与布局</b>。完全开放形态(<code>qr</code>/<code>crypto_address</code>)业务方用自己设计系统全权渲染(pangolin 暖色 clay 风、jiu 自己的风,各画各的);<code>redirect</code> 的收银台、<code>native_pay</code>/<code>sdk_handoff</code>/<code>iap_receipt</code> 的系统/SDK/平台弹窗,样式归平台,业务方只控触发按钮。</p>
<p><b>"加渠道不改 client"的边界</b>:<code>redirect</code>/<code>qr</code>/<code>crypto_address</code> = <b>完全开放</b>(加渠道纯服务端);<code>native_pay</code>/<code>sdk_handoff</code>/<code>iap_receipt</code> = <b>半开放</b>(复用已集成原生能力时零改,引入新 SDK/store 需发版)。对外承诺须按此措辞。</p>
<p class="small">pay 可可选提供服务端兜底渲染(如 <code>GET /qrcode?text=</code> 码串转 PNG)给纯 web 无 client 场景;规范路径是 client 自渲染。</p>
<div class="warnbox"><b>常见案例:移动端拉起支付宝/微信 App</b> —— 不需要单独 render_type,两种落法:①<b>手机网站/H5(WAP)</b><code>redirect</code>(服务端返 wap URL,client <code>launchUrl</code>,系统经 <code>alipays://</code>/<code>weixin://</code> scheme 自动拉起 App,付完经 return_url/return_scheme 回跳;<b>无需 SDK、完全开放,推荐</b>,jiu 现行做法);②<b>原生 App 支付 SDK</b><code>sdk_handoff</code>(服务端返订单串/prepay 参数,client 调渠道原生 SDK 拉起+回调;<b>需集成 SDK、半开放</b>)。回跳拿不到也没事,client 始终轮询订单状态收敛。</div>
<h2>5. webhook v2 事件模型 + 开通</h2>
<p><b>v2 断代:webhook 加 <code>event_type</code></b>,接入方显式声明支持的事件集。</p>
<table>
<thead><tr><th>event_type</th><th>本轮</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>payment.succeeded</code></td><td><span class="tag ok">先做</span></td><td>收款成功 → 接入方开通</td></tr>
<tr><td><code>refund.succeeded</code> / <code>refund.failed</code></td><td><span class="tag ok">卡类要真做</span></td><td>退款结果 → 接入方冲正权益</td></tr>
<tr><td><code>subscription.created</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions)</td></tr>
<tr><td><code>subscription.renewed</code></td><td><span class="tag ok">✅ P8</span></td><td>续费成功(invoice.paid,下一期扣款到账)</td></tr>
<tr><td><code>subscription.past_due</code></td><td><span class="tag ok">✅ P8</span></td><td>续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡)</td></tr>
<tr><td><code>subscription.canceled</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅取消(业务方发起或渠道侧取消)</td></tr>
<tr><td><code>chargeback.received</code></td><td><span class="tag ok">✅ P8</span></td><td>信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流)</td></tr>
</tbody>
</table>
<p>签名沿用双向 HMAC(<code>system\ntimestamp\nnonce\nrawBody</code>,±5min,nonce 防重放);payload 幂等键 <code>out_trade_no</code>。接入方收 <code>payment.succeeded</code> → 验签 → 幂等 → 金额核对 → <code>max(现到期,now)+时长</code> 叠加(<b>此算法抽成共享工具</b>);非 200/不含 SUCCESS → 60s 重试 24h。</p>
<h3>5.1 自动续订(recurring):<code>gateway_scheduled</code>(Stripe)P8 已实现,其余仍设计进模型</h3>
<p>本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":</p>
<table>
<thead><tr><th>recurring_kind</th><th>谁驱动</th><th>代表</th><th>pay 如何推进</th></tr></thead>
<tbody>
<tr><td><code>token_offsession</code></td><td>我方</td><td>Stripe自建/支付宝周期扣/微信papay</td><td>cron 到期主动 charge(能力位,<b>未实现</b>)</td></tr>
<tr><td><code>gateway_scheduled</code></td><td>网关</td><td>Stripe Subscriptions / PayPal</td><td>监听 invoice.paid webhook(<span class="tag ok">✅ P8 已实现,Stripe</span>;PayPal 未接)</td></tr>
<tr><td><code>store_managed</code></td><td>平台</td><td>Apple IAP / Google Play</td><td>被动接 Server Notification/RTDN(能力位,<b>未实现</b>)</td></tr>
<tr><td><code>none</code></td><td></td><td>所有 crypto / 单笔</td><td>到期提醒用户手动再买(伪续订)</td></tr>
</tbody>
</table>
<p class="small">统一 subscription 实体 + 状态机(active→past_due→canceled,<code>internal/model/subscription.go</code>);单笔层与订阅层解耦。crypto 天生无 recurring(<code>SupportsRecurring=false</code>,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → <code>subscription.created</code>;<code>invoice.paid</code> → 续费单 + <code>subscription.renewed</code>;<code>invoice.payment_failed</code><code>subscription.past_due</code>;取消(业务方 API 或渠道侧)→ <code>subscription.canceled</code>;<code>charge.dispute.created</code><code>Chargeback</code> 记录 + 原单打 disputed 标 + <code>chargeback.received</code><code>token_offsession</code>/<code>store_managed</code>/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。</p>
<h3>5.2 接入方显式声明支持事件集</h3>
<p><code>BizSystemConfig.SupportedEvents</code>(<code>config.yaml</code><code>biz.&lt;name&gt;.supported_events</code>):接入方在配置里显式声明自己能处理哪些 <code>event_type</code><b>空 = 只收 <code>payment.succeeded</code></b>(v1/P2 老接入方向后兼容,不会突然收到 <code>subscription.*</code>/<code>chargeback.received</code> 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:<code>event_type</code> 不在声明集 → 不 POST、直接标 <code>delivered</code>(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。</p>
<p class="small">门禁旁路:<code>subscription.past_due</code>/<code>subscription.canceled</code> 挂在已 paid 的首购单(<code>out_trade_no</code>)上,既有的 <code>orderPaid</code> 投递门禁天然放行,无需额外旁路。</p>
<h3>5.3 webhook payload 契约(P8 新增事件)</h3>
<table>
<thead><tr><th>event_type</th><th>payload 关键字段</th></tr></thead>
<tbody>
<tr><td><code>subscription.created</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>created_at</code></td></tr>
<tr><td><code>subscription.renewed</code></td><td><code>out_trade_no</code>(<b>续费单号</b>,与首购单号不同)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>paid_at</code></td></tr>
<tr><td><code>subscription.past_due</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>failed_at</code></td></tr>
<tr><td><code>subscription.canceled</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>canceled_at</code></td></tr>
<tr><td><code>chargeback.received</code></td><td><code>out_trade_no</code>/<code>dispute_ref</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>reason</code>/<code>received_at</code></td></tr>
</tbody>
</table>
<p class="small">所有事件的 <code>event_type</code> 字段本身也回显在 payload 顶层(与 <code>X-Pay-Event</code> 头一致),便于业务方单点分发。<code>subscription.renewed</code> 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + <code>event_type</code>(同订阅只会 created 一次、canceled 一次;<code>past_due</code> 当前实现下同订阅多次失败只保证首次必达,见 <code>internal/gateway/subscription.go::markSubscriptionPastDue</code> 注释)。</p>
<h2>6. 退款设计</h2>
<p><b>退款政策归业务,退款机制归 pay</b>(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。</p>
<table>
<thead><tr><th>渠道</th><th>退款怎么走</th></tr></thead>
<tbody>
<tr><td>支付宝/微信/Stripe/PayPal</td><td><b>业务发起</b><code>POST /refunds</code><code>provider.refund()</code> 调渠道退款 API → webhook <code>refund.succeeded</code></td></tr>
<tr><td><b>crypto(自托管)</b></td><td><b>无退款 API</b> → pay 记"待人工"退款单,运营用 <code>sweep</code> 反向链上转账、回填状态</td></tr>
<tr><td><b>IAP / 信用卡拒付</b></td><td><b>平台发起</b> → pay 收平台通知 → 转发 <code>refund.succeeded</code>/<code>chargeback</code> 给业务</td></tr>
</tbody>
</table>
<ul>
<li><b>开通冲正</b>:业务收 <code>refund.succeeded</code> → 自己冲正 entitlement(缩订阅/降级/标记),因为 entitlement 归业务。</li>
<li>Order 退款态:<code>paid → refunding →(全额)refunded /(部分)partially_refunded</code>;一单可多次部分退,累计 ≤ 已付。</li>
</ul>
<div class="warnbox"><b>crypto 无 chargeback、无强制退款</b> —— 钱到账不可逆,不会被发卡行强行拉回。这是自托管抗风控的又一优势。</div>
<h2>7. 促销 / 折扣归属</h2>
<p><b>促销"逻辑"(谁有资格/限几次/什么活动/券码/A-B)全在业务侧;pay 只按被授权的最终金额收款 + 记账留痕。</b></p>
<table>
<thead><tr><th>场景</th><th>机制</th><th>pay 怎么做</th></tr></thead>
<tbody>
<tr><td>结构性稳定价(首月¥1、年付折扣)</td><td>做成 pay 定价档 product</td><td>业务按资格选 product_id,pay 收该档权威价</td></tr>
<tr><td>动态活动(限时8折/满减/优惠券)</td><td>业务算折后价,带签名折扣明细(base/discount/campaign_id/reason)</td><td>pay 记账(留痕对账),按最终价收;不判断折扣合理性</td></tr>
</tbody>
</table>
<ul>
<li><b>防薅</b>:规则(每人一次/新客)业务侧;pay 提供通用兜底(<code>out_trade_no</code> 幂等 + 可选去重键 + settle 事务二次查)。</li>
<li><b>优惠券</b>与激活码<b>码机制同源</b>(生成/发放/核销校验+防双花),但<b>作用不同</b>:激活码=独立核销直接开权益;优惠券=绑在一次下单、核销后产出折扣,<b>折扣由业务算、pay 只收折后价记账</b>。所以优惠券 = codes 内核(码机制)+ 业务侧折扣逻辑,未来做成 codes 变体,本轮留扩展位。</li>
</ul>
<h2>8. 多币种 + 配置驱动渠道 + 国内外双部署</h2>
<ul>
<li><b>金额</b>=最小单位整数 + 币种码(crypto=decimal string + asset + chain),<b>禁 float</b>。分三种货币存快照:<code>presentment</code>(展示价)/ <code>charge</code>(扣款币种)/ <code>settlement</code>(落袋币种),便于对账与按原价退款。</li>
<li><b>配置驱动渠道开关</b>:渠道 + 账户 + 权重 + 限额 + 区域 + 启停 全在<b>配置</b>(结构在文件、密钥从 env/Bitwarden),照现有 <code>config.biz</code> 范式扩展 <code>config.channels</code>/<code>config.accounts</code>。pay 启动加载注册表,方法发现端点只下发<b>已启用</b>渠道。<b>加/停/切换渠道 = 改配置重启(可选热重载),不改代码。</b></li>
<li><b>渠道路由</b>:按 (区域 × 平台 × 类目) 过滤可用渠道下发 client;<b>crypto = 始终可用兜底</b>(VPN 类目国内持牌基本过不了审;海外卡随时可能被 Stripe/PayPal 风控封 → 渠道注册表支持运行时禁用/降级)。</li>
<li><b>国内外双部署</b>:<b>同一套代码、两实例</b>(cn=支付宝/微信/哪吒 CNY;global=Stripe/crypto USD/USDT),各注册本区渠道 + 结算账户。海外以 USD 记账、crypto 只收 USDT(TRC20 优先);<b>跨区不做实时资金归并</b>(合规+外汇),财务层对账合并。<b>对账优先于实时</b>:每 provider 实现 query,跑定时对账 job 防掉单/防重复授权。</li>
</ul>
<h2>9. codes 兑换内核(定架构,不深实现)</h2>
<p>与 pay <b>平级</b>的共享兑换内核(以 pangolin codes 为基:哈希存储 + 发卡店 webhook 灌码 + Redis 限流 + 三层防双花)。抽公共:码模型 / 状态机(unused/redeemed/void)/ 生成器 / 兑换事务骨架(锁 + CAS + 幂等)/ 批次 / 审计 + <code>max(现到期,now)+时长</code> 叠加算法。<b>不并入 pay</b>(激活码携带业务语义、由发卡店注入、与支付渠道正交)。归属维度(user/shop)与"最终开通落库"用回调接口留给各产品。</p>
<h3>9.1 部署选型:A 共享库嵌入各产品(已定)</h3>
<p><b>决定性技术点</b>:兑码 = "标记码已用 + 开该产品权益" 要<b>原子</b>,而开权益在各产品自己的库。只要码数据不在产品库(独立部署 or 跟 pay 一起),核销就是<b>跨库分布式事务</b>(要 saga);码嵌入各产品才是<b>本地事务</b>(pangolin 现行做法)。</p>
<table>
<thead><tr><th>方案</th><th>redeem 原子性</th><th>运维</th><th>集中管码/reseller</th><th>与 pay</th><th>结论</th></tr></thead>
<tbody>
<tr><td><b>A 共享库嵌入各产品</b></td><td><span class="tag ok">本地事务</span></td><td>无新服务</td><td>各产品各一套</td><td>不碰 pay</td><td><span class="tag ok">现选</span></td></tr>
<tr><td>B 独立服务(与 pay 平级)</td><td>跨库 saga</td><td>多一服务</td><td>✅ 集中</td><td>平级中性</td><td>发卡规模化后再抽</td></tr>
<tr><td>C 跟 pay 同部署</td><td>跨库 saga</td><td>多一部署</td><td>集中</td><td><span class="tag bad">污染 pay 中性/故障域</span></td><td><span class="tag bad">不采用</span></td></tr>
</tbody>
</table>
<p><b>已定 A</b>:codes 内核做成 <b>Go 共享库</b>(带契约,像 pay-contract),各产品 import,码表落各产品库、redeem 本地事务。发卡/reseller 规模化到需要"集中管所有码 + reseller 门户 + 跨产品对账"时,再抽 B 独立服务(接受 redeem saga)。<b>不与 pay 同部署</b>——"平级"指逻辑与部署都独立于 pay。</p>
<h3>9.2 履约动作多态:购买激活码 = pay 收款 + codes 发码</h3>
<p>pay 中性、履约归业务 → 业务的履约 handler 收到 <code>payment.succeeded</code> 后,动作是<b>多态</b>的:</p>
<table>
<thead><tr><th>履约动作</th><th>场景</th></tr></thead>
<tbody>
<tr><td>直接开权益</td><td>普通订阅购买(自己用)</td></tr>
<tr><td><b>mint + 发码</b></td><td><b>购买激活码</b>:调 codes 内核 mint 一个码(状态 unused、记来源订单 out_trade_no)→ 交付买家(邮件/页面/reseller 回调)。以后买家 redeem → 开权益(价值买时已预付)</td></tr>
<tr><td>加余额(ledger)</td><td>充值(dudu 秒数 / 流量包)</td></tr>
</tbody>
</table>
<p class="small">pay 不知道这是"码商品",只卖 product、发事件;"这笔钱换成什么"由业务履约 handler 决定。天然支持送人 / 换账号设备兑 / iOS 合规(站外买码站内兑 3.1.3b)/ reseller 分销——即自建版"独角数卡发卡"。退款:码未兑 → void;已兑 → 走权益冲正。</p>
<h2>10. 契约版本策略(pay-contract)</h2>
<ul>
<li>多币种 / <code>render_type</code> / <code>channel</code> / 折扣明细:<b>向后兼容加字段</b>(照 <code>client_type</code> v1.1.0「可选加字段 + 缺省兜底 + 非法报错」范式)。</li>
<li>webhook <code>event_type</code>(退款 / 订阅 / 拒付多事件):<b>断代 v2.0.0</b>,接入方显式声明支持事件集。</li>
<li><code>GET /products</code> 进 openapi.yaml(现仅散落 README/看板);README 版本号收口到 v2。</li>
</ul>
<h2>11. 设计决策 · 知识点(我们敲定的要点)</h2>
<div class="card">
<ol>
<li><b>统一目标是 pay 网关,不是各产品共享代码</b> —— pay 已是通用多租户网关,pangolin/jiu 做瘦接入方,不各自造编排层。</li>
<li><b>三个正交关注点</b>:pay 收款 / codes 兑换(平级)/ entitlement 开通(各产品自留)。只共享"入账成功"事件契约 + 时长叠加算法。</li>
<li><b>一单 N 渠道、只一个成功</b>:Order + Attempt 两层,标付原子守卫 order=pending。<b>这些功能归 pay 实现</b>,不在接入方。</li>
<li><b>attempt 超时 ≠ order 关闭</b>:超时挂 attempt,order 仍 pending 可换渠道;order 另有整体购买窗口。可 <code>cancel</code>,取消单在列表可见。</li>
<li><b>render_type 6 个</b>;pay 下发数据不下发 UI,样式布局归业务方(除托管/系统 UI 归平台)。加同形态新渠道零改 client(redirect/qr/crypto_address 完全开放;native/sdk/iap 半开放)。</li>
<li><b>verify_callback 吸收全部渠道差异</b>;query 轮询兜底是刚需(crypto/国内扫码无可靠 push;webhook 可能被 GFW/CF 吞)。</li>
<li><b>金额权威在 pay</b>,不信裸传金额;折后价靠"定价档 product"或"签名折扣明细"表达。<b>促销逻辑归业务,收款+记账归 pay。</b></li>
<li><b>退款政策归业务,退款机制归 pay</b>;退款有业务发起(卡/宝/微)、人工(crypto)、平台发起(IAP/拒付)三向,统一收敛成 refund 事件。开通冲正由业务做。</li>
<li><b>多账户/单渠道 + 路由策略</b>(轮询/加权/限额/地址池);attempt 绑账户,回调/退款/结算走同一账户。用于分摊限额/抗冻/隐私/多主体。</li>
<li><b>渠道开关配置驱动</b>,加渠道不改代码;<b>国内外同码双部署</b>,crypto(USDT-TRC20 自托管)是抗风控始终兜底通道。</li>
<li><b>recurring 设计进模型、暂不实现</b>(4 类 kind),避免第三次 breaking;crypto 无 recurring 用到期提醒再买。</li>
<li><b>IAP 不对称</b>:jiu 上 App Store 被迫 iOS 隐藏购买 UI(3.1.1);pangolin 站外分发不受限。统一层把"平台合规开关"当一等公民。</li>
<li><b>激活码不并入 pay</b>(业务语义 + 发卡店注入,与支付正交),做<b>共享 Go 库(A)嵌入各产品</b>(redeem 本地事务),规模化再抽独立服务、不与 pay 同部署。<b>购买激活码</b>=pay 收款 + codes mint 发码,验证"pay 中性、履约动作多态(开权益/发码/加余额)"。优惠券=码机制同源但折扣归业务。</li>
</ol>
</div>
<h2>12. 可扩展性验证:流量包 / 额度型权益</h2>
<p>压力测试:未来若不仅卖<b>时长</b>、还卖<b>流量包</b>(数据额度),pay 要改吗?<b>—— pay 核心零改。</b></p>
<p>pay 是<b>权益无关(entitlement-agnostic)</b>的:它只卖"有价商品(product = biz_code + 金额)"、发 <code>payment.succeeded(product_biz_code)</code> 事件,<b>不知道也不关心</b>商品给的是"30 天时长"还是"100GB 流量"——那是业务侧的事。加流量包 = ①pay 加一个 product(<code>data_100gb</code> + 价);②<b>业务侧</b>把该 biz_code 映射成"+100GB"。pay 的 Order/Attempt/Account/Refund/render_type/webhook <b>全部不动</b></p>
<p><b>这恰好验证了本设计「entitlement 各产品自留、pay/codes 只透传 biz_code」的决策</b>——正因权益形态会变(时长 / 流量 / 档位 / 多维),pay 保持权益无关,新权益类型永远碰不到 pay。若当初把 entitlement 塞进 pay,今天流量包就得改 pay。</p>
<p>真正要扩的是<b>履约/开通侧</b>(业务 + 可选共享 util),entitlement 形态从一种扩到两种:</p>
<table>
<thead><tr><th>形态</th><th>模型</th><th>累加方式</th><th>谁在用</th></tr></thead>
<tbody>
<tr><td><b>时长型</b></td><td>subscription + <code>expires_at</code></td><td><code>max(现到期,now)+天数</code></td><td>pangolin 订阅 / jiu license</td></tr>
<tr><td><b>额度型</b></td><td>ledger + 余额</td><td><code>余额 += 数量</code>(GB/秒/次)</td><td><b>dudu 已有(充值秒数)</b>、未来流量包</td></tr>
</tbody>
</table>
<ul>
<li>共享"叠加算法" util 提供<b>两种</b>(dudu 的秒数 ledger 是现成参考)。</li>
<li><b>退款冲正</b>:时长型=缩到期;额度型=扣回未用余额。都业务侧。</li>
<li><b>可组合</b>:一个商品可同时给时长 + 流量(pangolin 的 plan 本就有 <code>daily_minutes</code> + <code>daily_mb</code>)。</li>
<li><b>codes 内核顺带泛化</b>:激活码"面额"应是<b>透传的通用权益描述符</b>(不硬编码 plan+天数),这样流量包激活码也能承载。</li>
</ul>
<h2>13. 待确认 / 后续</h2>
<ul>
<li>本设计聚焦 <b>pay 深设计 + codes 定架构</b>;entitlement 各产品自留(不在本设计)。</li>
<li>实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付 —— <b>P8 已落地</b>:<code>gateway_scheduled</code>(Stripe)订阅状态机 + <code>chargeback</code> 拒付记录 + 业务方事件集声明/过滤;<code>token_offsession</code>/<code>store_managed</code> 仍留能力位、未实现。</li>
<li>补测试(现 pay 核心资金链路几乎无单测,托多产品钱必须补)。</li>
<li>pangolin / jiu 收口改造:<b>pay 定稿后</b>再动(见 brain todo)。</li>
</ul>
<p class="small" style="margin-top:28px">相关:<a href="pay接入jiu授权续费对接方案.html">pay 接入 jiu 对接方案</a> · <a href="上线状态与部署Runbook.html">部署 Runbook</a></p>
</div>
</body>
</html>