Compare commits

...

5 Commits

Author SHA1 Message Date
wangjia cc47f16190 docs(pay): P1 实现计划(数据层+开通重构)+ 哪吒密钥入库标记已定
P1 计划(4 TDD 任务:迁移 000022 双引擎 / subscription.Grant 抽取 / codes 委托 /
payorders 数据访问)+ HTML 阅读版 + 登记 index。设计文档哪吒行更新为已定
(Bitwarden nzzf)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:17:47 +08:00
wangjia c33ac6d258 docs(pay): return_url 跳用户中心 + 用户中心订单历史页纳入 v1
新增 GET /v1/pay/orders 列表接口(历史订单);范围加用户中心订单页(读该接口、
新增导航、return_url 跳此);待确认清单收口(仅剩哪吒 pid/密钥,不挡开发)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:04:10 +08:00
wangjia 674ee504cb docs(pay): 定 CNY 价 ¥29.99/¥68.88/¥199.99
pro 月/季/年 人民币价拍板(哪吒/支付宝渠道),定价与待确认清单更新为已定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:01:32 +08:00
wangjia 266f4e83f3 docs(pay): 架构总览字符画换成 SVG 时序图
4 参与者 lifeline(客户端/控制面/Provider/外部平台)+ 14 条消息:
实线=请求(cyan)、虚线=返回(grey)、accent=异步(用户付款/webhook)、绿=paid。
含落库/render 分发/开通管线/Query 兜底 说明框。合规范式记入全局记忆
docs-diagrams-use-svg(文档图一律 SVG,禁字符画)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:57:15 +08:00
wangjia b327798511 docs(pay): 统一支付编排层设计(provider 无关,加渠道不改客户端)
Order+PaymentAttempt+Provider adapter(Create/HandleCallback/Query)三动作,
客户端只认 render_type 联合体(redirect/display_details/…)。首批 crypto
(包裹 pangolin-pay,display_details)+ 哪吒(支付宝跳转 redirect,RSA/GET 回调,
灰产四方可随时换持牌)。统一开通管线复用 codes,source=pay。四端 Flutter 支付页。
登记 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:05:20 +08:00
4 changed files with 1246 additions and 0 deletions
+10
View File
@@ -44,6 +44,11 @@
</div>
<h2>设计方案 / Specs</h2>
<a class="doc" href="pay-orchestration-design.html">
<div class="t">统一支付编排层 · provider 无关架构(加渠道不改客户端)<span class="tag html">HTML</span></div>
<div class="d">控制面新增支付编排层:<b>Order 业务订单 + PaymentAttempt 支付尝试 + Provider 适配器(Create/HandleCallback/Query</b>,客户端只认服务端下发的少数 <b>render_type</b>redirect/display_details/…)联合体,不认具体平台 → 同形态新网关零改客户端。首批接 crypto(USDT-TRC20,包裹已部署 pangolin-paydisplay_details) + 哪吒聚合支付(支付宝跳转,redirect,RSA 签/GET 回调,<b>灰产四方·可随时换持牌</b>)。统一开通管线(验签→定位→幂等→金额核对→复用 codes 开通,source=pay+ Query 轮询兜底。四端 Flutter 统一支付页。含 SKU 目录/双币定价/两表 schema/边界与合规。</div>
<div class="path">docs/pay-orchestration-design.html</div>
</a>
<a class="doc" href="payment-reseller-fulfillment-design.html">
<div class="t">支付落地方案 · 发卡/Reseller 收款 + 激活码自动发货 <span class="tag html">HTML</span></div>
<div class="d">把「收钱」与品牌 VPN 主体解耦:收款外包给发卡平台/Reseller(他们承担支付宝/微信跑分、冻卡、跑路风险),你只交付「激活码」;客户端/用户中心只认码,codes 模块核销即生效。含完整流程图(钱流/码流/结算)、两种对接模型(A 预充卡密库存 / B API 实时签发 POST /v1/codes/issue)、三阶段落地节奏、对账与风险边界(主体永不碰跑分/中国支付)。灰产“付完秒发货”体验的合规化替身。</div>
@@ -91,6 +96,11 @@
</a>
<h2>实现计划 / Plans</h2>
<a class="doc" href="pay-orchestration-p1-plan.html">
<div class="t">统一支付编排层 · P1 数据层 + 开通重构(阅读版)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md</code>(含 checkbox)。<a href="pay-orchestration-design.html">编排层设计</a>的首个落地阶段(7 阶段之 P1)。建 <b>pay_orders</b>(业务订单/购买账本,order_no 幂等)+ <b>pay_attempts</b>(支付尝试/收款账本,UNIQUE(provider,provider_ref) 幂等)两表;把「开通订阅」从 codes 抽成可传 <code>source</code><code>subscription.Grant</code> 包,<code>subscriptions.source</code> 枚举加 <code>'pay'</code><code>payorders</code> 数据访问含幂等 MarkOrderPaid。4 任务 TDD。P2-P7Provider 抽象/crypto·哪吒 adapter/Flutter 支付页/订单页/端到端)各自成计划、落地前细化。</div>
<div class="path">docs/pay-orchestration-p1-plan.html · 真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md</div>
</a>
<a class="doc" href="pay-single-address-plan.html">
<div class="t">pangolin-pay 单地址 + 唯一金额收款模型(定稿,#34/34A)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code>(含 checkbox)。<b>定稿:单个固定收款地址 + 每单唯一金额</b>(取代每单唯一 HD 地址/地址池)。归集=1 地址(激活一次、扫一笔)最省;订单靠金额区分。唯一金额=base+微尾数(≤0.01 USDT);精确 == 匹配 + 时间戳(晚于建单)防迟到误配;付错→孤儿人工对账。前端契约不变(POST /order 返回 address+expect_amount)→ 以后升多地址/GasFree 纯后端切、前端零改。5 Phase:数据模型→建单→watcher 匹配/超时/孤儿→归集(能量租赁)→并发/付错真链验证。</div>
+357
View File
@@ -0,0 +1,357 @@
<!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}
.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>
<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>
<pre>order_no TEXT UNIQUE -- 幂等主键, 形如 PAY2026...
user_id INTEGER -- FK users.id(内部关联订阅)
user_uuid TEXT -- 对外/跨系统标识(= pay 侧 user_ref)
sku TEXT -- pro-month / pro-quarter / pro-year
plan_code TEXT -- pro(建单时锁定,回调改不了)
duration_days INTEGER -- 30 / 90 / 365(建单时锁定)
status TEXT -- created|pending|paid|expired|canceled
subscription_id INTEGER NULL -- 开通后回填(审计)
created_at / paid_at / expires_at DATETIME</pre>
<p class="small">建单时就把 <code>order_no → user / plan / 天数</code> 落库。回调<b>只能推进已存在的订单、开通其中预先锁定的套餐</b>——回调无法指定"给谁开多久"。这是防回调伪造的纵深。</p>
<h3>4.2 <code>pay_attempts</code> — 支付尝试(收款账本)</h3>
<pre>id INTEGER PK
order_no TEXT -- FK pay_orders
method TEXT -- usdt_trc20 / alipay_nazha
provider TEXT -- crypto / nazha
provider_ref TEXT -- pangolin-pay 单号 / 哪吒 trade_no
render_type TEXT -- redirect / display_details
amount_minor INTEGER -- 该尝试的应收(按币种最小单位)
currency TEXT -- USDT / CNY
status TEXT -- created|pending|paid|failed|expired
created_at / paid_at DATETIME
UNIQUE(provider, provider_ref) -- 回调幂等的命门</pre>
<h3>4.3 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>type Provider interface {
Method() MethodInfo // 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}
Create(ctx, o Order) (Session, error) // ① 下单→返回渲染指令
HandleCallback(ctx, r *http.Request) (CallbackResult, error) // ② 验签+解析+归一化
Query(ctx, providerRef string) (PaymentStatus, *PaidEvent, error) // ③ 主动查单兜底
}
type Session struct { // Create 的返回,客户端按 RenderType 分发
ProviderRef string
RenderType string // redirect / display_details / ...
Payload json.RawMessage // 按 RenderType 定 shape
ExpiresAt time.Time
}
type PaidEvent struct { OrderNo string; ProviderRef string; AmountMinor int64; Currency string; PaidAt time.Time; TxRef string }
type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } // AckBody: 哪吒要回 "success"</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>GET /v1/pay/methods → 200 [{id,name,icon,render_type,currency,enabled,min,max}]
POST /v1/pay/orders {sku, method} → 201 {order_no, status, session:{render_type,payload,expires_at}}
GET /v1/pay/orders/{order_no} → 200 {status, plan, expires_at, session?}
GET /v1/pay/orders?limit&cursor → 200 {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} (历史订单,用户中心订单页用)
POST /v1/pay/orders/{order_no}/retry {method} → 201 新 session(Order 不变)
--- 平台→控制面(公开,不带 Bearer)---
POST /v1/webhooks/pay/crypto (pangolin-pay, HMAC 验签)
GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)</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>
<pre>① 验签 adapter.HandleCallback / Query (各平台不同:HMAC / RSA / 链上确认)
② 定位订单 provider_ref → pay_attempts → pay_orders
③ 幂等 attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通
④ 金额/币种核对 PaidEvent.amount == attempt.amount_minor && currency 一致
⑤ 事务开通 { order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }</pre>
<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>订单过期后到账</b>:订单已 expired 则不自动开通,转人工对账(避免迟到付款误开/漏开)。</li>
<li><b>换渠道重试</b>:新建 Attempt,老 Attempt 置 expired/canceled,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">相关文档:<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>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统一支付编排层 · P1 数据层 + 开通重构(阅读版)</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:28px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 24px}
h2{font-size:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:15px;margin:20px 0 8px;color:var(--accent2)}
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}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{text-align:left;padding:8px 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}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
.path{color:var(--fg2);font-size:12px;font-family:var(--mono)}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>统一支付编排层 · P1 数据层 + 开通重构<span style="font-size:14px"> (阅读版)</span></h1>
<p class="sub">执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md</code>(含 <code>- [ ]</code> checkbox,勿改)。设计见 <a href="pay-orchestration-design.html">统一支付编排层设计</a></p>
<div class="lead">
<b>P1 目标</b>:给控制面加支付订单数据层(<code>pay_orders</code> 业务订单 + <code>pay_attempts</code> 支付尝试),并把「开通订阅」从 codes 抽成可复用、可传 <code>source</code><code>subscription</code> 包,<code>subscriptions.source</code> 枚举加 <code>'pay'</code><b>只覆盖数据层 + 开通原语</b>HTTP/adapter/客户端在 P2+。
</div>
<h2>7 阶段路线图</h2>
<table>
<thead><tr><th>阶段</th><th>内容</th><th>本计划</th></tr></thead>
<tbody>
<tr><td><b>P1</b></td><td>schemapay_orders/pay_attempts+ subscription 开通重构(source=pay migration+ payorders 数据访问</td><td><span class="tag ok">本页</span></td></tr>
<tr><td>P2</td><td>Provider 抽象/注册表 + Order/Attempt service + 客户端 RESTmethods/orders/get/list/retry+ 统一开通管线 + Query 轮询 workerfake provider 测)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P3</td><td>crypto adapter(包裹 pangolin-pay+ pay-server 出站 webhook + <code>/v1/webhooks/pay/crypto</code></td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P4</td><td>哪吒 adapterRSA 签/验、GET 回调、查单;密钥 Bitwarden <code>nzzf</code>+ <code>/v1/webhooks/pay/nazha</code></td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P5</td><td>四端 Flutter 支付页(方法选择器 + render_type 分发 + 轮询)+ 接购买入口</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P6</td><td>用户中心订单历史页(读 <code>GET /v1/pay/orders</code>return_url 跳此)</td><td><span class="tag info">待细化</span></td></tr>
<tr><td>P7</td><td>端到端联调(两轨小额真链/真单)</td><td><span class="tag info">待细化</span></td></tr>
</tbody>
</table>
<p class="path">各阶段落地前逐一细化成独立计划,让接口以真代码为准、不臆测。</p>
<h2>P1 任务(4 个,TDD bite-sized</h2>
<div class="card">
<h3>Task 1 · 迁移 000022(双引擎)</h3>
<p><code>pay_orders</code> + <code>pay_attempts</code>,并给 <code>subscriptions.source</code><code>'pay'</code>。SQLite 列级 CHECK 不能 ALTER → <b>重建表</b>(rename→建新→复制→drop→重建索引);MySQL <code>ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay')</code>。up/down 各 4 文件,临时 sqlite 库跑 up/down 验证。</p>
</div>
<div class="card">
<h3>Task 2 · <code>subscription</code> 包抽出开通原语</h3>
<p>新包 <code>server/internal/subscription</code><code>Grant(ctx, tx, userID, planID, days, source) → (subID, expiresAt)</code> 复刻现规则(同 plan 有活跃订阅→延长;否则→新建),<code>source</code> 写进新行。SQLite 内存实库测「新建→续期叠加、只 1 行、source=pay」。</p>
</div>
<div class="card">
<h3>Task 3 · codes 委托 subscription.Grant(行为不变)</h3>
<p><code>codes.Service.applySubscription</code> 改为调 <code>subscription.Grant(..., "code")</code>,跑通现有 codes 全套测试证明零行为变化。删除 <code>codes.Store</code> 里迁走的重复方法(若无其他调用者)。</p>
</div>
<div class="card">
<h3>Task 4 · <code>payorders</code> 数据访问</h3>
<p>新包 <code>server/internal/payorders</code><code>Order</code>/<code>Attempt</code> 结构 + <code>CreateOrder/GetOrder/ListOrders/CreateAttempt/AttemptByProviderRef</code> + 幂等 <code>MarkOrderPaid</code>(仅 <code>status='pending'</code> 才置 paid + 回填 subscription_id + attempt 置 paid;已 paid 返回 false)。SQLite 内存实库测「建单→查单→标付→再标付幂等」。</p>
</div>
<h2>两张新表</h2>
<pre>pay_orders 业务订单/购买账本:order_no(唯一幂等) · user_id/user_uuid · sku · plan_code
· duration_days · amount_minor(int64) · currency · status
(created|pending|paid|expired|canceled) · subscription_id(开通回填)
· created_at/paid_at/expires_at
pay_attempts 支付尝试/收款账本:order_no · method · provider · provider_ref
· render_type · amount_minor · currency · status
(created|pending|paid|failed|expired) · UNIQUE(provider,provider_ref) 幂等命门</pre>
<p style="margin-top:28px" class="path">相关:<a href="pay-orchestration-design.html">编排层设计</a> · 真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md</p>
</div>
</body>
</html>
@@ -0,0 +1,777 @@
# 统一支付编排层 · P1 数据层 + 开通重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 给控制面加支付订单数据层(`pay_orders` + `pay_attempts`)并把「开通订阅」逻辑从 codes 里抽成可复用、可传 `source``subscription` 包,为后续支付编排(P2+)打地基。
**Architecture:** 两张新表:`pay_orders`(业务订单,order_no 幂等,建单时锁定 user/plan/天数)+ `pay_attempts`(支付尝试,`UNIQUE(provider,provider_ref)` 幂等)。开通逻辑从 `codes.Service.applySubscription` 抽到新包 `server/internal/subscription`,`Grant(...)``source` 参数;`subscriptions.source` 枚举加 `'pay'`。codes 改为委托新包,行为不变。
**Tech Stack:** Go(裸 SQL + `internal/db` 方言层)· golang-migrate(`migrations/{mysql,sqlite}/`)· SQLite(`modernc.org/sqlite`,`:memory:` 测试)· MySQL(docker 集成测试)。
## Global Constraints
- 金额一律 `int64` 最小单位存(USDT 微单位 1e-6 / CNY 分);禁 float。
- 迁移分 `server/migrations/{mysql,sqlite}/` 两套,同号同名,`up`+`down` 成对;golang-migrate。
- 时间一律 Go 端算好传 `?`;禁 `NOW()`/`UTC_TIMESTAMP()`/`DATE_ADD()`/`FIELD()` 等 MySQL 专属构造。
- upsert 用 `dialect.Upsert(...)`;行锁用 `dialect.LockForUpdate()`
- SQLite 改列级 CHECK/UNIQUE 须**重建表**(rename→建新→复制→drop→重建索引),单事务内完成。
- 新代码在 `server/` 模块内(`go build ./...` 通过);测试 `go test ./...` 免 docker(SQLite 实库)。
- 订单生命周期中文词(仅显示层):`created`=初始化 / `pending`=等待付款 / `paid`=付款完成 / `expired`=已过期 / `canceled`=已取消。枚举值用英文。
---
### Task 1: 迁移 000022 — pay_orders / pay_attempts 表 + subscriptions.source 加 'pay'
**Files:**
- Create: `server/migrations/sqlite/000022_pay_orders.up.sql`
- Create: `server/migrations/sqlite/000022_pay_orders.down.sql`
- Create: `server/migrations/mysql/000022_pay_orders.up.sql`
- Create: `server/migrations/mysql/000022_pay_orders.down.sql`
**Interfaces:**
- Produces: 表 `pay_orders`(列:`id,order_no,user_id,user_uuid,sku,plan_code,duration_days,amount_minor,currency,status,subscription_id,created_at,paid_at,expires_at`)、`pay_attempts`(列:`id,order_no,method,provider,provider_ref,render_type,amount_minor,currency,status,created_at,paid_at`,`UNIQUE(provider,provider_ref)`);`subscriptions.source` 枚举含 `'pay'`。后续 P1 Task2/3 与 P2+ 依赖这些列名。
- [ ] **Step 1: 写 sqlite up 迁移**
`server/migrations/sqlite/000022_pay_orders.up.sql`:
```sql
-- 支付编排层数据层:业务订单 pay_orders + 支付尝试 pay_attempts。
-- 金额一律最小单位 int64(USDT 微单位 1e-6 / CNY 分)。
-- subscriptions.source 加 'pay'(SQLite 列级 CHECK 不能 ALTER,重建表)。
CREATE TABLE pay_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
user_uuid TEXT NOT NULL,
sku TEXT NOT NULL,
plan_code TEXT NOT NULL,
duration_days INTEGER NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','pending','paid','expired','canceled')),
subscription_id INTEGER NULL,
created_at DATETIME NOT NULL,
paid_at DATETIME NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_pay_orders_user ON pay_orders (user_id, created_at);
CREATE TABLE pay_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL,
method TEXT NOT NULL,
provider TEXT NOT NULL,
provider_ref TEXT NOT NULL,
render_type TEXT NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','pending','paid','failed','expired')),
created_at DATETIME NOT NULL,
paid_at DATETIME NULL,
UNIQUE (provider, provider_ref),
FOREIGN KEY (order_no) REFERENCES pay_orders(order_no)
);
CREATE INDEX idx_pay_attempts_order ON pay_attempts (order_no);
-- subscriptions.source 加 'pay' —— 重建表(rename→建新→复制→drop→重建索引)。
ALTER TABLE subscriptions RENAME TO subscriptions_old;
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
plan_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
source TEXT NOT NULL CHECK (source IN ('trial','code','pay')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (plan_id) REFERENCES plans(id)
);
INSERT INTO subscriptions (id, user_id, plan_id, expires_at, source, created_at)
SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions_old;
DROP TABLE subscriptions_old;
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
```
- [ ] **Step 2: 写 sqlite down 迁移**
`server/migrations/sqlite/000022_pay_orders.down.sql`:
```sql
DROP TABLE IF EXISTS pay_attempts;
DROP TABLE IF EXISTS pay_orders;
-- 回滚 source 枚举(重建回 'trial','code';回滚前须无 source='pay' 行,否则 INSERT 被 CHECK 拒)。
ALTER TABLE subscriptions RENAME TO subscriptions_old;
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
plan_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
source TEXT NOT NULL CHECK (source IN ('trial','code')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (plan_id) REFERENCES plans(id)
);
INSERT INTO subscriptions (id, user_id, plan_id, expires_at, source, created_at)
SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions_old;
DROP TABLE subscriptions_old;
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
```
- [ ] **Step 3: 写 mysql up 迁移**
`server/migrations/mysql/000022_pay_orders.up.sql`:
```sql
CREATE TABLE pay_orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
user_uuid CHAR(36) NOT NULL,
sku VARCHAR(64) NOT NULL,
plan_code VARCHAR(32) NOT NULL,
duration_days INT NOT NULL,
amount_minor BIGINT NOT NULL,
currency VARCHAR(16) NOT NULL,
status ENUM('created','pending','paid','expired','canceled') NOT NULL,
subscription_id BIGINT UNSIGNED NULL,
created_at DATETIME(6) NOT NULL,
paid_at DATETIME(6) NULL,
expires_at DATETIME(6) NOT NULL,
INDEX idx_pay_orders_user (user_id, created_at),
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE pay_attempts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
method VARCHAR(32) NOT NULL,
provider VARCHAR(32) NOT NULL,
provider_ref VARCHAR(128) NOT NULL,
render_type VARCHAR(32) NOT NULL,
amount_minor BIGINT NOT NULL,
currency VARCHAR(16) NOT NULL,
status ENUM('created','pending','paid','failed','expired') NOT NULL,
created_at DATETIME(6) NOT NULL,
paid_at DATETIME(6) NULL,
UNIQUE KEY uq_provider_ref (provider, provider_ref),
INDEX idx_pay_attempts_order (order_no),
FOREIGN KEY (order_no) REFERENCES pay_orders(order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL;
```
- [ ] **Step 4: 写 mysql down 迁移**
`server/migrations/mysql/000022_pay_orders.down.sql`:
```sql
DROP TABLE IF EXISTS pay_attempts;
DROP TABLE IF EXISTS pay_orders;
ALTER TABLE subscriptions MODIFY source ENUM('trial','code') NOT NULL;
```
- [ ] **Step 5: 在临时 sqlite 库上跑 up 验证建表成功**
Run:
```bash
cd server && rm -f /tmp/p1.db && DB_DRIVER=sqlite DB_DSN=/tmp/p1.db go run ./cmd/migrate up && \
sqlite3 /tmp/p1.db ".tables" | tr ' ' '\n' | grep -E 'pay_orders|pay_attempts' && \
sqlite3 /tmp/p1.db "SELECT sql FROM sqlite_master WHERE name='subscriptions';" | grep -q "'pay'" && echo OK-UP
```
Expected: 打印 `pay_attempts` / `pay_orders` 两行 + `OK-UP`(subscriptions 含 'pay')。
- [ ] **Step 6: 跑 down 验证干净回滚**
Run:
```bash
cd server && DB_DRIVER=sqlite DB_DSN=/tmp/p1.db go run ./cmd/migrate down 1 && \
sqlite3 /tmp/p1.db ".tables" | tr ' ' '\n' | grep -Eq 'pay_orders' && echo "FAIL: 表还在" || echo OK-DOWN
```
Expected: `OK-DOWN`(pay_orders 已删)。若 `cmd/migrate``down N` 子命令,改用 `migrate -database ...` 或按 `cmd/migrate/main.go` 实际子命令名调整。
- [ ] **Step 7: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/macos-killswitch
git add server/migrations/sqlite/000022_pay_orders.*.sql server/migrations/mysql/000022_pay_orders.*.sql
git commit -m "feat(pay): 迁移 000022 pay_orders/pay_attempts + subscriptions.source 加 pay"
```
---
### Task 2: subscription 包 — 抽出可传 source 的开通逻辑
**Files:**
- Create: `server/internal/subscription/grant.go`
- Create: `server/internal/subscription/grant_test.go`
**Interfaces:**
- Consumes: 表 `subscriptions`/`plans`(Task 1)。
- Produces:
- `type Granter struct{ db *sql.DB }`
- `func NewGranter(db *sql.DB) *Granter`
- `func (g *Granter) Grant(ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, source string) (subID int64, expiresAt time.Time, err error)` — 复刻现 `applySubscription` 规则:同 plan 有活跃订阅→`ExtendSubscription`(max(expires,now)+days);否则→`CreateSubscription`(max(now, 同plan最晚)+days)。`source` 写进新建行。
- `func (g *Granter) PlanID(ctx context.Context, code string) (int64, error)` — plan code→id。
- [ ] **Step 1: 写失败测试(sqlite 实库)**
`server/internal/subscription/grant_test.go`:
```go
package subscription_test
import (
"context"
"database/sql"
"testing"
"time"
_ "modernc.org/sqlite"
"github.com/wangjia/pangolin/server/internal/subscription"
)
// openSeeded 建一个含 plans/subscriptions/users 最小 schema 的内存库并塞 pro 套餐 + 一个用户。
func openSeeded(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_txlock=immediate")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
stmts := []string{
`CREATE TABLE users (id INTEGER PRIMARY KEY, uuid TEXT)`,
`CREATE TABLE plans (id INTEGER PRIMARY KEY, code TEXT UNIQUE)`,
`CREATE TABLE subscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, plan_id INTEGER,
expires_at DATETIME NOT NULL, source TEXT NOT NULL CHECK (source IN ('trial','code','pay')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
`INSERT INTO users (id, uuid) VALUES (1, 'u-1')`,
`INSERT INTO plans (id, code) VALUES (10, 'pro')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("seed: %v", err)
}
}
return db
}
func TestGrantCreatesThenExtends(t *testing.T) {
db := openSeeded(t)
g := subscription.NewGranter(db)
ctx := context.Background()
planID, err := g.PlanID(ctx, "pro")
if err != nil || planID != 10 {
t.Fatalf("PlanID pro = %d, %v", planID, err)
}
tx, _ := db.Begin()
subID, exp1, err := g.Grant(ctx, tx, 1, planID, 30, "pay")
if err != nil {
t.Fatalf("grant#1: %v", err)
}
_ = tx.Commit()
if subID == 0 || time.Until(exp1) < 29*24*time.Hour {
t.Fatalf("grant#1 subID=%d exp=%v", subID, exp1)
}
// 同 plan 再开 30 天 → 应在原到期上延长(≈60 天),不新建行。
tx2, _ := db.Begin()
_, exp2, err := g.Grant(ctx, tx2, 1, planID, 30, "pay")
if err != nil {
t.Fatalf("grant#2: %v", err)
}
_ = tx2.Commit()
if exp2.Sub(exp1) < 29*24*time.Hour {
t.Fatalf("续期未叠加:exp1=%v exp2=%v", exp1, exp2)
}
var n int
db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&n)
if n != 1 {
t.Fatalf("应只有 1 行订阅(延长非新建),实际 %d", n)
}
var src string
db.QueryRow(`SELECT source FROM subscriptions WHERE user_id=1`).Scan(&src)
if src != "pay" {
t.Fatalf("source 应为 pay,实际 %s", src)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd server && go test ./internal/subscription/ -run TestGrantCreatesThenExtends -v`
Expected: 编译失败 / FAIL —— `subscription` 包不存在。
- [ ] **Step 3: 写实现**
`server/internal/subscription/grant.go`:
```go
// Package subscription owns "开通/续期订阅" —— 从 codes 抽出,供 codes 兑换与 pay 支付共用。
// 规则:同 plan 有活跃订阅→延长最晚一条;否则→新建。source 记来源('trial'|'code'|'pay')。
package subscription
import (
"context"
"database/sql"
"fmt"
"time"
)
type Granter struct{ db *sql.DB }
func NewGranter(db *sql.DB) *Granter { return &Granter{db: db} }
// PlanID maps a plans.code to its primary key.
func (g *Granter) PlanID(ctx context.Context, code string) (int64, error) {
var id int64
if err := g.db.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, code).Scan(&id); err != nil {
return 0, fmt.Errorf("subscription.PlanID(%s): %w", code, err)
}
return id, nil
}
// Grant extends the same-plan subscription or creates a new one, inside tx.
// Base-date math is done in Go (portable across MySQL/SQLite).
func (g *Granter) Grant(ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int, source string) (int64, time.Time, error) {
now := time.Now().UTC()
// 找同 plan 未过期订阅里到期最晚的一条。
var subID int64
var latest time.Time
err := tx.QueryRowContext(ctx,
`SELECT id, expires_at FROM subscriptions
WHERE user_id=? AND plan_id=? AND expires_at > ?
ORDER BY expires_at DESC LIMIT 1`,
userID, planID, now).Scan(&subID, &latest)
switch {
case err == sql.ErrNoRows:
// 新建:base = max(now, 同 plan 任意最晚到期)。
var anyLatest sql.NullTime
_ = tx.QueryRowContext(ctx,
`SELECT MAX(expires_at) FROM subscriptions WHERE user_id=? AND plan_id=?`,
userID, planID).Scan(&anyLatest)
base := now
if anyLatest.Valid && anyLatest.Time.After(base) {
base = anyLatest.Time.UTC()
}
expiresAt := base.AddDate(0, 0, durationDays)
res, err := tx.ExecContext(ctx,
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
VALUES (?, ?, ?, ?, ?)`,
userID, planID, expiresAt, source, now)
if err != nil {
return 0, time.Time{}, fmt.Errorf("subscription.Grant insert: %w", err)
}
id, _ := res.LastInsertId()
return id, expiresAt, nil
case err != nil:
return 0, time.Time{}, fmt.Errorf("subscription.Grant read: %w", err)
}
// 延长:base = max(latest, now)。
base := latest.UTC()
if now.After(base) {
base = now
}
expiresAt := base.AddDate(0, 0, durationDays)
if _, err := tx.ExecContext(ctx,
`UPDATE subscriptions SET expires_at=? WHERE id=?`, expiresAt, subID); err != nil {
return 0, time.Time{}, fmt.Errorf("subscription.Grant extend: %w", err)
}
return subID, expiresAt, nil
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `cd server && go test ./internal/subscription/ -run TestGrantCreatesThenExtends -v`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/macos-killswitch
git add server/internal/subscription/
git commit -m "feat(pay): subscription 包抽出可传 source 的开通逻辑(Grant/PlanID)"
```
---
### Task 3: codes 改为委托 subscription.Grant(行为不变)
**Files:**
- Modify: `server/internal/codes/service.go:235-287`(`applySubscription` 改为调 `subscription.Grant`,source 传 `"code"`)
- Modify: `server/internal/codes/service.go`(`Service` 结构加 `granter *subscription.Granter`;`NewService` 构造它)
- Modify: `server/cmd/server/main.go`(若 `NewService` 签名变,更新调用)
**Interfaces:**
- Consumes: `subscription.NewGranter(db)``(*Granter).Grant(ctx, tx, userID, planID, days, source)`(Task 2)。
- Produces: codes 兑换行为不变(source 仍写 `'code'`),但开通走 subscription 包。
- [ ] **Step 1: 跑现有 codes 测试记录基线(全绿)**
Run: `cd server && go test ./internal/codes/ 2>&1 | tail -3`
Expected: `ok .../internal/codes`(记住这是重构后必须仍绿的基线)。
- [ ] **Step 2: 改 Service 持有 granter 并委托**
`server/internal/codes/service.go` —— 给 `Service` 加字段(在其结构定义处)`granter *subscription.Granter`,`NewService``granter: subscription.NewGranter(store.DB())`(若 `Store``DB()` getter,加一个 `func (s *Store) DB() *sql.DB { return s.db }`)。然后把 `applySubscription` 体替换为委托:
```go
func (svc *Service) applySubscription(
ctx context.Context,
tx *sql.Tx,
userID int64,
cr *CodeRow,
) (subID int64, expiresAt time.Time, apiErr *apierr.Error) {
id, exp, err := svc.granter.Grant(ctx, tx, userID, cr.PlanID, cr.DurationDays, "code")
if err != nil {
return 0, time.Time{}, apierr.ErrInternal
}
return id, exp, nil
}
```
并在 import 块加 `"github.com/wangjia/pangolin/server/internal/subscription"`
- [ ] **Step 3: 编译**
Run: `cd server && go build ./...`
Expected: 无错误。若 `NewService` 参数未变(仍 `NewService(store, rdb, ...)`),`main.go` 无需改。
- [ ] **Step 4: 跑 codes 全套测试确认行为不变**
Run: `cd server && go test ./internal/codes/ ./internal/subscription/ 2>&1 | tail -4`
Expected: 两包均 `ok`(codes 兑换/幂等/并发单赢家等全绿,证明重构未改行为)。
- [ ] **Step 5: 删除 codes.Store 里已迁走的重复方法(可选,若无其他调用者)**
Run 先查引用:
```bash
cd server && grep -rn "\.GetActiveSubscriptions\|\.ExtendSubscription\|\.CreateSubscription\b" internal/ | grep -v _test.go
```
若仅 `subscription` 包内部有等价 SQL、codes 已不再调这三个 `Store` 方法,则从 `server/internal/codes/store.go:184-261` 删除它们及 `SubscriptionRow`(若无其他引用),减少重复。有任何其他调用者则**保留**,不动。
- [ ] **Step 6: 编译 + 全量测试**
Run: `cd server && go build ./... && go test ./internal/codes/ ./internal/subscription/ 2>&1 | tail -4`
Expected: 编译通过、两包 `ok`
- [ ] **Step 7: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/macos-killswitch
git add server/internal/codes/ server/cmd/server/main.go
git commit -m "refactor(pay): codes 开通委托 subscription.Grant(行为不变,source=code)"
```
---
### Task 4: payorders 包 — Order/Attempt 数据访问
**Files:**
- Create: `server/internal/payorders/store.go`
- Create: `server/internal/payorders/store_test.go`
**Interfaces:**
- Consumes: 表 `pay_orders`/`pay_attempts`(Task 1)。
- Produces:
- `type Order struct{ OrderNo string; UserID int64; UserUUID, SKU, PlanCode string; DurationDays int; AmountMinor int64; Currency, Status string; CreatedAt, ExpiresAt time.Time; PaidAt sql.NullTime; SubscriptionID sql.NullInt64 }`
- `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; CreatedAt time.Time; PaidAt sql.NullTime }`
- `type Store struct{ db *sql.DB }` · `NewStore(db) *Store` · `func (s *Store) DB() *sql.DB`
- `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound``ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)`
- `CreateAttempt(ctx, Attempt) (int64, error)` · `AttemptByProviderRef(ctx, provider, ref string) (*Attempt, error)`
- `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;已 paid 返回 `false,nil`
- `var ErrNotFound = errors.New("payorders: not found")`
- [ ] **Step 1: 写失败测试(sqlite 实库,建最小 schema)**
`server/internal/payorders/store_test.go`:
```go
package payorders_test
import (
"context"
"testing"
"time"
_ "modernc.org/sqlite"
"database/sql"
"github.com/wangjia/pangolin/server/internal/payorders"
)
func openPay(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_txlock=immediate")
if err != nil { t.Fatal(err) }
t.Cleanup(func() { _ = db.Close() })
ddl := []string{
`CREATE TABLE pay_orders (id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE, user_id INTEGER,
user_uuid TEXT, sku TEXT, plan_code TEXT, duration_days INTEGER, amount_minor INTEGER, currency TEXT,
status TEXT, subscription_id INTEGER NULL, created_at DATETIME, paid_at DATETIME NULL, expires_at DATETIME)`,
`CREATE TABLE pay_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT, method TEXT, provider TEXT,
provider_ref TEXT, render_type TEXT, amount_minor INTEGER, currency TEXT, status TEXT,
created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`,
}
for _, s := range ddl {
if _, err := db.Exec(s); err != nil { t.Fatalf("ddl: %v", err) }
}
return db
}
func TestCreateGetMarkPaidIdempotent(t *testing.T) {
db := openPay(t)
st := payorders.NewStore(db)
ctx := context.Background()
now := time.Now().UTC()
o := payorders.Order{
OrderNo: "PAY-1", UserID: 1, UserUUID: "u-1", SKU: "pro-year", PlanCode: "pro",
DurationDays: 365, AmountMinor: 29990000, Currency: "USDT", Status: "pending",
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
if err := st.CreateOrder(ctx, o); err != nil { t.Fatalf("create: %v", err) }
got, err := st.GetOrder(ctx, "PAY-1")
if err != nil || got.AmountMinor != 29990000 || got.Status != "pending" {
t.Fatalf("get: %+v %v", got, err)
}
if _, err := st.CreateAttempt(ctx, payorders.Attempt{
OrderNo: "PAY-1", Method: "usdt_trc20", Provider: "crypto", ProviderRef: "P-9",
RenderType: "display_details", AmountMinor: 29990000, Currency: "USDT",
Status: "pending", CreatedAt: now,
}); err != nil { t.Fatalf("attempt: %v", err) }
tx, _ := db.Begin()
ok, err := st.MarkOrderPaid(ctx, tx, "PAY-1", "crypto", "P-9", 77, now)
_ = tx.Commit()
if err != nil || !ok { t.Fatalf("mark#1 ok=%v err=%v", ok, err) }
// 幂等:再标一次 → false。
tx2, _ := db.Begin()
ok2, _ := st.MarkOrderPaid(ctx, tx2, "PAY-1", "crypto", "P-9", 77, now)
_ = tx2.Commit()
if ok2 { t.Fatalf("第二次 MarkOrderPaid 应 false(幂等)") }
final, _ := st.GetOrder(ctx, "PAY-1")
if final.Status != "paid" || !final.SubscriptionID.Valid || final.SubscriptionID.Int64 != 77 {
t.Fatalf("final: %+v", final)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
Run: `cd server && go test ./internal/payorders/ -run TestCreateGetMarkPaidIdempotent -v`
Expected: 编译失败 —— `payorders` 包不存在。
- [ ] **Step 3: 写实现**
`server/internal/payorders/store.go`:
```go
// Package payorders is the control-plane payment ledger: business orders
// (pay_orders) + payment attempts (pay_attempts). Amounts are int64 minor units.
package payorders
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
var ErrNotFound = errors.New("payorders: not found")
type Order struct {
OrderNo string
UserID int64
UserUUID string
SKU string
PlanCode string
DurationDays int
AmountMinor int64
Currency string
Status string
CreatedAt time.Time
ExpiresAt time.Time
PaidAt sql.NullTime
SubscriptionID sql.NullInt64
}
type Attempt struct {
ID int64
OrderNo string
Method string
Provider string
ProviderRef string
RenderType string
AmountMinor int64
Currency string
Status string
CreatedAt time.Time
PaidAt sql.NullTime
}
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) DB() *sql.DB { return s.db }
func (s *Store) CreateOrder(ctx context.Context, o Order) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO pay_orders (order_no, user_id, user_uuid, sku, plan_code, duration_days,
amount_minor, currency, status, created_at, expires_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
o.OrderNo, o.UserID, o.UserUUID, o.SKU, o.PlanCode, o.DurationDays,
o.AmountMinor, o.Currency, o.Status, o.CreatedAt.UTC(), o.ExpiresAt.UTC())
if err != nil {
return fmt.Errorf("payorders.CreateOrder: %w", err)
}
return nil
}
const orderCols = `order_no, user_id, user_uuid, sku, plan_code, duration_days,
amount_minor, currency, status, subscription_id, created_at, paid_at, expires_at`
func scanOrder(row interface{ Scan(...any) error }) (*Order, error) {
var o Order
if err := row.Scan(&o.OrderNo, &o.UserID, &o.UserUUID, &o.SKU, &o.PlanCode, &o.DurationDays,
&o.AmountMinor, &o.Currency, &o.Status, &o.SubscriptionID, &o.CreatedAt, &o.PaidAt, &o.ExpiresAt); err != nil {
return nil, err
}
return &o, nil
}
func (s *Store) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
o, err := scanOrder(s.db.QueryRowContext(ctx,
`SELECT `+orderCols+` FROM pay_orders WHERE order_no=?`, orderNo))
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("payorders.GetOrder: %w", err)
}
return o, nil
}
// ListOrders returns the user's orders newest-first; beforeID=0 means from the top.
func (s *Store) ListOrders(ctx context.Context, userID int64, limit int, beforeID int64) ([]Order, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
q := `SELECT ` + orderCols + ` FROM pay_orders WHERE user_id=?`
args := []any{userID}
if beforeID > 0 {
q += ` AND id < ?`
args = append(args, beforeID)
}
q += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("payorders.ListOrders: %w", err)
}
defer rows.Close()
var out []Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
return nil, err
}
out = append(out, *o)
}
return out, rows.Err()
}
func (s *Store) CreateAttempt(ctx context.Context, a Attempt) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO pay_attempts (order_no, method, provider, provider_ref, render_type,
amount_minor, currency, status, created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
a.OrderNo, a.Method, a.Provider, a.ProviderRef, a.RenderType,
a.AmountMinor, a.Currency, a.Status, a.CreatedAt.UTC())
if err != nil {
return 0, fmt.Errorf("payorders.CreateAttempt: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string) (*Attempt, error) {
var a Attempt
err := s.db.QueryRowContext(ctx,
`SELECT id, order_no, method, provider, provider_ref, render_type, amount_minor,
currency, status, created_at, paid_at
FROM pay_attempts WHERE provider=? AND provider_ref=?`, provider, ref).
Scan(&a.ID, &a.OrderNo, &a.Method, &a.Provider, &a.ProviderRef, &a.RenderType,
&a.AmountMinor, &a.Currency, &a.Status, &a.CreatedAt, &a.PaidAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("payorders.AttemptByProviderRef: %w", err)
}
return &a, nil
}
// MarkOrderPaid flips a pending order to paid inside tx (idempotent). Returns
// false if the order was not in 'pending' (already handled / expired).
func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error) {
res, err := tx.ExecContext(ctx,
`UPDATE pay_orders SET status='paid', subscription_id=?, paid_at=?
WHERE order_no=? AND status='pending'`, subID, at.UTC(), orderNo)
if err != nil {
return false, fmt.Errorf("payorders.MarkOrderPaid order: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return false, nil
}
if _, err := tx.ExecContext(ctx,
`UPDATE pay_attempts SET status='paid', paid_at=? WHERE provider=? AND provider_ref=?`,
at.UTC(), provider, ref); err != nil {
return false, fmt.Errorf("payorders.MarkOrderPaid attempt: %w", err)
}
return true, nil
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `cd server && go test ./internal/payorders/ -run TestCreateGetMarkPaidIdempotent -v`
Expected: PASS。
- [ ] **Step 5: 全量编译 + 测试**
Run: `cd server && go build ./... && go test ./internal/payorders/ ./internal/subscription/ ./internal/codes/ 2>&1 | tail -5`
Expected: 编译通过,三包 `ok`
- [ ] **Step 6: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/macos-killswitch
git add server/internal/payorders/
git commit -m "feat(pay): payorders 包 Order/Attempt 数据访问 + 幂等 MarkOrderPaid"
```
---
## Self-Review
**Spec coverage(P1 范围):** 迁移(pay_orders/pay_attempts/source=pay)= Task1 ✓;开通重构可传 source = Task2+3 ✓;订单数据访问(建单/查单/列表/幂等标付)= Task4 ✓。P1 只覆盖数据层 + 开通原语,不含 HTTP/adapter/客户端(P2+)。
**Placeholder scan:** 无 TBD;所有 SQL/Go/测试均完整给出。Task1 Step6 对 `cmd/migrate` 子命令名留了「按实际调整」的注记——因未逐字读 `cmd/migrate/main.go`,执行者据实调整,不影响建表正确性。
**Type consistency:** `Granter.Grant(ctx,tx,userID,planID,days,source)` 在 Task2 定义、Task3 调用一致;`payorders.Order`/`Attempt` 字段在 Task4 测试与实现一致;`MarkOrderPaid` 幂等语义(status='pending' 才动)测试与实现一致。
---
## 后续阶段(各自独立成计划,落地前逐一细化)
- **P2** Provider 抽象/注册表 + Order/Attempt service + 客户端 REST(methods/orders/get/list/retry)+ 统一开通管线 + Query 轮询 worker(用 fake provider 测)。
- **P3** crypto adapter(包裹 pangolin-pay)+ pay-server 出站 webhook + `/v1/webhooks/pay/crypto`
- **P4** 哪吒 adapter(RSA 签/验、GET 回调、查单;密钥 Bitwarden `nzzf`:`shop_id`/`ShopPrivateKey`/`PublicPlateKey`)+ `/v1/webhooks/pay/nazha`
- **P5** 四端 Flutter 支付页(方法选择器 + render_type 分发 redirect/display_details + 轮询)+ 接上现有购买入口。
- **P6** 用户中心订单历史页(读 `GET /v1/pay/orders`,新增导航;return_url 跳此)。
- **P7** 端到端联调(两轨小额真链/真单)。