8a72665428
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>
397 lines
31 KiB
HTML
397 lines
31 KiB
HTML
<!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 000001–000021 无 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(谁/买啥/几天)+ Attempt(provider/单号)</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&</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&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 && 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>
|