Files
pangolin/docs/pay-orchestration-design.html
wangjia 8a72665428 docs(pay): 设计文档 pre 伪表格改真 HTML 表格 + 代码语法高亮
schema/开通管线字段改 table.fields;Provider 接口/REST 端点改 pre.code 高亮
(GitHub-dark 配色,自包含无 CDN)。规矩记入全局记忆 docs-tables-and-code-blocks。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:30:55 +08:00

397 lines
31 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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>Pangolin 统一支付编排层设计</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;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:#cdd3df}
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:#cdd3df;white-space:nowrap;width:1%}
table.fields td code{background:transparent;padding:0}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
.card h3{margin-top:0;color:var(--accent2)}
table{width:100%;border-collapse:collapse;margin:16px 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(--fg2);font-weight:600;font-size:13px}
ul,ol{padding-left:22px;margin:8px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
.warnbox{background:rgba(224,106,106,.08);border:1px solid rgba(224,106,106,.35);border-radius:12px;padding:14px 18px;margin:14px 0}
.warnbox b{color:var(--bad)}
.clean{color:var(--ok);font-weight:600}
.grey{color:var(--warn);font-weight:600}
.diagram{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:16px;overflow-x:auto;font-family:var(--mono);font-size:12px;line-height:1.5;color:#cdd3df;white-space:pre}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>Pangolin 统一支付编排层设计</h1>
<p class="sub">支付方式无关(provider-agnostic)架构 · client↔控制面协议固定 · 加渠道不改客户端 · 2026-07-09</p>
<div class="lead">
<b>一句话</b>:控制面新增一个<b>支付编排层</b>,把不同支付平台(哪吒聚合支付、加密货币、未来 Stripe/信用卡/epay)统一到「<code>Order</code> 业务订单 + <code>PaymentAttempt</code> 支付尝试 + <code>Provider</code> 适配器」模型;客户端只认服务端下发的少数几种<b>渲染形态(render_type)</b>,不认具体平台。<b>同形态的新网关 = 零改客户端;只有全新交互形态才动客户端。</b>
</div>
<p class="small">📋 <b>实现计划</b>:<a href="pay-orchestration-p1-plan.html">P1 数据层 + 开通重构</a>(7 阶段之一;P2–P7 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。</p>
<h2>1. 背景与目标</h2>
<p>Pangolin 当前<b>没有 App 内支付</b>:用户变 pro 的唯一路径是兑换激活码(<code>POST /v1/redeem</code>),客户端"购买"卡片是 <code>onTap: () {}</code> 空实现。控制面<b>没有订单系统</b>(migration 000001000021 无 orders/payment 表),只有 codes 激活码履约。这是一块绿地。</p>
<p><b>目标</b>:</p>
<ul>
<li>client(Flutter 四端)与 pangolin 控制面之间的支付协议<b>固定</b>,底层支付平台<b>可插拔</b></li>
<li>首批接入 <b>加密货币(USDT-TRC20)</b><b>哪吒聚合支付(支付宝)</b> 两条渠道。</li>
<li>未来接 Stripe / 信用卡 / epay 等<b>尽量不改客户端</b></li>
<li>支付成功 → 自动给用户开通订阅(复用现有订阅/权益模型)。</li>
</ul>
<h2>2. 可行性结论 <span class="tag ok">可行</span></h2>
<p>调研(Stripe <code>next_action</code> 模型 + 多 PSP 编排实践)确认:不同支付平台的<b>交互形态是有限的</b>,而平台是无限的。把形态收敛成客户端认识的 <code>render_type</code> 联合体,即可让客户端 provider 无关。</p>
<p>首批两渠道的形态<b>都落在客户端已有能力上</b>:</p>
<table>
<thead><tr><th>渠道</th><th>render_type</th><th>客户端如何渲染</th><th>客户端现状</th></tr></thead>
<tbody>
<tr><td>哪吒(支付宝)</td><td><code>redirect</code></td><td>拿 payurl → 外部浏览器打开</td><td><span class="tag ok">已有</span> <code>url_launcher</code> + SSO 跳转范式</td></tr>
<tr><td>加密货币(USDT)</td><td><code>display_details</code></td><td>展示地址+精确金额卡片</td><td><span class="tag ok">已有</span> 就是把 <code>/_test</code> 卡搬进 App</td></tr>
</tbody>
</table>
<p><b>边界(诚实说)</b>:客户端改动频率 = <b>新 render_type 出现频率</b>(极低)+ OS scheme 白名单变化,<b>不是</b> provider 频率。真正要碰客户端的只有:全新交互形态、拉起 App 的 scheme 白名单(<code>Info.plist</code>/manifest)、信用卡 3DS / Apple·Google Pay 的专有 SDK。</p>
<h2>3. 架构总览</h2>
<div style="overflow-x:auto;background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:8px 4px;margin:14px 0">
<svg viewBox="0 0 960 664" style="width:100%;min-width:780px;height:auto;display:block" font-family="'SF Mono',ui-monospace,'PingFang SC',sans-serif" role="img" aria-label="统一支付编排层时序图">
<defs>
<marker id="mreq" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5fb0c9"/></marker>
<marker id="mret" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#9aa3b2"/></marker>
<marker id="masy" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#e0884f"/></marker>
<marker id="mok" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5ec27a"/></marker>
</defs>
<style>
.ll{stroke:#2f3542;stroke-width:1.2;stroke-dasharray:3 4}
.hb{fill:#171a22;stroke:#313846;stroke-width:1}
.ht{fill:#e6e8ee;font-size:12.5px;font-weight:600;text-anchor:middle}
.hs{fill:#8b93a3;font-size:9.5px;text-anchor:middle}
.req{stroke:#5fb0c9;stroke-width:1.5;fill:none}
.ret{stroke:#9aa3b2;stroke-width:1.4;stroke-dasharray:5 4;fill:none}
.asy{stroke:#e0884f;stroke-width:1.6;fill:none}
.okl{stroke:#5ec27a;stroke-width:1.6;stroke-dasharray:5 4;fill:none}
.lb{fill:#cbd2de;font-size:10.5px;text-anchor:middle}
.lo{fill:#e0a86a;font-size:10.5px;text-anchor:middle}
.lg{fill:#7fd39a;font-size:10.5px;text-anchor:middle}
.nb{fill:#1d2129;stroke:#3a4150;stroke-width:1}
.nt{fill:#b3bccb;font-size:10px}
</style>
<!-- lifelines -->
<line class="ll" x1="120" y1="58" x2="120" y2="644"/>
<line class="ll" x1="380" y1="58" x2="380" y2="644"/>
<line class="ll" x1="620" y1="58" x2="620" y2="644"/>
<line class="ll" x1="860" y1="58" x2="860" y2="644"/>
<!-- headers -->
<rect class="hb" x="45" y="14" width="150" height="44" rx="9"/>
<text class="ht" x="120" y="34">客户端</text><text class="hs" x="120" y="49">Flutter 四端</text>
<rect class="hb" x="305" y="14" width="150" height="44" rx="9"/>
<text class="ht" x="380" y="34">控制面</text><text class="hs" x="380" y="49">编排层 :8080</text>
<rect class="hb" x="545" y="14" width="150" height="44" rx="9"/>
<text class="ht" x="620" y="34">Provider</text><text class="hs" x="620" y="49">适配器</text>
<rect class="hb" x="785" y="14" width="150" height="44" rx="9"/>
<text class="ht" x="860" y="34">外部平台</text><text class="hs" x="860" y="49">pangolin-pay · 哪吒</text>
<!-- r1 -->
<text class="lb" x="250" y="86">GET /v1/pay/methods</text>
<line class="req" x1="120" y1="92" x2="374" y2="92" marker-end="url(#mreq)"/>
<!-- r2 -->
<text class="lb" x="250" y="120">启用渠道 [{id,name,render_type}]</text>
<line class="ret" x1="380" y1="126" x2="126" y2="126" marker-end="url(#mret)"/>
<!-- r3 -->
<text class="lb" x="250" y="154">POST /v1/pay/orders {sku, method}</text>
<line class="req" x1="120" y1="160" x2="374" y2="160" marker-end="url(#mreq)"/>
<!-- note: 落库 -->
<rect class="nb" x="238" y="171" width="284" height="22" rx="6"/>
<text class="nt" x="248" y="186">落库 Order(谁/买啥/几天)+ Attemptprovider/单号)</text>
<!-- r4 -->
<text class="lb" x="500" y="208">adapter.Create(order)</text>
<line class="req" x1="380" y1="214" x2="614" y2="214" marker-end="url(#mreq)"/>
<!-- r5 -->
<text class="lb" x="740" y="242">下单(/order | /pay/create</text>
<line class="req" x1="620" y1="248" x2="854" y2="248" marker-end="url(#mreq)"/>
<!-- r6 -->
<text class="lb" x="740" y="276">地址+金额 | payurl</text>
<line class="ret" x1="860" y1="282" x2="626" y2="282" marker-end="url(#mret)"/>
<!-- r7 -->
<text class="lb" x="500" y="310">Session{render_type, payload}</text>
<line class="ret" x1="620" y1="316" x2="386" y2="316" marker-end="url(#mret)"/>
<!-- r8 -->
<text class="lb" x="250" y="344">{order_no, session}</text>
<line class="ret" x1="380" y1="350" x2="126" y2="350" marker-end="url(#mret)"/>
<!-- note: render dispatch -->
<rect class="nb" x="56" y="361" width="452" height="22" rx="6"/>
<text class="nt" x="66" y="376">客户端按 render_type 渲染:redirect→浏览器 / display_details→地址卡</text>
<!-- r9 user pays -->
<text class="lo" x="490" y="398">用户付款(链上转账 | 支付宝跳转)</text>
<line class="asy" x1="120" y1="404" x2="854" y2="404" marker-end="url(#masy)"/>
<!-- r10 poll -->
<text class="lb" x="250" y="432">轮询 GET /v1/pay/orders/{no}</text>
<line class="req" x1="120" y1="438" x2="374" y2="438" marker-end="url(#mreq)"/>
<!-- r11 -->
<text class="lb" x="250" y="464">pending…</text>
<line class="ret" x1="380" y1="470" x2="126" y2="470" marker-end="url(#mret)"/>
<!-- r12 webhook -->
<text class="lo" x="620" y="500">webhook/webhooks/pay/crypto(HMAC) | /nazha(RSA·GET)</text>
<line class="asy" x1="860" y1="506" x2="386" y2="506" marker-end="url(#masy)"/>
<!-- note: grant pipeline -->
<rect class="nb" x="242" y="517" width="536" height="22" rx="6"/>
<text class="nt" x="252" y="532">验签 → 定位订单 → 幂等 → 金额核对 → 开通订阅(source=pay</text>
<!-- r13 -->
<text class="lb" x="250" y="554">GET /v1/pay/orders/{no}</text>
<line class="req" x1="120" y1="560" x2="374" y2="560" marker-end="url(#mreq)"/>
<!-- r14 paid -->
<text class="lg" x="250" y="588">paid ✓ 已开通</text>
<line class="okl" x1="380" y1="594" x2="126" y2="594" marker-end="url(#mok)"/>
<!-- bottom note: query fallback -->
<rect class="nb" x="56" y="607" width="848" height="24" rx="6"/>
<text class="nt" x="66" y="623">兜底:控制面 →(Query 主动查单) 外部平台 —— crypto 靠链上轮询;哪吒回调可能被 GFW/CF 吞</text>
</svg>
</div>
<p><b>为什么 Order 与 PaymentAttempt 拆两层</b>:同一笔订单,用户可能先扫哪吒超时、再换 crypto 付成功。换渠道 = 新建一个 Attempt,Order 不变。只有一个对象会让"换渠道重试"污染状态机。</p>
<h2>4. 数据模型</h2>
<p>控制面新增两张表(<code>mysql</code> + <code>sqlite</code> 双套 migration,金额一律 <code>int64</code> 最小单位,禁 float)。</p>
<h3>4.1 <code>pay_orders</code> — 业务订单(购买/权益账本)</h3>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>order_no</td><td>幂等主键(UNIQUE,形如 PAY2026…)</td></tr>
<tr><td>user_id</td><td>FK users.id(内部关联订阅)</td></tr>
<tr><td>user_uuid</td><td>对外/跨系统标识(= pay 侧 user_ref)</td></tr>
<tr><td>sku</td><td>pro-month / pro-quarter / pro-year</td></tr>
<tr><td>plan_code</td><td>pro(建单时锁定,回调改不了)</td></tr>
<tr><td>duration_days</td><td>30 / 90 / 365(建单时锁定)</td></tr>
<tr><td>status</td><td>created | pending | paid | expired | canceled</td></tr>
<tr><td>subscription_id</td><td>开通后回填(审计,可空)</td></tr>
<tr><td>created_at / paid_at / expires_at</td><td>时间戳</td></tr>
</tbody>
</table>
<p class="small">建单时就把 <code>order_no → user / plan / 天数</code> 落库。回调<b>只能推进已存在的订单、开通其中预先锁定的套餐</b>——回调无法指定"给谁开多久"。这是防回调伪造的纵深。</p>
<h3>4.2 <code>pay_attempts</code> — 支付尝试(收款账本)</h3>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>id</td><td>主键</td></tr>
<tr><td>order_no</td><td>FK pay_orders</td></tr>
<tr><td>method</td><td>usdt_trc20 / alipay_nazha</td></tr>
<tr><td>provider</td><td>crypto / nazha</td></tr>
<tr><td>provider_ref</td><td>pangolin-pay 单号 / 哪吒 trade_no</td></tr>
<tr><td>render_type</td><td>redirect / display_details</td></tr>
<tr><td>amount_minor</td><td>该尝试应收(按币种最小单位)</td></tr>
<tr><td>currency</td><td>USDT / CNY</td></tr>
<tr><td>status</td><td>created | pending | paid | failed | expired</td></tr>
<tr><td>expires_at</td><td>本次尝试超时(哪吒 payurl / crypto 15min)</td></tr>
<tr><td>created_at / paid_at</td><td>时间戳</td></tr>
<tr><td>UNIQUE(provider, provider_ref)</td><td>回调幂等的命门</td></tr>
</tbody>
</table>
<p class="small">⚠️ 超时挂在 <b>attempt</b> 上:某 attempt 超时只把<b>它自己</b>置 expired,<code>order</code><code>pending</code> → 用户可再建 attempt 换渠道。<code>order.expires_at</code><b>整体购买窗口</b>(较长,如 1h),到点才整单 expired。<b>一笔 order 最多一个 attempt 能成功</b> —— <code>MarkOrderPaid</code> 只在 order=<code>pending</code> 时原子成立,paid 后续 attempt 全部失效。</p>
<h3>4.3 两表分工与创建时机(一对多)</h3>
<table>
<thead><tr><th></th><th><code>pay_orders</code> 业务订单</th><th><code>pay_attempts</code> 支付尝试</th></tr></thead>
<tbody>
<tr><td>回答</td><td><b>谁买了什么、开通谁</b>(购买/权益)</td><td><b>用哪个平台怎么付、平台单号</b>(收款)</td></tr>
<tr><td>何时创建</td><td>用户选套餐点「购买」确认那刻 —— <code>POST /v1/pay/orders</code>,<b>一次购买一行</b></td><td>每次选定支付方式建 provider 会话 —— 同一 <code>POST /v1/pay/orders</code>(首选渠道),或 <code>/retry</code>(换渠道)</td></tr>
<tr><td>数量</td><td>1 笔购买 = 1 行(<code>order_no</code> 唯一)</td><td>1 笔购买 = <b>N 行</b>(每次换渠道重试各一行)</td></tr>
<tr><td>锁定</td><td>建单即锁 user/plan/天数/金额 → 回调改不了(防伪造)</td><td>绑 provider + provider_ref → <code>UNIQUE</code> 幂等</td></tr>
<tr><td>生命周期</td><td>created→pending→paid / expired(整体窗口到点) / <b>canceled(用户取消)</b>(长)</td><td>created→pending→paid / failed / expired(单次超时)(短,可弃)</td></tr>
<tr><td>超时</td><td>整体购买窗口(如 1h)到点 → order expired</td><td><b>各自超时</b>(哪吒 payurl / crypto 15min);超时只弃本 attempt,<b>order 不变</b></td></tr>
</tbody>
</table>
<p class="small">典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → <b>attempt#1 expired,order 仍 pending</b>;点「换 crypto」→ order <b>不变</b>,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。<b>一笔 order 最多一个 attempt 成功</b>(<code>MarkOrderPaid</code> 只在 order=pending 原子成立)。用户也可 <code>POST /orders/{no}/cancel</code> 主动取消(pending→canceled),取消单仍在订单列表可见。<b>拆两层:换渠道重试不污染订单状态机 + 订单是开通谁的唯一真相。</b></p>
<h3>4.4 SKU 目录 + 定价 <span class="tag ok">已定</span></h3>
<p>SKU → (plan, 天数, 各币种价格)。哪吒收人民币(支付宝)、crypto 收 USDT,故<b>按币种各定一价</b>(不做实时汇率换算,固定价是产品决策)。首版 SKU 目录作为控制面代码常量(后续可迁 DB)。</p>
<table>
<thead><tr><th>SKU</th><th>plan</th><th>天数</th><th>USDT(crypto)</th><th>CNY(哪吒/支付宝)</th></tr></thead>
<tbody>
<tr><td><code>pro-month</code></td><td>pro</td><td>30</td><td>$3.99</td><td>¥29.99</td></tr>
<tr><td><code>pro-quarter</code></td><td>pro</td><td>90</td><td>$9.99</td><td>¥68.88</td></tr>
<tr><td><code>pro-year</code></td><td>pro</td><td>365</td><td>$29.99</td><td>¥199.99</td></tr>
</tbody>
</table>
<p class="small">USDT 价与 CNY 价均已拍板。金额一律 <code>int64</code> 最小单位存(USDT 微单位 1e-6、CNY 分),哪吒 <code>money</code> 参数按元、2 位小数下发。</p>
<h2>5. Provider 适配器接口</h2>
<p>每个支付平台实现同一个接口,控制面只依赖接口。三个动作是最小完备集。</p>
<pre class="code"><span class="k">type</span> <span class="ty">Provider</span> <span class="k">interface</span> {
<span class="fn">Method</span>() MethodInfo <span class="co">// 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}</span>
<span class="fn">Create</span>(ctx, o Order) (Session, <span class="ty">error</span>) <span class="co">// ① 下单→返回渲染指令</span>
<span class="fn">HandleCallback</span>(ctx, r *http.Request) (CallbackResult, <span class="ty">error</span>) <span class="co">// ② 验签+解析+归一化</span>
<span class="fn">Query</span>(ctx, providerRef <span class="ty">string</span>) (PaymentStatus, *PaidEvent, <span class="ty">error</span>) <span class="co">// ③ 主动查单兜底</span>
}
<span class="k">type</span> <span class="ty">Session</span> <span class="k">struct</span> { <span class="co">// Create 的返回,客户端按 RenderType 分发</span>
ProviderRef <span class="ty">string</span>
RenderType <span class="ty">string</span> <span class="co">// redirect / display_details / ...</span>
Payload json.RawMessage <span class="co">// 按 RenderType 定 shape</span>
ExpiresAt time.Time
}
<span class="k">type</span> <span class="ty">PaidEvent</span> <span class="k">struct</span> { OrderNo <span class="ty">string</span>; ProviderRef <span class="ty">string</span>; AmountMinor <span class="ty">int64</span>; Currency <span class="ty">string</span>; PaidAt time.Time; TxRef <span class="ty">string</span> }
<span class="k">type</span> <span class="ty">CallbackResult</span> <span class="k">struct</span> { Handled <span class="ty">bool</span>; Event *PaidEvent; AckBody []<span class="ty">byte</span> } <span class="co">// AckBody: 哪吒要回 "success"</span></pre>
<h3>5.1 crypto adapter(包裹已部署的 pangolin-pay)</h3>
<p>pay-server 保持<b>独立进程</b>(持 xpub、看链,最小权限),控制面 crypto adapter 只是它的 HTTP 客户端:</p>
<ul>
<li><b>Create</b><code>POST pangolin-pay /order {user_ref=uuid, sku, amount=USDT微单位}</code> → 得地址+精确金额 → <code>render_type=display_details</code>,<code>provider_ref=pay 单号</code></li>
<li><b>HandleCallback</b> → 需给 pay-server <b>新增出站 webhook</b>:侦测到 paid → <code>POST 控制面 /v1/webhooks/pay/crypto</code>(HMAC 签名、失败重试)。</li>
<li><b>Query</b><code>GET pangolin-pay /order/{provider_ref}</code>(链上侦测无 webhook 时兜底)。</li>
</ul>
<h3>5.2 nazha adapter(哪吒聚合支付)</h3>
<ul>
<li><b>Create</b><code>POST nzzf.org/api/pay/create</code>,参数 <code>pid / type=alipay / out_trade_no=尝试号 / notify_url / return_url / name / money=元 / timestamp / sign / sign_type=RSA</code>,签名 <b>SHA256WithRSA</b>(商户私钥、参数 ASCII 升序拼 <code>k=v&amp;</code>、Base64)→ 得 <code>payurl</code><code>render_type=redirect</code>,<code>provider_ref=trade_no</code></li>
<li><b>HandleCallback</b> → 哪吒以 <b>GET</b> 回调 <code>notify_url</code>,平台公钥 RSA 验签 → <code>trade_status==TRADE_SUCCESS</code> → PaidEvent;响应纯文本 <code>success</code></li>
<li><b>Query</b><code>POST /api/pay/query</code>,<code>status==1</code> 为已支付(回调可能被吞,轮询兜底)。</li>
<li><b>配置</b>:<code>pid</code> + 商户 RSA 私钥 + 平台 RSA 公钥,走 Bitwarden/env。<b>前置</b>:你需在哪吒(Telegram 开户)拿到 pid 与密钥。</li>
</ul>
<h2>6. 客户端契约</h2>
<h3>6.1 REST 端点(控制面,全部 Bearer)</h3>
<pre class="code"><span class="k">GET</span> /v1/pay/methods → <span class="nu">200</span> [{id,name,icon,render_type,currency,enabled,min,max}]
<span class="k">POST</span> /v1/pay/orders {sku, method} → <span class="nu">201</span> {order_no, status, session:{render_type,payload,expires_at}}
<span class="k">GET</span> /v1/pay/orders/{order_no} → <span class="nu">200</span> {status, plan, expires_at, session?}
<span class="k">GET</span> /v1/pay/orders?limit&amp;cursor → <span class="nu">200</span> {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} <span class="co">(历史订单含 canceled,用户中心订单页用)</span>
<span class="k">POST</span> /v1/pay/orders/{order_no}/retry {method} → <span class="nu">201</span> 新 attempt/session(Order 不变)
<span class="k">POST</span> /v1/pay/orders/{order_no}/cancel → <span class="nu">200</span> {status:"canceled"}<span class="co">(仅 pending 可取消;paid/已取消返 409</span>
<span class="co">--- 平台→控制面(公开,不带 Bearer)---</span>
<span class="k">POST</span> /v1/webhooks/pay/crypto <span class="co">(pangolin-pay, HMAC 验签)</span>
<span class="k">GET</span> /v1/webhooks/pay/nazha <span class="co">(哪吒, RSA 验签, GET)</span></pre>
<h3>6.2 render_type 联合体(写死在协议里)</h3>
<table>
<thead><tr><th>render_type</th><th>payload</th><th>首版</th></tr></thead>
<tbody>
<tr><td><code>redirect</code></td><td><code>{url, return_hint}</code></td><td><span class="tag ok">v1</span> 哪吒</td></tr>
<tr><td><code>display_details</code></td><td><code>{fields:[{label,value,copyable}], amount, currency, expires_at, poll_interval_sec}</code></td><td><span class="tag ok">v1</span> crypto</td></tr>
<tr><td><code>qr_code</code></td><td><code>{qr_content, display_amount, expires_at}</code></td><td><span class="tag info">预留</span></td></tr>
<tr><td><code>redirect_native</code></td><td><code>{app_scheme, universal_link, fallback_url}</code></td><td><span class="tag info">预留</span></td></tr>
<tr><td><code>sdk_handoff</code></td><td><code>{sdk, params}</code></td><td><span class="tag info">预留(逃逸口)</span></td></tr>
</tbody>
</table>
<p><b>护栏</b>:客户端遇到未知 render_type → 不显示该方法 / 提示"请升级 App"。无论哪种形态,客户端最终都收敛到同一个"轮询 <code>GET /orders/{no}</code> 到 paid"。</p>
<h3>6.3 客户端改动:一次 vs 永不</h3>
<table>
<thead><tr><th></th><th>动作</th></tr></thead>
<tbody>
<tr><td><b>一次(本轮做)</b></td><td>新增 <code>PaymentClient</code>(Dart, 四端共享)+ 支付页:方法选择器(读 <code>/v1/pay/methods</code>)→ 建单 → 按 render_type 分发(<code>redirect</code>=<code>url_launcher</code> 外部浏览器;<code>display_details</code>=地址卡)→ 轮询到 paid → 成功页。接上现有 no-op 购买入口。<b>无需新增原生依赖</b>(url_launcher 已在,无需 webview)。</td></tr>
<tr><td><b class="clean">永不(加渠道零改)</b></td><td>新增 provider 若落在已有 render_type(epay/Stripe Checkout = redirect;另一条链 = display_details)→ 仅服务端加 adapter + 下发一项。</td></tr>
<tr><td><b class="grey">未来才碰</b></td><td>全新交互形态(新 render_type);拉起 App 的 scheme 白名单;信用卡 3DS / Apple·Google Pay 专有 SDK。</td></tr>
</tbody>
</table>
<h2>7. 统一开通管线</h2>
<p>webhook 与 Query 轮询<b>最终都产出 <code>PaidEvent</code></b>,走同一条幂等开通逻辑:</p>
<table class="fields">
<thead><tr><th>步骤</th><th>动作</th></tr></thead>
<tbody>
<tr><td>① 验签</td><td>adapter.HandleCallback / Query(各平台不同:HMAC / RSA / 链上确认)</td></tr>
<tr><td>② 定位订单</td><td>provider_ref → pay_attempts → pay_orders</td></tr>
<tr><td>③ 幂等</td><td>attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通</td></tr>
<tr><td>④ 金额/币种核对</td><td>PaidEvent.amount == attempt.amount_minor &amp;&amp; currency 一致</td></tr>
<tr><td>⑤ 事务开通</td><td>{ order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }</td></tr>
</tbody>
</table>
<h3>7.1 复用并重构现有开通逻辑</h3>
<p>开通能力现埋在 <code>codes.Service.applySubscription</code>(<code>server/internal/codes/service.go:235</code>),仅作为 Redeem 事务的一步、且 <code>CreateSubscription</code><code>source</code> 硬编码 <code>'code'</code>。改动:</p>
<ul>
<li>抽出可复用、可传 <code>source</code> 的开通方法(codes.Redeem 与 pay 管线共用),避免两套开通逻辑漂移。</li>
<li><code>subscriptions.source</code> 的 CHECK 枚举现只允许 <code>('trial','code')</code><b><code>'pay'</code></b>(migration:sqlite 需表重建、mysql alter,双套)。</li>
<li>权益判定(<code>EntitlementForUser</code>)、<code>/me</code>、免费门 <b>无需改</b>——它们只看"最新未过期订阅",source 对它们透明。</li>
</ul>
<h2>8. 哪吒合规风险 <span class="tag bad">务必知情</span></h2>
<div class="warnbox">
调研明确:哪吒是<b>匿名注册(Telegram 开户、无 KYC)、USDT 结算、带代付出款、仅支付宝单通道、无公司主体 / ICP / 牌照</b>的四方聚合/代收平台。<b>风险:资金冻结、平台跑路(无主体可追索)、商户帮信罪敞口。</b>
</div>
<p><b>缓解 = 本架构天生解耦</b>:哪吒只是一个 <code>redirect</code> adapter,将来换成持牌支付宝直连 / Stripe / epay,<b>客户端零改、订单与开通逻辑零改</b>,只换服务端一个 adapter。可"先用它跑量、随时替换",不锁死。crypto(USDT 自托管)作为<b class="clean">干净主轨</b>并存。</p>
<h2>9. 错误处理与边界</h2>
<ul>
<li><b>回调不可信回跳</b>:浏览器从 return_url 回来时支付结果未必已到,客户端<b>不信任回跳</b>,只认轮询到的 <code>paid</code></li>
<li><b>webhook 可能丢</b>:crypto 无 webhook(只链上侦测)、哪吒 GET 回调可能被 GFW/CF 吞 → <b>Query 轮询兜底是刚需</b>,不只依赖 webhook。</li>
<li><b>重复回调</b>:<code>UNIQUE(provider, provider_ref)</code> + order granted 标记,已开通再来直接 200。</li>
<li><b>金额不符</b>:crypto 付错金额进 pay 侧 orphan;哪吒金额核对不过则拒绝并告警,不开通。</li>
<li><b>attempt 超时 ≠ order 关闭</b>:单个 attempt 到自己的 expires_at 只置该 attempt expired,order 仍 pending,用户可继续换渠道;只有 order 整体窗口到点才整单 expired。</li>
<li><b>只有一个 attempt 能成功</b>:<code>MarkOrderPaid</code> 原子守卫 order=pending,paid 后所有 attempt/retry 失效。</li>
<li><b>用户主动取消</b>:<code>POST /orders/{no}/cancel</code> 仅在 pending 生效(→canceled);已 paid/已取消返 409。canceled 单仍在订单列表可见。取消后若链上迟到付款到账 → 进 orphan 人工处理。</li>
<li><b>订单过期/取消后到账</b>:order 非 pending 则不自动开通,转人工对账(避免迟到付款误开/漏开)。</li>
<li><b>换渠道重试</b>:新建 Attempt,老 Attempt 置 expired,Order 仍 pending。</li>
</ul>
<h2>10. 测试策略</h2>
<ul>
<li><b>控制面</b>:adapter Create/Callback/Query 单测(mock 平台 HTTP);管线幂等(双回调→单开通)、金额不符拒绝、RSA/HMAC 验签通过/失败、开通重构(codes + pay 共用、source 正确)、Order/Attempt 状态机、方法发现。</li>
<li><b>pay-server</b>:出站 webhook 投递(签名、重试)单测。</li>
<li><b>客户端</b>:render_type 分发 widget 测试;轮询到 paid;未知 render_type 降级。</li>
<li><b>端到端</b>:两轨各跑一笔小额真链/真单。</li>
</ul>
<h2>11. 首版范围与实施阶段</h2>
<p>范围:<b>后端编排层 + crypto & 哪吒两 adapter + 四端 Flutter 统一支付页 + 用户中心订单历史页</b>一次到位。实施在计划里分阶段:</p>
<ol>
<li>控制面:schema(pay_orders/pay_attempts)+ store + 开通重构 + source=pay migration。</li>
<li>Provider 抽象 + 注册表 + crypto adapter + pay-server 出站 webhook。</li>
<li>哪吒 adapter(RSA 签/验、GET 回调、查单)。</li>
<li>客户端 REST(methods/orders/list/retry/webhooks)+ 统一开通管线 + Query 轮询 worker。</li>
<li>Flutter 支付页(方法选择器 + render_type 分发 + 轮询)+ 接上购买入口。</li>
<li>用户中心订单历史页(读 <code>GET /v1/pay/orders</code>,新增导航项;<code>return_url</code> 跳此)。</li>
<li>端到端联调(两轨小额)。</li>
</ol>
<h2>12. 待确认清单(评审 gate)</h2>
<table>
<thead><tr><th></th><th>状态</th></tr></thead>
<tbody>
<tr><td>SKU 三档 + USDT 价($3.99/$9.99/$29.99)</td><td><span class="tag ok">已定</span></td></tr>
<tr><td>CNY 价(¥29.99/¥68.88/¥199.99)</td><td><span class="tag ok">已定</span></td></tr>
<tr><td>哪吒商户 pid + RSA 密钥对(Bitwarden <code>nzzf</code>:shop_id/ShopPrivateKey/PublicPlateKey)</td><td><span class="tag ok">已定</span></td></tr>
<tr><td>return_url → 跳用户中心订单页</td><td><span class="tag ok">已定</span></td></tr>
<tr><td>Order + PaymentAttempt 两层模型</td><td><span class="tag info">本设计采用</span></td></tr>
<tr><td>用户中心订单历史页</td><td><span class="tag ok">已定:本轮做</span></td></tr>
</tbody>
</table>
<p class="small" style="margin-top:32px"><b>实现计划</b>:<a href="pay-orchestration-p1-plan.html">P1 数据层 + 开通重构</a>(真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md)。<br>相关文档:<a href="payment-channels-overview.html">支付渠道选型总览</a> · <a href="payment-clean-usdt-plan.html">干净 USDT 方案</a> · <a href="pay-single-address-plan.html">单地址收款模型</a> · <a href="crypto-tx-engine-plan.html">加密交易引擎</a></p>
</div>
</body>
</html>