Compare commits

..

13 Commits

Author SHA1 Message Date
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
wangjia 6cdcec59af docs(pay): 设计文档与 P1 计划互相加链接
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:30:51 +08:00
wangjia 127f09faaa docs(pay): attempt 带 expires_at(单次超时不关 order)+ 订单取消接口
- 超时挂 attempt(哪吒/crypto 各自),超时只弃本 attempt、order 仍 pending 可换渠道;
  order.expires_at 是整体购买窗口。一单只一个 attempt 成功(MarkOrderPaid 守卫 pending)。
- 加 POST /orders/{no}/cancel(pending→canceled),canceled 单在订单列表可见。
- P1 计划同步:pay_attempts 加 expires_at 列 + CancelOrder 方法 + 取消/列表测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:26:39 +08:00
wangjia bf058077cd docs(pay): 补两表分工与创建时机(4.3),SKU 定价改 4.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:18:53 +08:00
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
wangjia bced7b35e0 fix(pay): 测试页状态改中文标签(消歧义)
pending/paid/expired 显示层映射为 等待付款/付款完成/已过期,徽章字体改 sans。
枚举值不变(程序仍用英文);发货/开通属门面(独角数卡),不在收款服务里。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:08:41 +08:00
wangjia e421f03790 fix(pay): 测试页对齐官网暖色风格 + 修地址逐字换行布局
- 布局 bug:收款地址原挤在 flex 行里(与复制键争宽)→ word-break 逐字换行。
  改为独立整行 .addr 块(全宽自然换行),复制键下移全宽。
- 风格:改用官网 light 暖色 token(sand/clay,主色 #B96A3D、sage 绿
  #5B8C5A、JetBrains Mono),内联对齐 design/prototype/tokens.css。
- 页脚补一句「测什么」:收款订单闭环 POST /order + GET /order/{id} 轮询到 paid。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:00:25 +08:00
wangjia 5fd0d3c148 feat(pay): 同源收款测试页 GET /_test(Phase E 手动闭环验证)
pay-server 自带 /_test 测试页:创建订单→显示收款地址+精确金额(可复制)
+过期倒计时→轮询状态到 paid。同源、无 CORS/CSP 摩擦,不碰营销站安全头;
纯调 POST /order + GET /order/{id},无私钥无密钥。生产收银台后续走独角数卡门面。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:51:10 +08:00
wangjia 7e7381b209 feat(pay): httpapi 加 CORS(web 跨域下单)+ 部署到 pangolin1 准备(#34/34A)
withCORS 中间件:仅白名单 origin(默认 pangolin.yanmeiai.com + pages.dev)返回 CORS 头、
OPTIONS 直接 204;New(svc, corsOrigins) + main 读 PAY_CORS_ORIGINS。为官网下单页跨域调 pay 铺路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:39:52 +08:00
18 changed files with 3828 additions and 74 deletions
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pangolin-pay 加密货币交易引擎 实现计划</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:29px;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:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.85em;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.5;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
ul.ck{list-style:none;padding-left:4px}
ul.ck li{position:relative;padding-left:26px;margin:7px 0}
ul.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
ul{padding-left:22px}
li{margin:6px 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)}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>pangolin-pay 加密货币交易引擎</h1>
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-crypto-tx-engine.md</code> · #34 的第一块(可独立执行 + 真实小额验证)</p>
<div class="lead">
#34 的<b>第一块、可独立执行、能用真实小额 USDT 端到端验证</b>的基础件。<b>只做加密货币"交易"核心</b>——派生地址、侦测到账、归集(sweep)——<b>不含</b>独角数卡对接、不含发码。成果=独立 Go 服务 <code>pangolin-pay</code>,提供"建单→收款地址→侦测到账→标记已付"+ 离线归集工具。跑通后任何门面都能挂上来。
</div>
<div class="warnbox">
<b>安全铁律:</b>热服务只持 <b>xpub</b>(watch-only 派生)+ TronGrid key + 金库收款地址,<b>绝不持私钥</b>;私钥/助记词冷存,归集在离线端签名。关键常量(核对无误再写死):USDT-TRC20 合约 <code>TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t</code>(6 位小数)· 派生 <code>m/44'/195'/0'/0/i</code> · TronGrid <code>api.trongrid.io</code>
</div>
<h2>Phase A — 钱包与密钥(全程离线)</h2>
<ul class="ck">
<li><b>A.1</b> 离线生成<b>两套</b>助记词(断网,Ian Coleman/bip_utils,TRX/24词/English):钱包 A 运营收款、钱包 B 冷备金库。</li>
<li><b>A.2</b> 导出<b>钱包 A account xpub</b>(<code>m/44'/195'/0'</code>);取<b>钱包 B 地址0</b> 当归集目标。</li>
<li><b>A.3</b> 冷存助记词 A/B(分开)+ Bitwarden;只把 xpub_A + sweep_addr_B 放进配置。</li>
<li><b>A.4</b> <b>交叉核对</b>:Go 派生出的地址 0..4 必须与 Ian Coleman 逐个一致(防收款地址对不上)。</li>
</ul>
<h2>Phase B — 服务骨架 + HD 派生</h2>
<ul class="ck">
<li><b>B.1</b> 建 Go 服务 <code>pangolin-pay</code>(独立)。配置:xpub_A / sweep_addr_B / TronGrid key / 确认数 / 超时。自带 SQLite。</li>
<li><b>B.2</b> HD 派生:go-bip39 + hdkeychain 从 xpub_A watch-only 派生 <code>.../0/i</code> 公钥 → TRON 地址(Keccak 后20字节 + <code>0x41</code> + Base58Check)。<b>单测对齐 Ian Coleman</b></li>
<li><b>B.3</b> 数据模型:<code>pay_orders(order_no, sku, expect_amount, addr_index, address, status, tx_id, confirmations, created_at, expires_at)</code> + <code>addr_cursor(next_index)</code>(地址不复用)。</li>
</ul>
<h2>Phase C — 收款(建单 + 侦测到账)</h2>
<ul class="ck">
<li><b>C.1</b> <code>POST /order</code> → cursor++ 派生新地址 → 写单(expect_amount 唯一尾数、expires +15min)→ 返回 <code>{order_no, address, expect_amount, expires_at}</code></li>
<li><b>C.2</b> <code>GET /order/{order_no}</code> → status(pending/paid/expired)。</li>
<li><b>C.3</b> TronGrid watcher 轮询:<code>/v1/accounts/{addr}/transactions/trc20?only_confirmed=true&amp;contract_address=&lt;USDT&gt;</code>,匹配 to+金额(6位小数)+确认 → paid、记 tx_id。</li>
<li><b>C.4</b> 幂等(同 tx 只认一次)+ 超时 expired(地址不复用)+ 崩溃恢复。</li>
<li><b>C.5</b> 限流退避 + 重试;mock TronGrid 测试:匹配/确认/超时/幂等/恢复。</li>
</ul>
<h2>Phase D — 归集(sweep,离线签名)</h2>
<ul class="ck">
<li><b>D.1</b> watcher(xpub)列出有余额地址清单。</li>
<li><b>D.2</b> gas:gas 钱包给待归集地址打少量 TRX(或能量租赁)。</li>
<li><b>D.3</b> 离线机导入<b>钱包 A 助记词</b> → 派生 address_i 私钥 → 构造 TRC20 <code>transfer(sweep_addr_B, balance)</code><b>离线签名</b></li>
<li><b>D.4</b> 广播:签好的交易经 TronGrid <code>/wallet/broadcasttransaction</code> 上链(私钥不出门)。</li>
<li><b>D.5</b> 半自动脚本 + 归集 runbook;阈值/定时触发;热地址不囤大额。</li>
</ul>
<h2>Phase E — 端到端验证(真实小额)</h2>
<ul class="ck">
<li><b>E.1</b> 真单:<code>POST /order</code> → 转 1 USDT(TRC20)→ watcher 确认 → status=paid。</li>
<li><b>E.2</b> 归集:D 流程 → USDT 到钱包 B 冷地址。</li>
<li><b>E.3</b> 安全核对:热服务无私钥、密钥不入 git、地址不复用。</li>
</ul>
<h2>不在本轮</h2>
<ul>
<li>独角数卡门面对接 + epay 支付口(下一块:接门面)。</li>
<li><code>/internal/codes/mint</code> JIT 发码 + 兑换闭环(发货侧,#34 后续)。</li>
<li>HD 自动 sweep(先人工)、多币种、watcher 高可用。</li>
</ul>
<p class="small" style="margin-top:32px">相关:<a href="payment-clean-usdt-plan.html">#34 收款闭环全景</a> · <a href="payment-channels-overview.html">渠道选型</a> · 知识:brain <code>notes/dev/crypto-hd-wallet-basics.html</code></p>
</div>
</body>
</html>
+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>
+396
View File
@@ -0,0 +1,396 @@
<!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>
+103
View File
@@ -0,0 +1,103 @@
<!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;非 pending 返回 false —— 「一单只一个 attempt 成功」的守卫)+ <code>CancelOrder</code>pending→canceledcanceled 单仍在列表可见)。attempt 带自己的 <code>expires_at</code>(单次超时不关 order)。SQLite 内存实库测「建单→查单→标付→再标付幂等」「取消→再取消 false→列表可见」。</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) · expires_at(单次超时,不关 order)
· 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>
+244
View File
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案A · 自建发卡网(独角数卡)落地细化</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}
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
.diagram{overflow-x:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:20px;margin:16px 0}
.diagram svg{display:block;width:100%;height:auto;max-width:840px;margin:0 auto}
.legend{display:flex;gap:20px;flex-wrap:wrap;font-size:13px;color:var(--fg2);margin-top:12px;justify-content:center}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend i{width:22px;height:0;border-top-width:3px;border-top-style:solid;display:inline-block}
.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)}
.okbox{background:rgba(94,194,122,.07);border:1px solid rgba(94,194,122,.32);border-radius:12px;padding:14px 18px;margin:14px 0}
.done{color:var(--ok);font-weight:700}
.todo{color:var(--warn);font-weight:700}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>方案 A · 自建发卡网(独角数卡)落地细化</h1>
<p class="sub">2026-07-08 · <span class="tag info">实现计划</span> · 门面自建 + USDT 收款 + 激活码自动发货 · 基于<b>已就绪</b><code>server/internal/codes</code></p>
<div class="lead">
<b>关键前提(好消息):</b>激活码后端<b>基本已就绪</b>——发卡店回调 <code>POST /webhook/store/codes</code>HMAC + 时间戳 + nonce 防重放)、用户兑换 <code>POST /v1/redeem</code>JWT)、批次生成/导出/作废都在 <code>server/internal/codes</code>。所以方案 A 的工作量集中在<b>门面(独角数卡)+ 一小段发货胶水 + 运维</b><b>不是重写后端</b>
</div>
<h2>已就绪的后端契约(照这个对接,别新造)</h2>
<div class="card">
<h3><span class="done">已实现</span> · 发卡店回调 <code>POST /webhook/store/codes</code></h3>
<p>发卡店卖出一个码后调它,把该码登记为 pangolin 的有效激活码。挂在 <code>/v1</code> 之外、无需 JWT,靠三重校验。</p>
<pre>POST /webhook/store/codes # 无 JWT
Headers:
X-Pangolin-Signature: sha256=&lt;hex&gt; # HMAC-SHA256(raw body, secret)
X-Pangolin-Timestamp: &lt;unix&gt; # ±5 分钟窗口
X-Pangolin-Nonce: &lt;唯一串&gt; # Redis 去重,防重放
Body:
{ "code": "&lt;明文激活码&gt;", # 必须是 pangolin 格式合法码(见下)
"plan": "pro", # free | pro | team
"duration_days": 365,
"note": "dujiaoka #订单号" } # 可选备注
→ 201 {"status":"created"}
→ 200 {"status":"already_exists"} # code_hash 已存在,幂等
→ 200 {"status":"duplicate_ignored"} # nonce 重复,幂等
→ 400 / 401 # 校验失败</pre>
<p class="small">仅存 <b>hash</b>(明文不落库)。secret 走 Bitwarden,配到 <code>server.env</code></p>
</div>
<div class="card">
<h3><span class="done">已实现</span> · 用户兑换 <code>POST /v1/redeem</code></h3>
<pre>POST /v1/redeem (JWT)
Body: { "code": "PGL-XXXX-..." }
→ 200 { "idempotent":false, "plan":"pro", "duration_days":365,
"expires_at":"2027-07-08T00:00:00Z" }
错误码: CODE_NOT_FOUND / CODE_REDEEMED / CODE_VOID / INVALID_CODE / RATE_LIMITED / ACCOUNT_LOCKED</pre>
<p class="small">已含幂等(同用户同码重放返回 idempotent=true)+ 限频 + 账户锁。用户中心/客户端只要有"输码兑换"入口即可。</p>
</div>
<div class="card">
<h3><span class="done">已实现</span> · 批次生成 / 导出 / 作废</h3>
<p><code>server/cmd/codegen</code> 生成一批合法码(Crockford Base32、75-bit、带校验位、可 <code>Canonicalize</code> 纠错),<code>export.go</code> 导出明文,<code>VoidBatch</code> 批量作废未使用码。<b>码格式带校验位——第三方要产出合法码不能乱编</b>(见下模型 B 的待补点)。</p>
</div>
<h2>整体拓扑</h2>
<div class="diagram">
<svg viewBox="0 0 840 360" role="img" aria-label="自建发卡网拓扑">
<defs>
<marker id="a-code" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#5fb0c9"/></marker>
<marker id="a-money" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#e0884f"/></marker>
</defs>
<!-- 用户 -->
<rect x="30" y="150" width="130" height="60" rx="10" fill="#1d2129" stroke="#272c36"/>
<text x="95" y="177" text-anchor="middle" font-size="13" font-weight="700" fill="#e6e8ee">用户</text>
<text x="95" y="195" text-anchor="middle" font-size="11" fill="#a8afbd">浏览器 / TG</text>
<!-- 独角数卡 (独立 VPS) -->
<rect x="240" y="40" width="230" height="130" rx="12" fill="#141922" stroke="#5fb0c9"/>
<text x="355" y="66" text-anchor="middle" font-size="13" font-weight="700" fill="#5fb0c9">独角数卡(独立 VPS</text>
<text x="355" y="87" text-anchor="middle" font-size="11" fill="#a8afbd">门面 · 商品/SKU · 卡密库存</text>
<text x="355" y="104" text-anchor="middle" font-size="11" fill="#a8afbd">USDT 支付插件 → 收款</text>
<text x="355" y="121" text-anchor="middle" font-size="11" fill="#a8afbd">PHP + MySQL + Redis</text>
<text x="355" y="145" text-anchor="middle" font-size="11" fill="#e0b84f">发货 hook → 回调 pangolin</text>
<!-- USDT 钱包 -->
<rect x="240" y="250" width="230" height="60" rx="10" fill="#1d2129" stroke="#5ec27a" stroke-dasharray="4 3"/>
<text x="355" y="277" text-anchor="middle" font-size="12.5" font-weight="700" fill="#e6e8ee">USDT 钱包 / 网关</text>
<text x="355" y="295" text-anchor="middle" font-size="11" fill="#a8afbd">TRC20 · 你 US LLC 自托管</text>
<!-- pangolin -->
<rect x="560" y="40" width="250" height="130" rx="12" fill="#141922" stroke="#e0884f"/>
<text x="685" y="66" text-anchor="middle" font-size="13" font-weight="700" fill="#e0884f">Pangolin 控制面</text>
<text x="685" y="87" text-anchor="middle" font-size="11" fill="#a8afbd">POST /webhook/store/codes</text>
<text x="685" y="104" text-anchor="middle" font-size="11" fill="#a8afbd">codes:登记/作废/批次</text>
<text x="685" y="126" text-anchor="middle" font-size="11" fill="#a8afbd">POST /v1/redeem(兑换)</text>
<text x="685" y="146" text-anchor="middle" font-size="11" fill="#5ec27a">→ 订阅/时长生效</text>
<!-- 客户端/用户中心 -->
<rect x="560" y="250" width="250" height="60" rx="10" fill="#1d2129" stroke="#272c36"/>
<text x="685" y="277" text-anchor="middle" font-size="12.5" font-weight="700" fill="#e6e8ee">客户端 / 用户中心</text>
<text x="685" y="295" text-anchor="middle" font-size="11" fill="#a8afbd">输码兑换 → /v1/redeem</text>
<!-- arrows -->
<line x1="160" y1="170" x2="238" y2="130" stroke="#e0884f" stroke-width="2.2" marker-end="url(#a-money)"/>
<text x="150" y="128" font-size="10.5" fill="#e0884f">①下单付款</text>
<line x1="355" y1="170" x2="355" y2="248" stroke="#e0884f" stroke-width="2.2" marker-end="url(#a-money)"/>
<text x="362" y="215" font-size="10.5" fill="#e0884f">②USDT 收款</text>
<line x1="470" y1="95" x2="558" y2="95" stroke="#5fb0c9" stroke-width="2.2" marker-end="url(#a-code)"/>
<text x="482" y="86" font-size="10.5" fill="#5fb0c9">③发货 hook 登记码</text>
<line x1="160" y1="188" x2="558" y2="285" stroke="#5fb0c9" stroke-width="2.2" marker-end="url(#a-code)"/>
<text x="250" y="245" font-size="10.5" fill="#5fb0c9">④用户拿码 → 输码兑换</text>
<line x1="685" y1="248" x2="685" y2="172" stroke="#5fb0c9" stroke-width="2.2" marker-end="url(#a-code)"/>
</svg>
<div class="legend">
<span><i style="border-color:#e0884f"></i>钱流</span>
<span><i style="border-color:#5fb0c9"></i>码流</span>
<span><i style="border-color:#5ec27a;border-top-style:dashed"></i>自托管资产</span>
</div>
</div>
<h2>部署选址 · 风险隔离</h2>
<div class="warnbox">
<b>独角数卡必须独立部署,别放 pangolin1。</b> 两个理由:① pangolin1 只有 ~1GB 内存,跑控制面+agent+sing-box 已经紧,再塞 PHP+MySQL+Redis 会 OOM;② <b>风险隔离</b>——发卡/收款站点和 VPN 控制面绑一起,一处被盯上会牵连另一处。放<b>独立小 VPS</b>(海外,2C/2G 起),与控制面只经 HTTPS webhook 通信。
</div>
<h2>两种发货模型(映射到已有能力)</h2>
<table>
<thead><tr><th></th><th>模型 A · 预充卡密库存 <span class="tag ok">最快 MVP</span></th><th>模型 B · webhook JIT 登记 <span class="tag info">更安全</span></th></tr></thead>
<tbody>
<tr><td>怎么做</td><td><code>codegen</code> 生成一批 → 导出明文 → 充进独角数卡卡密库 → 发货直接给码</td><td>独角数卡发货后调 <code>/webhook/store/codes</code> 才把码登记为有效</td></tr>
<tr><td>码何时有效</td><td>生成即有效(在 codes 表 status=unused</td><td>付款+发货后才有效</td></tr>
<tr><td>泄漏风险</td><td>独角数卡库被脱 → 码可被无票兑换</td><td>未售出的码无效,脱库也没用 <span class="tag ok"></span></td></tr>
<tr><td>要写的胶水</td><td>几乎零(导入卡密即可)</td><td>发货 hook 签 HMAC 调 webhook + 解决"码由谁生成"</td></tr>
<tr><td>webhook</td><td>不用</td><td><code>/webhook/store/codes</code></td></tr>
</tbody>
</table>
<div class="okbox">
<b>建议:MVP 先用模型 A</b>(零胶水、当天能卖),跑通量后升级模型 B 拿安全性。<br>
<b class="todo">模型 B 的待补点:</b>webhook 要求发卡店发送<b>pangolin 格式合法码(带校验位)</b>。独角数卡自己产不出合法码,两条路二选一:(1) 在独角数卡侧复刻 <code>GenerateCode</code> 生成算法;(2) 给 pangolin 加一个受保护的 <code>POST /internal/codes/mint</code>(HMAC 同款)让发卡网"先领一个合法码再发货登记"。<b>推荐 (2)</b>,格式单源、不重复实现。
</div>
<h2>商品 / SKU 映射</h2>
<p>独角数卡里每个商品 = 一个套餐档,映射到 webhook 的 <code>plan</code> + <code>duration_days</code></p>
<table>
<thead><tr><th>独角数卡商品</th><th>plan</th><th>duration_days</th></tr></thead>
<tbody>
<tr><td>Pro · 月</td><td>pro</td><td>30</td></tr>
<tr><td>Pro · 年</td><td>pro</td><td>365</td></tr>
<tr><td>Team · 月 / 年</td><td>team</td><td>30 / 365</td></tr>
</tbody>
</table>
<p class="small">档位/定价对齐 <code>design/CLAUDE.md §7</code> 与官网 Pricing;SKU 表落进配置,别散在代码里。</p>
<h2>USDT 收款接入</h2>
<ul>
<li><b>路子 1 · 独角数卡 USDT 插件 / 加密网关</b>NOWPayments/Cryptomus 等):最快,插件配好即"支付完成"回调独角数卡触发发货。查 AUP/抽成/是否非托管。</li>
<li><b>路子 2 · 自建 TronGrid watcher</b> 作为独角数卡的自定义支付网关:单地址+唯一金额匹配(MVP)或 HD 每单地址(规模化),到账 N 确认后回调独角数卡。最干净,代码量小。详见 <a href="payment-reseller-fulfillment-design.html">发卡+发货架构</a> 的 USDT 一节。</li>
</ul>
<h2>要建的胶水(很小)</h2>
<ol>
<li><b>发货 hook → pangolin webhook</b>(仅模型 B):独角数卡发货成功后,组 body + 签 <code>X-Pangolin-Signature</code>(HMAC)+时间戳+noncePOST <code>/webhook/store/codes</code>。独角数卡支持"自动发货 API 商品/webhook",写个小中间脚本或插件即可。</li>
<li><b>HMAC secret</b>:生成一把,存 Bitwarden;配 pangolin <code>server.env</code> 与独角数卡侧。</li>
<li><b>(模型 B 推荐)</b><code>POST /internal/codes/mint</code>:受 HMAC 保护,返回一个合法码给发卡网。<span class="tag warn">需新增</span></li>
<li><b>兑换入口自检</b>:确认客户端/用户中心已有"输码兑换 → /v1/redeem"的 UI(后端已就绪)。</li>
</ol>
<h2>落地步骤</h2>
<div class="phase">第 1 步 · 门面起来(半天)</div>
<ul>
<li>独立小 VPS 部署独角数卡(Docker 最省事),配域名+HTTPS。建 Pro/Team 商品与 SKU。</li>
</ul>
<div class="phase">第 2 步 · 收款接上(USDT</div>
<ul>
<li>选路子 1(网关插件)或路子 2(自建 watcher),配到独角数卡,小额实测"付款→自动发货"。</li>
</ul>
<div class="phase">第 3 步 · 发货接 pangolin</div>
<ul>
<li><b>MVP(模型 A</b><code>codegen</code> 生成一批 → 导出 → 充进独角数卡卡密库。<b>此步即可开卖。</b></li>
<li><b>升级(模型 B</b>:加 <code>/internal/codes/mint</code> + 发货 hook 调 webhook,切到"售出才有效"。</li>
</ul>
<div class="phase">第 4 步 · 闭环验证</div>
<ul>
<li>真机走一遍:下单→USDT付款→拿码→客户端/用户中心 <code>/v1/redeem</code>→订阅生效、全端同步。</li>
<li>对账:独角数卡订单 ↔ codes 登记/核销记录,日结核对。</li>
</ul>
<h2>密钥 · 备份 · 风险边界</h2>
<ul>
<li>HMAC secret、USDT 种子/私钥、独角数卡 DB → 全走 Bitwarden,不入 git、不落明文。</li>
<li>独角数卡 DB 定期备份(订单/卡密)。</li>
<li><b>红线</b>:这条链全程不碰跑分/中国支付;USDT 进你自托管钱包,法币结算走 US LLC。支付宝/微信便利若要,另起第三方通道(E 层),当可替换插件,不进本链核心。</li>
</ul>
<h2>待你拍板 / 下一步</h2>
<ul>
<li>门面选 <b>独角数卡</b> 还是同类?独立 VPS 放哪(海外区域/配置)?</li>
<li>USDT 走<b>路子 1(网关)</b>还是<b>路子 2(自建 watcher</b></li>
<li>起步用<b>模型 A(预充)</b>先开卖,确认?后续再上模型 B + <code>/internal/codes/mint</code></li>
<li>确认后我把上面拆成 todo(门面部署 / USDT 接入 / mint 端点 / 发货 hook / 闭环验证)进入落地。</li>
</ul>
<p class="small" style="margin-top:32px">相关:<a href="payment-channels-overview.html">支付渠道选型总览</a> · <a href="payment-reseller-fulfillment-design.html">发卡/Reseller 收款 + 激活码自动发货架构</a></p>
</div>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!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}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>支付渠道选型总览</h1>
<p class="sub">2026-07-08 · <span class="tag info">调研 / 选型</span> · 各支付渠道候选与甄别标准(研究起点,非背书;合规/AUP/风评自行核实)</p>
<div class="lead">
<b>先破一个概念:</b>「发卡平台」不是一个东西,而是<b>两个可分开的决策</b>——① <b>门面</b>storefront / 发卡网:展示、下单、自动发卡密);② <b>收款通道</b>(channel:实际把钱收进来的管子)。<b>灰 / 干净的分水岭在通道,不在门面。</b>把两者绑在一个第三方身上,是被套牢和被跑路的根源。
</div>
<h2>决策框架:门面 × 通道</h2>
<table>
<thead><tr><th></th><th>选择</th><th>建议</th></tr></thead>
<tbody>
<tr><td><b>门面</b></td><td>自建发卡网 / 上第三方发卡市场</td><td class="clean">自建(你拥有门面+数据+发货节奏,只外包"收款"一环)</td></tr>
<tr><td><b>通道</b></td><td>USDT 自收 / 加密网关 / 官方鹅 / 支付宝微信第三方</td><td>干净优先;支付宝/微信第三方=灰产层,最后再叠加、且当可替换插件</td></tr>
</tbody>
</table>
<h2>候选清单(AE</h2>
<div class="card">
<h3>A · 自建发卡网(门面 · 开源自托管)<span class="tag ok">推荐起步</span></h3>
<p>你完全拥有门面 + 自动发卡逻辑(卡密库存 / API 发货),对接 pangolin <code>codes</code> 模块。</p>
<ul>
<li><b>独角数卡 dujiaoka</b>GitHub <code>assimon/dujiaoka</code>)— PHP,最主流,卡密库存 + 多支付插件。</li>
<li><b>acg-faka</b>(异次元 / 彩虹发卡)— PHP,插件多。</li>
<li><b>KamiFaka</b> — Vue + PHP,轻量。</li>
</ul>
<p class="small">看点:卡密/API 发货是否好接 codes、支付插件生态、维护活跃度。→ <a href="payment-a-selfhosted-store-plan.html">已细化为落地方案(方案 A</a></p>
</div>
<div class="card">
<h3>B · 加密支付网关(让 USDT 收款像普通支付,API+webhook<span class="tag info">懒人版 USDT</span></h3>
<ul>
<li><b>NOWPayments</b> — 非托管、币种多、API/webhook 齐。</li>
<li><b>Cryptomus</b> — TRC20 友好、商户后台。</li>
<li><b>CoinGate</b> — 欧洲、合规相对正。</li>
<li><b>Plisio / 0xProcessing</b> — 同类可比。</li>
</ul>
<p class="small">看点:①是否<b>非托管</b>(直接进你钱包)②抽成 ③<b>AUP 是否禁 VPN</b>(逐家查)④是否强制 KYC/公司资料 ⑤TRC20 支持。</p>
</div>
<div class="card">
<h3>C · 自建 USDT 监听(DIY,最干净)<span class="tag ok">干净</span></h3>
<ul>
<li><b>TronGrid</b>(TRON 官方 API,免费额度)— 轮询到账。</li>
<li>节点服务:<b>GetBlock / QuickNode</b>,或自建 TRON 节点。</li>
<li>GitHub 搜 <code>tron usdt payment</code> / <code>trc20 watcher</code> 有大量参考实现。</li>
</ul>
<p class="small">看点:确认数、TRX gas 归集、种子/私钥托管。见 <a href="payment-reseller-fulfillment-design.html">发卡+发货架构</a> 的“USDT 自动收款”一节。</p>
</div>
<div class="card">
<h3>D · 官方鹅(合规补充)<span class="tag ok">干净</span></h3>
<ul>
<li><b>Telegram Stars</b> — TG 官方,bot 内收款(走 Apple/Google IAP,抽成)。</li>
<li><b>Apple / Google IAP</b> — 海外区。</li>
</ul>
</div>
<div class="card">
<h3>E · 支付宝/微信 第三方通道(灰产层)<span class="tag bad">不列名单</span></h3>
<p>跑分 / 四方聚合。<b>不给候选名单</b>——合法性与跑路风险无法背书。自研路子:</p>
<ul>
<li>站长圈打听历史:<code>hostloc</code>、LowEndTalk 中文区、相关 TG 发卡/站长群,看<b>实际跑路/冻卡口碑</b></li>
<li>一律<b>小额实测提现</b>跑通再放量。</li>
<li>做成<b>可热插拔多通道</b>,账上不留余额。</li>
</ul>
</div>
<h2>决策矩阵</h2>
<table>
<thead><tr><th>渠道</th><th>干净度</th><th>接入难度</th><th>抽成</th><th>主要风险</th><th>适合阶段</th></tr></thead>
<tbody>
<tr><td>A 自建发卡网</td><td class="clean">门面干净</td><td>低(部署即用)</td><td>—(只付通道费)</td><td>看你插什么通道</td><td>起步基座</td></tr>
<tr><td>B 加密网关</td><td class="clean">干净</td><td>低(API</td><td>~0.5%1.5%</td><td>AUP 可能禁 VPN、KYC</td><td>快速上 USDT</td></tr>
<tr><td>C 自建 USDT 监听</td><td class="clean">最干净</td><td>中(写 watcher</td><td>~0(仅链上手续费)</td><td>种子安全、gas 归集</td><td>长期主力</td></tr>
<tr><td>D 官方鹅</td><td class="clean">干净</td><td></td><td>15%30%</td><td>抽成高、中国区不可用</td><td>海外补充</td></tr>
<tr><td>E 支付宝/微信第三方</td><td class="grey">灰(跑分)</td><td></td><td>8%20%</td><td>冻卡、跑路、周期性死</td><td>可选便利、可替换</td></tr>
</tbody>
</table>
<h2>尽调清单(仅当接 E · 支付宝/微信第三方通道)</h2>
<ul>
<li><b>结算周期</b>:优先 T+0 / T+1,压你钱三五天的直接 pass。</li>
<li><b>保证金方向</b>:它押给你?还是你押给它?<b>你押钱=高危</b></li>
<li><b>口碑与跑路史</b>:先在站长圈/TG 群打听历史,有跑路记录排除。</li>
<li><b>小额实测</b>:先小金额跑通提现,确认到账再放量。</li>
<li><b>通道稳定性 + 热切换</b>:注定周期性死;同时接 2–3 家,一家挂立刻切。</li>
<li><b>费率</b>:别只看费率——压钱/爱跑路的更坑。</li>
</ul>
<div class="warnbox">
<b>红线:</b>Pangolin 主体(尤其中国岩美)永不直接对接跑分/四方聚合、永不用中国支付账户收 VPN 款。E 层的冻卡/跑路风险只允许存在于第三方通道那一层;到你手里的必须是它们的<b>结算款</b>(法币/USDT,走 US LLC)。
</div>
<h2>推荐研究顺序 + 起步组合</h2>
<ol>
<li>先啃 <b>A(独角数卡)+ C(TronGrid 自建监听)</b>——这条让你干净地跑起来,"付完自动发码"体验和灰产 bot 一致。</li>
<li><b>B(加密网关)</b>作为"懒人版 USDT"备选(省掉写 watcher,但查 AUP/抽成/是否非托管)。</li>
<li><b>D</b> 作海外补充;<b>E</b> 最后再碰、只当可替换插件。</li>
</ol>
<p><b>建议起步组合:独角数卡(门面)+ USDT(C 自建监听 或 B 网关)。</b> 已细化 → <a href="payment-a-selfhosted-store-plan.html">方案 A · 自建发卡网落地细化</a></p>
<h2>相关文档</h2>
<ul>
<li><a href="payment-reseller-fulfillment-design.html">支付落地方案 · 发卡/Reseller 收款 + 激活码自动发货</a>(总架构:钱流/码流/结算 + 两种对接模型)</li>
<li><a href="payment-a-selfhosted-store-plan.html">方案 A · 自建发卡网(独角数卡)落地细化</a>(基于已就绪的 codes webhook/redeem</li>
</ul>
</div>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收款闭环 · 独角数卡 + 自托管 USDT + webhook JIT 实现计划</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:29px;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:.85em;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.5;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:6px 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}
.phase{font-size:18px;margin:38px 0 6px;color:var(--accent);font-weight:700}
.ck{list-style:none;padding-left:4px}
.ck li{position:relative;padding-left:26px}
.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
.diagram{overflow-x:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;margin:16px 0}
.diagram pre{background:transparent;border:none;padding:0;color:#cdd3df;font-size:12px}
.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)}
.okbox{background:rgba(94,194,122,.07);border:1px solid rgba(94,194,122,.32);border-radius:12px;padding:14px 18px;margin:14px 0}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>收款闭环 · 独角数卡 + 自托管 USDT(TRC20) + webhook JIT 发码</h1>
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-payment-clean-usdt-loop.md</code>(带 checkbox)· #34tier-1</p>
<div class="lead">
<b>目标:</b>把「下单 → USDT 付款 → 自动发激活码 → 客户端/用户中心兑换 → 订阅生效」端到端打通,<b>全程自托管、售出才发码、无跑分、无第三方支付主体</b><br>
<b>关键前提(已摸底):</b>激活码后端大半已就绪——<code>POST /v1/redeem</code>(兑换,幂等+限频)、批次生成/导出、发卡店回调 <code>/webhook/store/codes</code>HMAC+时间戳+nonce)都在。本方案<b>新增核心只有一个后端端点</b>JIT 发码 <code>/internal/codes/mint</code>+ 一个独立收款服务 <code>pangolin-pay</code>TronGrid watcher + epay 支付口)。
</div>
<h2>三大组件 / 数据流</h2>
<div class="diagram">
<pre>买家 ──下单──▶ 独角数卡(门面, 独立 VPS)
│ 建单 + epay 跳转
pangolin-pay(自建收款服务, 同 VPS)
├ 派生每单唯一 TRC20 收款地址 (watch-only, 只持 xpub)
├ TronGrid watcher 轮询到账 + 确认
└ 到账 ──epay notify──▶ 独角数卡「已支付」
▼ 独角数卡 API-提货
pangolin-pay /issue ──HMAC──▶ pangolin 控制面 POST /internal/codes/mint
└ 生成合法码 + 存 unused + 返回明文
▼ 独角数卡把码交付买家
买家 ──输码──▶ 客户端/用户中心 POST /v1/redeem ──▶ 订阅生效</pre>
</div>
<div class="warnbox">
<b>安全红线(贯穿全程):</b>① 热服务 <code>pangolin-pay</code> <b>只持 xpub 派生收款地址、绝不持私钥</b>(能收不能转,脱库也转不走钱);② 私钥/助记词冷备 + Bitwarden,不入 git、不上热机;③ 收款终点=自托管钱包,变现经交易所出金 US LLC,记账走 accounting;④ 全程不碰跑分/中国支付。<b>部署在独立海外小 VPS,不放 pangolin1</b>(内存紧 + 风险隔离),只经 HTTPS+HMAC 与控制面 <code>api.yanmeiai.com</code> 通信。
</div>
<h2>已就绪 vs 本轮新增</h2>
<table>
<thead><tr><th>能力</th><th>状态</th></tr></thead>
<tbody>
<tr><td><code>POST /v1/redeem</code> 兑换(幂等+限频+账户锁)</td><td><span class="tag ok">已实现</span></td></tr>
<tr><td>批次生成/导出(Crockford Base32 75-bit 带校验位,只存 hash</td><td><span class="tag ok">已实现</span></td></tr>
<tr><td>发卡店回调 <code>/webhook/store/codes</code>HMAC+时间戳+nonce</td><td><span class="tag ok">已实现</span>(留给未来第三方发卡店)</td></tr>
<tr><td><code>POST /internal/codes/mint</code> JIT 发码</td><td><span class="tag warn">本轮新增</span></td></tr>
<tr><td><code>pangolin-pay</code>TronGrid watcher + epay 支付口</td><td><span class="tag warn">本轮新增</span></td></tr>
<tr><td>自托管 TRC20 HD 钱包 + 归集/变现 runbook</td><td><span class="tag warn">本轮新增</span></td></tr>
</tbody>
</table>
<h2>Phase 0.5 — 钱包选型(用哪个钱包)</h2>
<p><b>先厘清:这套架构的「钱包」是两件事,别当成装一个 app。</b></p>
<ul>
<li><b>主种子(钱在哪)</b>:一套 BIP39 助记词,冷存。资产本体。</li>
<li><b>watcher 收款</b>:热服务用<b></b>从 account <b>xpub(公钥)</b>派生每单地址——<b>watch-only,不是钱包 app</b>,拿不到私钥。</li>
<li><b>归集签名</b>:动钱时在冷端用私钥离线签 sweep 交易。</li>
</ul>
<p>所以「用哪个钱包」= 选 ① 主种子怎么冷存 + ② watcher 用哪个派生库。</p>
<h3>① 主种子 / 冷存 候选</h3>
<table>
<thead><tr><th>方案</th><th>冷存安全</th><th>xpub 导出</th><th>归集签名</th><th>结论</th></tr></thead>
<tbody>
<tr><td><b>离线生成助记词 + 库派生</b> <span class="tag ok">推荐</span></td><td>高(纸/钢板离线,永不联网)</td><td>从种子导 account xpub</td><td>离线机用库签</td><td>最贴合 watch-only 架构、最灵活</td></tr>
<tr><td>Ledger 硬件钱包(可叠加)</td><td>最高(私钥不出设备)</td><td>可导 account 公钥(需工具)</td><td>插 Ledger 签</td><td>想再加一层硬件冷存就用它兜底</td></tr>
<tr><td>TronLink / imToken / Trust(手机热钱包)</td><td>低(热钱包)</td><td>不便批量派生</td><td>app 内签</td><td><span class="tag bad">不推荐</span>当收款主钱包</td></tr>
</tbody>
</table>
<h3>② watcher 派生库 候选(给热服务 watch-only 派生,非钱包)</h3>
<table>
<thead><tr><th>语言</th><th></th></tr></thead>
<tbody>
<tr><td>Go(与 server 同栈,推荐)</td><td><code>tyler-smith/go-bip39</code> + <code>btcd/btcutil/hdkeychain</code>BIP32)→ 公钥转 TRON base58 地址;或成熟 TRON Go SDK</td></tr>
<tr><td>Python</td><td><code>tronpy</code> + <code>bip_utils</code>(一步派生 TRON 地址)</td></tr>
</tbody>
</table>
<div class="okbox">
<b>推荐:</b>主种子用<b>离线生成 BIP39 助记词</b>(想再稳叠一层 <b>Ledger 冷存</b>);watcher 用 <b>Go 库从 account xpub watch-only 派生</b>(与 server 同栈、好集成、热机无私钥)。<b>收款钱包 = 你自己的 HD 种子,不是某个 app。</b>
</div>
<ul class="ck">
<li><b>0.5.1</b> 定主种子方案:离线助记词(推荐)/ 是否叠加 Ledger 冷存;记录选型。</li>
<li><b>0.5.2</b> 定 watcher 派生库(Go bip39+hdkeychain→TRON / Python tronpy+bip_utils),跑通派生并与钱包 app 交叉核对同一地址(防派生不一致收不到钱)。</li>
<li><b>0.5.3</b> 定归集冷签工具(离线机脚本 / Ledger),确认能对 sweep 交易离线签名。</li>
</ul>
<h2>Phase 1 — 自托管钱包(TRC20 HDwatch-only 收款)</h2>
<p>一套助记词派生无限个收款地址;热服务只拿 xpub 派生地址收款、拿不到私钥;私钥冷备用于归集。</p>
<ul class="ck">
<li><b>1.1</b> <b>离线</b>生成 BIP39 助记词(24 词),TRON 派生路径 <code>m/44'/195'/0'/0/i</code>195=TRON)。工具:离线机 tronpy/tronweb 或硬件钱包(Ledger 导出 xpub),<b>生成不联网</b></li>
<li><b>1.2</b> 密钥分级:助记词/私钥→冷备+Bitwarden(永不上热机);account xpub→给 pangolin-pay watch-only 派生;HMAC secret→Bitwarden。</li>
<li><b>1.3</b> 定冷钱包归集地址 + 记 USDT-TRC20 合约地址(主网 <code>TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t</code>,核对无误再写死)。</li>
<li><b>1.4</b> 备少量 <b>TRX</b> 作归集 gasTRON 转账吃 energy/bandwidth)。</li>
<li><b>1.5</b> 验证:xpub 离线派生前 5 地址;往 index 0 打 1 USDT 测试,确认 watch-only 查得到。</li>
</ul>
<h2>Phase 2 — pangolin-payTronGrid watcher(自建收款侦测)</h2>
<ul class="ck">
<li><b>2.1</b> 建 Go 服务 <code>pangolin-pay</code>(自带轻量 SQLite)。</li>
<li><b>2.2</b> 数据模型:<code>pay_orders(order_no, sku, plan, duration_days, addr_index, address, expect_amount, status, tx_id, confirmations, created_at, expires_at)</code> + <code>addr_cursor(next_index)</code></li>
<li><b>2.3</b> <b>每单唯一地址</b>:建单时从 xpub 派生 <code>m/.../0/next_index</code>(最准,免金额撞车)。次选单地址+唯一金额。</li>
<li><b>2.4</b> TronGrid 轮询:<code>GET /v1/accounts/{addr}/transactions/trc20?only_confirmed=true&amp;contract_address=&lt;USDT&gt;</code>,比对 to/token/金额(含容差)。</li>
<li><b>2.5</b> 确认:只认已固化交易或等 ≥N 区块(约 1 分钟)→ <code>status=paid</code> + 记 tx_id。</li>
<li><b>2.6</b> 到账触发发货;<b>幂等</b>:同 tx_id 只认一次、一单只发一次。</li>
<li><b>2.7</b> 超时(默认 15min)→ <code>expired</code>、释放地址(不复用,避免串单)。</li>
<li><b>2.8</b> TronGrid key + 限流退避 + 重试;崩溃可从 DB 未决单恢复。</li>
<li><b>2.9</b> 测试:mock TronGrid → 金额匹配/确认门槛/超时/幂等/恢复。</li>
</ul>
<h2>Phase 3 — 控制面:JIT 发码端点 <code>POST /internal/codes/mint</code></h2>
<p>付款确认时<b>即时生成一个合法码</b>——这才是 webhook JIT 的落点:售出才产码、才有效。自建场景由 pangolin 产码(保证格式+校验位单源),比让外部产码更干净。</p>
<pre>POST /internal/codes/mint # /internal 组, 无 JWT, HMAC 同 webhook
Headers: X-Pangolin-Signature: sha256=&lt;hmac&gt; · X-Pangolin-Timestamp(±5min) · X-Pangolin-Nonce
Body: { "plan":"pro", "duration_days":365, "order_ref":"&lt;独角数卡订单号&gt;" }
→ 200 { "code":"PGL-XXXX-...", "expires_at":"..." } # 以 order_ref 幂等, 同单返同码</pre>
<ul class="ck">
<li><b>3.1</b> 新增端点,HMAC+时间戳+nonce(复用 webhook 那套验签,抽公共中间件)。</li>
<li><b>3.2</b> 复用 <code>codes.GenerateCode()</code> 产码 + <code>Store.CreateCode</code> 存 unusedchannel=ChannelStore)→ 返回明文。</li>
<li><b>3.3</b> <b>幂等</b><code>order_ref</code> 为幂等键(存 order_ref→code_hash),同单重放返回同一码。</li>
<li><b>3.4</b> secret → Bitwarden → <code>server.env</code><code>CODES_MINT_HMAC_SECRET</code>);<code>main.go</code> 挂路由。</li>
<li><b>3.5</b> 保留现有 <code>/webhook/store/codes</code> 供未来第三方发卡店。</li>
<li><b>3.6</b> 测试:验签/时间戳/nonce/order_ref 幂等/产码格式。</li>
</ul>
<h2>Phase 4 — 独角数卡对接(门面 + 自动发货)</h2>
<ul class="ck">
<li><b>4.1</b>(研究·先做)摸清所用独角数卡版本的 <b>epay 支付接口</b> + <b>API-提货/自动发货商品</b>机制(版本相关,实测出对接点清单再实现)。</li>
<li><b>4.2</b> pangolin-pay 实现 <b>epay 兼容支付接口</b>:独角数卡建单 → 跳我方支付页(USDT 地址+应付金额+倒计时+TRC20 强提示)。</li>
<li><b>4.3</b> 到账 → 按 epay 规范 <b>notify 独角数卡</b>(签名)→ 标「已支付」。</li>
<li><b>4.4</b> 商品设 <b>API-提货</b>:发货时调 pangolin-pay <code>/issue</code> → 内部 HMAC 调 <code>/internal/codes/mint</code> 取码 → 交付买家。</li>
<li><b>4.5</b> <b>SKU 映射表</b>(配置化):独角数卡商品 → plan+duration_daysPro 月/年、Team 月/年;对齐官网 Pricing)。</li>
<li><b>4.6</b> 幂等贯通:独角数卡 order_no 作为 order_ref 一路透传到 mint,一单一码。</li>
</ul>
<h2>Phase 5 — 部署 · 运维 · 安全</h2>
<ul class="ck">
<li><b>5.1</b> 独立海外小 VPS(2C/2G)部署:独角数卡(Docker+ pangolin-pay + 域名 + HTTPS<b>不放 pangolin1</b>)。</li>
<li><b>5.2</b> 密钥落位核对:pangolin-pay 只有 xpub + TronGrid key + epay 签名密钥 + mint HMAC secret<b>无私钥/助记词</b>(冷备)。全 Bitwarden。</li>
<li><b>5.3</b> <b>归集 runbook</b>:定期把各收款地址 USDT sweep 到冷钱包(<b>离线签名</b>),备 TRX gas。</li>
<li><b>5.4</b> <b>变现 runbook</b>:冷钱包 → 交易所(能出金 US LLC 的)→ 卖 USDT→USD → 银行;记账交 accounting agent。</li>
<li><b>5.5</b> 监控告警(接现有 Telegram):watcher 存活、TronGrid 配额、到账延迟、mint 失败、订单堆积。</li>
<li><b>5.6</b> <b>对账</b>:独角数卡订单 ↔ pay_orders ↔ codesmint/redeem)三方日结,差异告警。</li>
</ul>
<h2>Phase 6 — 端到端验证</h2>
<ul class="ck">
<li><b>6.1</b> 真单闭环:下单→USDT付款→watcher确认→mint码→独角数卡交付→<code>/v1/redeem</code>→订阅生效、全端同步。</li>
<li><b>6.2</b> 异常路径:超时未付/金额不足/重复付/确认期抖动/mint 重放。</li>
<li><b>6.3</b> 安全核对:热服务无私钥、密钥不入 git、每单唯一地址不复用、redeem 幂等+限频。</li>
</ul>
<div class="okbox">
<b>验收判据:</b>后端 <code>go test ./internal/codes/...</code> 含 mint 验签/幂等/格式全绿;pangolin-pay <code>go test</code> 含匹配/确认/超时/幂等/恢复;真机一笔真 USDT 小额单全通 + 异常逐条演练;安全逐项核对。
</div>
<h2>不在本轮</h2>
<ul>
<li>支付宝/微信第三方通道(E 层,灰产,另议且当可替换插件)。</li>
<li>HD 自动 sweep 归集(先人工离线归集)。</li>
<li>多币种、watcher 高可用、加密网关备选路子(B 方案)。</li>
<li>Telegram Stars / Apple IAP 独立收款口(海外补充,另排)。</li>
</ul>
<p class="small" style="margin-top:32px">相关:<a href="payment-channels-overview.html">支付渠道选型总览</a> · <a href="payment-a-selfhosted-store-plan.html">方案A 自建发卡网细化</a> · <a href="payment-reseller-fulfillment-design.html">发卡+发货架构</a></p>
</div>
</body>
</html>
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 支付落地方案 · 发卡/Reseller 收款 + 激活码自动发货</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:14px}
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}
.phase{font-size:19px;margin:40px 0 6px;color:var(--accent);font-weight:700}
.diagram{overflow-x:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:20px;margin:16px 0}
.diagram svg{display:block;width:100%;height:auto;max-width:820px;margin:0 auto}
.legend{display:flex;gap:20px;flex-wrap:wrap;font-size:13px;color:var(--fg2);margin-top:12px;justify-content:center}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend i{width:22px;height:0;border-top-width:3px;border-top-style:solid;display:inline-block}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:14px 0}
@media(max-width:680px){.grid2{grid-template-columns:1fr}}
.rec{border-left:3px solid var(--ok);padding-left:12px}
.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)}
.num{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--accent);color:#0f1117;font-weight:700;font-size:13px;margin-right:8px;flex-shrink:0}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>Pangolin 支付落地方案</h1>
<p class="sub">2026-07-08 · <span class="tag info">设计方案</span> · 发卡 / Reseller 收款 + 激活码自动发货(“付完秒发货”体验,收款风险不落在你的主体上)</p>
<div class="lead">
<b>一句话:</b>把「收钱」和「你的品牌 VPN」彻底解耦——<b>收款外包给发卡平台 / Reseller</b>(他们承担支付宝/微信跑分、冻卡、跑路风险),你只做一件事:<b>把一段「激活码」交付出去</b>。所有渠道(发卡法币、USDT、Telegram Stars)进来的钱最后都汇成同一种“货币”——激活码;客户端与用户中心<b>只认码</b>。你们的 <code>codes</code> 激活码模块已实现,支付层只是「入账 → 发码 → 核销」的适配器。
</div>
<h2>为什么是这个形状(约束)</h2>
<p>上一轮已经确认的硬约束,直接决定方案形状:</p>
<ul>
<li><b>中国主体 / 中国支付不能碰</b>:支付宝 / 微信 / 银联 + 岩美(北京)主体卖 VPN → 冻结商户号 + 法律风险。<span class="tag bad">红线</span></li>
<li><b>灰产 bot 的做法 = 跑分</b>:那些“资源入群助手”用的是<b>四方聚合支付 + 个人账户跑分</b>(帮信罪 / 冻卡 / 平台跑路)。你自己的主体<b>绝不直接接跑分</b></li>
<li><b>能保留“支付宝/微信便利”的唯一干净姿势</b>:把这一环<b>外包</b>——发卡平台/Reseller 用他们的通道收人民币,风险在他们;你拿结算 + 发激活码。或主力走 <b>USDT(你自己钱包,链上干净)</b> + <b>Telegram StarsTG 官方)</b></li>
</ul>
<h2>整体流程图</h2>
<p>一笔订单从下单到订阅生效的完整流转。<b>橙色 = 钱流,青色 = 码流,绿色虚线 = 结算/对账</b>。收款与跑分风险全部圈在「发卡平台 / Reseller」内,不进入 Pangolin 主体。</p>
<div class="diagram">
<svg viewBox="0 0 820 610" role="img" aria-label="发卡/Reseller 收款 + 激活码自动发货流程图">
<defs>
<marker id="ah-money" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#e0884f"/></marker>
<marker id="ah-code" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#5fb0c9"/></marker>
<marker id="ah-settle" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 z" fill="#5ec27a"/></marker>
</defs>
<!-- boxes: main column x=70..560 (w=490) -->
<!-- ① 用户 -->
<rect x="70" y="24" width="490" height="50" rx="10" fill="#1d2129" stroke="#272c36"/>
<circle cx="96" cy="49" r="12" fill="#e0884f"/><text x="96" y="53" text-anchor="middle" font-size="13" font-weight="700" fill="#0f1117">1</text>
<text x="122" y="46" font-size="14" font-weight="700" fill="#e6e8ee">用户 · 在下单页 / 发卡平台选套餐</text>
<text x="122" y="63" font-size="12" fill="#a8afbd">Pro 月/年、Team……(对应你的定价档)</text>
<!-- ② 发卡平台收款 (risk) -->
<rect x="70" y="118" width="490" height="70" rx="10" fill="#1d2129" stroke="#e06a6a" stroke-width="1.5"/>
<circle cx="96" cy="143" r="12" fill="#e0884f"/><text x="96" y="147" text-anchor="middle" font-size="13" font-weight="700" fill="#0f1117">2</text>
<text x="122" y="140" font-size="14" font-weight="700" fill="#e6e8ee">发卡平台 / Reseller · 收款</text>
<text x="122" y="158" font-size="12" fill="#a8afbd">支付宝/微信(跑分)· USDT · 国际代付 —— 通道与</text>
<text x="122" y="174" font-size="12" fill="#e06a6a">冻卡/跑路风险都在这一层,不进入 Pangolin 主体</text>
<!-- ③ 取激活码 -->
<rect x="70" y="232" width="490" height="70" rx="10" fill="#1d2129" stroke="#272c36"/>
<circle cx="96" cy="257" r="12" fill="#e0884f"/><text x="96" y="261" text-anchor="middle" font-size="13" font-weight="700" fill="#0f1117">3</text>
<text x="122" y="254" font-size="14" font-weight="700" fill="#e6e8ee">取一个激活码</text>
<text x="122" y="272" font-size="12" fill="#a8afbd">A · 从你预充的「卡密库存」取一张(最松耦合)</text>
<text x="122" y="288" font-size="12" fill="#a8afbd">B · 支付成功 webhook 调你 API → 实时签发一张</text>
<!-- ④ 秒发码 -->
<rect x="70" y="346" width="490" height="50" rx="10" fill="#1d2129" stroke="#272c36"/>
<circle cx="96" cy="371" r="12" fill="#5fb0c9"/><text x="96" y="375" text-anchor="middle" font-size="13" font-weight="700" fill="#0f1117">4</text>
<text x="122" y="368" font-size="14" font-weight="700" fill="#e6e8ee">发卡平台把激活码「秒发」给用户</text>
<text x="122" y="385" font-size="12" fill="#a8afbd">这就是灰产 bot 的“付完自动发货”体验</text>
<!-- ⑤ 兑换 + 核销 -->
<rect x="70" y="440" width="490" height="70" rx="10" fill="#1d2129" stroke="#5fb0c9" stroke-width="1.5"/>
<circle cx="96" cy="465" r="12" fill="#5fb0c9"/><text x="96" y="469" text-anchor="middle" font-size="13" font-weight="700" fill="#0f1117">5</text>
<text x="122" y="462" font-size="14" font-weight="700" fill="#e6e8ee">用户在 Pangolin 客户端/用户中心输码兑换</text>
<text x="122" y="480" font-size="12" fill="#a8afbd">→ 后端 codes 模块核销(一次性、幂等、防重放)</text>
<text x="122" y="496" font-size="12" fill="#5ec27a">→ 订阅/时长生效,全端同步</text>
<!-- 结算 side box -->
<rect x="612" y="118" width="180" height="70" rx="10" fill="#141922" stroke="#5ec27a" stroke-dasharray="4 3"/>
<text x="702" y="143" text-anchor="middle" font-size="13" font-weight="700" fill="#e6e8ee">你 · US LLC</text>
<text x="702" y="161" text-anchor="middle" font-size="11.5" fill="#a8afbd">定期结算(扣佣)</text>
<text x="702" y="177" text-anchor="middle" font-size="11.5" fill="#a8afbd">法币 / USDT 到账</text>
<!-- arrows down -->
<line x1="315" y1="74" x2="315" y2="116" stroke="#e0884f" stroke-width="2.5" marker-end="url(#ah-money)"/>
<text x="325" y="99" font-size="11.5" fill="#e0884f">付款 ¥ / USDT</text>
<line x1="315" y1="188" x2="315" y2="230" stroke="#e0884f" stroke-width="2.5" marker-end="url(#ah-money)"/>
<text x="325" y="213" font-size="11.5" fill="#e0884f">订单支付成功</text>
<line x1="315" y1="302" x2="315" y2="344" stroke="#5fb0c9" stroke-width="2.5" marker-end="url(#ah-code)"/>
<text x="325" y="327" font-size="11.5" fill="#5fb0c9">交付激活码</text>
<line x1="315" y1="396" x2="315" y2="438" stroke="#5fb0c9" stroke-width="2.5" marker-end="url(#ah-code)"/>
<text x="325" y="421" font-size="11.5" fill="#5fb0c9">用户输码</text>
<!-- settle dashed arrow from ② to settlement box -->
<line x1="560" y1="153" x2="610" y2="153" stroke="#5ec27a" stroke-width="2" stroke-dasharray="4 3" marker-end="url(#ah-settle)"/>
</svg>
<div class="legend">
<span><i style="border-color:#e0884f"></i>钱流</span>
<span><i style="border-color:#5fb0c9"></i>码流(激活码)</span>
<span><i style="border-color:#5ec27a;border-top-style:dashed"></i>结算 / 对账</span>
<span><i style="border-color:#e06a6a"></i>风险边界(发卡层内)</span>
</div>
</div>
<h2>逐环节详解</h2>
<div class="card">
<h3><span class="num">1</span>用户下单</h3>
<p>入口有三种形态(见后文“落地节奏”):① 你的 Telegram bot 菜单;② 用户中心内“获取激活码”下单页;③ 第三方发卡平台的商品页。用户选套餐档位(Pro 月/年、Team),点支付。</p>
</div>
<div class="card">
<h3><span class="num">2</span>发卡平台收款 <span class="tag bad">风险都在这层</span></h3>
<p>发卡平台/Reseller 用<b>他们自己的通道</b>收人民币(支付宝/微信走跑分)、USDT、国际代付。<b>关键:收款主体、收款码、跑分账户、冻卡与跑路风险,全部是发卡平台的,不是你的。</b>你和 Pangolin 主体永远不出现在这条法币收款链上。你付出的代价是<b>佣金 / 折扣</b>(业界常见 8%–20%,视通道与结算周期)。</p>
</div>
<div class="card">
<h3><span class="num">3</span>取激活码(两种对接模型)</h3>
<p>这是唯一需要你出工程的地方,二选一(下一节详述):</p>
<ul>
<li><b>A · 预充卡密库存</b>:你批量生成激活码 → 充值进发卡平台的“卡密库存” → 平台卖一张发一张。你系统<b>零对接</b>,只需生成码 + 补货。</li>
<li><b>B · API 实时签发</b>:用户支付成功 → 发卡平台 <code>webhook</code> 通知你后端 → 你实时签发一张激活码返回 → 平台转发。库存不积压、可控性强,需对接一个回调接口。</li>
</ul>
</div>
<div class="card">
<h3><span class="num">4</span>秒发货</h3>
<p>发卡平台把激活码即时发给用户(页面展示 / bot 消息 / 邮件)。用户体验和那些灰产 bot 的“付完自动发”<b>完全一致</b>——差别只在风险归属。</p>
</div>
<div class="card">
<h3><span class="num">5</span>兑换 + 核销</h3>
<p>用户在 Pangolin 客户端或用户中心输入激活码 → 后端 <code>codes</code> 模块<b>核销</b>:校验有效性、一次性消费(幂等 + 防重放 + 并发锁)、把对应套餐时长写进账户 → 订阅生效、全端同步。这一步你们<b>已经实现</b>,是整套方案的“落地点”。</p>
</div>
<h2>两种对接模型对比</h2>
<table>
<thead><tr><th>维度</th><th>A · 预充卡密库存 <span class="tag ok">MVP 首选</span></th><th>B · API 实时签发</th></tr></thead>
<tbody>
<tr><td>你要出的工程</td><td>几乎为零:批量生成激活码导出即可</td><td>一个签发回调接口 + 验签 + 幂等</td></tr>
<tr><td>库存管理</td><td>要盯库存、及时补货(卖光即断供)</td><td>无库存概念,按需签发</td></tr>
<tr><td>可控性 / 风控</td><td>码一旦充进平台就“出手”了,作废要靠平台配合</td><td>你实时决定发不发、发什么档、可即时止血</td></tr>
<tr><td>对账</td><td>按“充进多少 / 平台报售出多少”对</td><td>按你签发条数对,最准</td></tr>
<tr><td>换平台成本</td><td>低(码是通用的,换平台重充即可)</td><td>中(每个平台对接一次回调)</td></tr>
</tbody>
</table>
<p class="rec"><b>建议:</b>起步用 <b>A(预充库存)</b>——零工程、当天能卖。跑通量之后,对主力发卡平台升级到 <b>BAPI 实时签发)</b>拿回控制权与精准对账;两者可并存(不同渠道用不同模型)。</p>
<h2>落地节奏(先能卖,再自动化)</h2>
<div class="phase">阶段 0 · MVP(当天可开卖,零/极少开发)</div>
<ul>
<li>你们 <code>codes</code> 模块已可批量生成激活码 → 手动生成一批 → 挂到 <b>1 个发卡平台</b>(预充库存模型 A)或直接 Telegram 人工收 USDT/发卡平台链接。</li>
<li>用户付款 → 平台秒发码 → 用户在客户端兑换。<b>验证需求、跑通话术与定价</b></li>
</ul>
<div class="phase">阶段 1 · 半自动(API 实时签发 + 自动对账)</div>
<ul>
<li>后端加 <b>发卡回调接口</b> <code>POST /v1/codes/issue</code>(B 模型):验签 → 幂等键(平台订单号)→ 签发激活码返回。</li>
<li><b>对账任务</b>:平台售出流水 ↔ 你签发/核销记录,日结核对,异常告警(防超发/漏发/重放)。</li>
</ul>
<div class="phase">阶段 2 · 自助下单页(可选,长期)</div>
<ul>
<li>用户中心 / 官网内做“获取激活码”自助页:接 <b>USDT 自动收款</b>(链上 watcher 到账→签发,你自己钱包,最干净)+ 可选 <b>Telegram Stars</b>。这条不依赖第三方发卡,抽成低、主体干净,但要自己做收款系统。</li>
<li>发卡/Reseller 继续作为“支付宝/微信便利”的并行兜底渠道。</li>
</ul>
<h2>激活码系统要补的接口(阶段 1</h2>
<div class="card">
<h3>B 模型 · 发卡平台签发回调</h3>
<pre>POST /v1/codes/issue # 发卡平台在“支付成功”后调用(服务端对服务端)
headers: X-Reseller-Sign # HMAC 验签(每个 reseller 一个密钥,Bitwarden 存)
body: {
reseller_id, reseller_order_no, # 幂等键 = (reseller_id, reseller_order_no)
sku, # 套餐档位 → 映射时长/等级
amount, currency
}
→ 200 { code: "PGL-XXXX-XXXX-XXXX", expires_at } # 幂等:同一订单号重复调用返回同一张码</pre>
<p class="small">要点:① <b>幂等</b>——同一 <code>reseller_order_no</code> 只签发一张(防平台重试超发);② <b>验签</b>——HMAC + 时间戳防伪造/重放;③ <b>SKU 映射表</b>——reseller 的商品 ↔ 你的套餐;④ 记录 <code>issued_by=reseller</code> 便于对账与止血作废。</p>
</div>
<h2>对账 · 防滥用 · 风险边界</h2>
<ul>
<li><b>对账</b>:每个渠道的“进货/售出”对上“签发/核销”。A 模型对充值批次,B 模型对签发流水。日结 + 异常告警。</li>
<li><b>防滥用</b>:激活码一次性、绑定套餐、可设过期;核销侧限频;发现某 reseller 泄漏/超卖可<b>整批作废</b>未核销的码。</li>
<li><b>风险边界(务必守住)</b>:见下。</li>
</ul>
<div class="warnbox">
<b>红线:</b>Pangolin 主体(尤其中国岩美)<b>永不</b>直接对接跑分/四方聚合、永不用中国支付账户收 VPN 款。法币收款的通道与冻卡/跑路风险,<b>只允许存在于发卡平台/Reseller 那一层</b>。你和用户之间流动的只有<b>激活码</b>,钱到你手里时已经是发卡平台的<b>结算款</b>(法币/USDT,走 US LLC)。挑选 reseller 时优先<b>预付结算 / 短结算周期</b>,降低平台跑路敞口。
</div>
<h2>主体与渠道归属</h2>
<table>
<thead><tr><th>渠道</th><th>收款主体</th><th>干净度</th><th>定位</th></tr></thead>
<tbody>
<tr><td>发卡/Reseller(支付宝/微信)</td><td>发卡平台(非你)</td><td>风险外包,你侧干净</td><td>大陆“便利”主力</td></tr>
<tr><td>USDT 自动收款</td><td>你 · US LLC 钱包</td><td><span class="tag ok">干净</span></td><td>大陆技术型用户 / 长期主力</td></tr>
<tr><td>Telegram Stars</td><td>TG 官方 → 你</td><td><span class="tag ok">干净</span></td><td>补充(抽成,走 Apple/Google IAP</td></tr>
<tr><td>App Store IAP(海外区)</td><td>Apple → 你</td><td><span class="tag ok">干净</span></td><td>海外华人补充</td></tr>
</tbody>
</table>
<p class="small">加密/结算入账的会计处理归 <code>code/accounting</code> 专门 agent,不在本项目做费用台账。</p>
<h2>待定 / 下一步</h2>
<ul>
<li>选定 <b>1 个起步发卡平台</b>(先 A 预充库存跑 MVP)——需要你定候选。</li>
<li>确认 <b>SKU ↔ 套餐</b> 映射与各档定价(对齐 <code>design/CLAUDE.md §7</code> / 官网 Pricing)。</li>
<li>阶段 1 排期:<code>POST /v1/codes/issue</code> 回调 + 对账任务(落 todo,走 codes 模块)。</li>
<li>是否并行启动 <b>阶段 2 的 USDT 自动收款</b>(自建下单页),还是先只靠发卡 + 人工。</li>
</ul>
<p class="small" style="margin-top:32px">相关:灰产 bot 收款机制分析(跑分/四方聚合)见对话记录;本方案是其“合规化替身”——同样的“付完秒发货”,风险不落在你的主体上。</p>
</div>
</body>
</html>
@@ -0,0 +1,79 @@
# pangolin-pay 加密货币交易引擎(钱包 + watcher + 收款侦测 + 归集)
> #34 的**第一块、可独立执行、能用真实小额 USDT 端到端验证**的基础件。**只做加密货币的"交易"核心**——
> 生成/派生地址、侦测链上到账、归集(sweep)——**不含**独角数卡对接、不含 `/internal/codes/mint` 发码
> (那是"发货侧",本引擎跑通后再接)。
>
> **成果物**:一个独立 Go 服务 `pangolin-pay`(部署在独立海外 VPS),对外提供"建单→给收款地址→侦测到账→
> 标记已付"的 HTTP 接口 + 一套离线归集工具。跑通后,任何门面(独角数卡 / 自建下单页)都能挂上来。
>
> **安全铁律(贯穿)**:热服务只持 **xpub**(watch-only 派生地址)+ TronGrid key + 金库收款地址,**绝不持私钥**;
> 私钥/助记词冷存,归集在离线端签名。参考知识:brain `notes/dev/crypto-hd-wallet-basics.html`。
>
> **关键常量(开工核对无误再写死)**:USDT-TRC20 合约 `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`(6 位小数);
> TRON 派生 `m/44'/195'/0'/0/i`;TronGrid `https://api.trongrid.io`。
---
## Phase A — 钱包与密钥(全程离线)
- [ ] A.1 **离线**生成**两套**助记词(断网 Mac 跑 Ian Coleman standalone / `bip_utils`,选 Coin=TRX、24 词、English):
- **钱包 A · 运营收款**;**钱包 B · 冷备金库**(另一套,独立冷存)
- [ ] A.2 导出**钱包 A 的 account xpub**(`m/44'/195'/0'` 的 Account Extended **Public** Key);取**钱包 B 的地址0**(`T...`)当**归集目标**
- [ ] A.3 冷存:助记词 A/B **分开**手抄/钢板 + Bitwarden;**只把** xpub_A + sweep 地址_B 放进 `pangolin-pay` 配置(Bitwarden 管密钥)
- [ ] A.4 **交叉核对**:B 阶段的 Go 派生实现算出的地址 0..4,必须与 Ian Coleman 显示的**逐个一致**(防派生实现不一致 → 收款地址对不上 → 收不到钱)
---
## Phase B — 服务骨架 + HD 派生
- [ ] B.1 建 Go 服务 `pangolin-pay`(独立 cmd 或独立仓)。配置:`xpub_A` / `sweep_addr_B` / `TRONGRID_API_KEY` / 确认数 / 订单超时。自带 SQLite
- [ ] B.2 **HD 派生模块**:`tyler-smith/go-bip39` + `btcsuite/.../hdkeychain`**xpub_A** watch-only 派生 `.../0/i` 公钥 →
TRON 地址(公钥 → Keccak-256 取后 20 字节 → 前缀 `0x41` → Base58Check → `T...`)。**单测:派生地址对齐 Ian Coleman(A.4)**
- [ ] B.3 数据模型:
- `pay_orders(order_no PK, sku, expect_amount, addr_index, address, status, tx_id, confirmations, created_at, expires_at)`
- `addr_cursor(next_index)` — HD 派生游标,单调递增,地址**不复用**
---
## Phase C — 收款(建单 + 侦测到账)
- [ ] C.1 `POST /order`:入参 `sku`/基准金额 → cursor++ 派生**新收款地址** → 写单(`expect_amount` 做**唯一尾数**去重、`expires=+15min`)→ 返回 `{order_no, address, expect_amount, expires_at}`
- [ ] C.2 `GET /order/{order_no}`:返回 `status`(pending/paid/expired)+ 到账信息
- [ ] C.3 **TronGrid watcher**(轮询):对每个 pending 单地址查 TRC20 转入
- `GET /v1/accounts/{addr}/transactions/trc20?only_confirmed=true&contract_address=<USDT>&limit=...`(头带 `TRON-PRO-API-KEY`)
- 匹配:`to==address``token==USDT``value≥expect_amount`(USDT 6 位小数,注意单位换算)且已确认 → `status=paid`、记 `tx_id`
- [ ] C.4 **幂等**(同 `tx_id` 只认一次)+ **超时** pending→expired(地址不复用)+ **崩溃恢复**(从 DB 未决单继续轮询)
- [ ] C.5 TronGrid 限流退避 + 重试;测试用 mock TronGrid 覆盖:金额匹配 / 确认门槛 / 超时 / 幂等 / 恢复
---
## Phase D — 归集(sweep,离线签名)
- [ ] D.1 **归集清单**:watcher(xpub)列出有余额地址 `[(index, address, balance)]`
- [ ] D.2 **gas**:从一个 `gas 钱包`给每个待归集地址打少量 **TRX**(TRC20 转账吃 energy;收款地址无 TRX);量大改**能量租赁**降本
- [ ] D.3 **离线签名**(离线机,导入**钱包 A 助记词**):派生 `address_i` 私钥 → 构造 TRC20 `transfer(sweep_addr_B, balance)`**离线签名**
- [ ] D.4 **广播**:把签好的交易经 TronGrid `POST /wallet/broadcasttransaction` 上链(**只有签名后的交易出门,私钥不出门**)
- [ ] D.5 半自动脚本(离线端)+ **归集 runbook**;触发=阈值(攒够 X)或定时;热派生地址不长期囤大额
---
## Phase E — 端到端验证(真实小额 USDT)
- [ ] E.1 真单:`POST /order` → 往返回的地址转 **1 USDT(TRC20)** → watcher 侦测 + 确认 → `GET /order``paid`
- [ ] E.2 归集:对该地址执行 D 流程 → USDT 到达**钱包 B 冷地址**
- [ ] E.3 安全核对:`pangolin-pay` 进程/配置**无任何私钥/助记词**(只有 xpub);密钥不入 git(红线扫描);地址不复用
---
## Verification(判据)
- `go test ./...`:HD 派生对齐 Ian Coleman、watcher 匹配/确认/超时/幂等/恢复全绿。
- 真机:一笔真 1 USDT 从建单→侦测→确认→归集到冷地址全通。
- 安全:热服务无私钥、密钥不入 git、每单唯一地址不复用——逐项核对。
## 不在本轮
- 独角数卡门面对接 + epay 支付口(下一块:接门面)。
- `/internal/codes/mint` JIT 发码 + 兑换闭环(发货侧,#34 后续)。
- HD 自动 sweep(先人工离线归集)、多币种(先只 USDT-TRC20)、watcher 高可用。
@@ -0,0 +1,827 @@
# 统一支付编排层 · 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.
> **设计文档(全景蓝图):** `docs/pay-orchestration-design.html`(架构/契约/取舍)。本计划是其 7 阶段中的 **P1** 落地;阅读版 `docs/pay-orchestration-p1-plan.html`。
**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')),
expires_at DATETIME NOT NULL, -- 本次尝试超时(哪吒 payurl / crypto 15min);超时只弃本 attempt
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,
expires_at DATETIME(6) 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; ExpiresAt, 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)` —— 返回**全部状态**(含 canceled),供订单历史页。
- `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;非 pending(已 paid/取消/过期)返回 `false,nil`。**这是「一单只一个 attempt 成功」的守卫**。
- `CancelOrder(ctx, orderNo string) (bool, error)` —— 仅当 order.status='pending' 时置 canceled,返回 true;非 pending 返回 `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,
expires_at DATETIME, 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", ExpiresAt: now.Add(15 * time.Minute), 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)
}
}
func TestCancelOrder(t *testing.T) {
db := openPay(t)
st := payorders.NewStore(db)
ctx := context.Background()
now := time.Now().UTC()
must := payorders.Order{
OrderNo: "PAY-C", UserID: 1, UserUUID: "u-1", SKU: "pro-month", PlanCode: "pro",
DurationDays: 30, AmountMinor: 3990000, Currency: "USDT", Status: "pending",
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
if err := st.CreateOrder(ctx, must); err != nil { t.Fatalf("create: %v", err) }
ok, err := st.CancelOrder(ctx, "PAY-C")
if err != nil || !ok { t.Fatalf("cancel#1 ok=%v err=%v", ok, err) }
// 再取消 → false(非 pending)。
ok2, _ := st.CancelOrder(ctx, "PAY-C")
if ok2 { t.Fatalf("第二次 CancelOrder 应 false") }
got, _ := st.GetOrder(ctx, "PAY-C")
if got.Status != "canceled" { t.Fatalf("status = %s, 应 canceled", got.Status) }
// canceled 单仍在 ListOrders 出现。
list, _ := st.ListOrders(ctx, 1, 20, 0)
var seen bool
for _, o := range list {
if o.OrderNo == "PAY-C" { seen = true }
}
if !seen { t.Fatalf("canceled 单应出现在订单列表") }
}
```
- [ ] **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
ExpiresAt time.Time
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, expires_at, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
a.OrderNo, a.Method, a.Provider, a.ProviderRef, a.RenderType,
a.AmountMinor, a.Currency, a.Status, a.ExpiresAt.UTC(), 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, expires_at, 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.ExpiresAt, &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
}
// CancelOrder flips a pending order to canceled. Returns false if the order was
// not in 'pending' (already paid / canceled / expired).
func (s *Store) CancelOrder(ctx context.Context, orderNo string) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE pay_orders SET status='canceled' WHERE order_no=? AND status='pending'`, orderNo)
if err != nil {
return false, fmt.Errorf("payorders.CancelOrder: %w", err)
}
n, _ := res.RowsAffected()
return n > 0, 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/ -v`
Expected: `TestCreateGetMarkPaidIdempotent``TestCancelOrder` 均 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** 端到端联调(两轨小额真链/真单)。
@@ -0,0 +1,185 @@
# 收款闭环 · 独角数卡 + 自托管 USDT(TRC20)+ webhook JIT 发码(最干净长期方案)
> #34。目标:把「下单 → USDT 付款 → 自动发激活码 → 客户端/用户中心兑换 → 订阅生效」端到端打通,
> 全程自托管、售出才发码、无跑分、无第三方支付主体。
>
> **关键前提(已摸底)**:激活码后端大半已就绪——`POST /v1/redeem`JWT 兑换,含幂等+限频+账户锁)、
> 批次生成/导出(`server/cmd/codegen` + `server/internal/codes`,码为 Crockford Base32 75-bit 带校验位、只存
> hash)、发卡店回调 `POST /webhook/store/codes`HMAC+时间戳+nonce 防重放)都在。本方案**新增的核心只有一个
> 后端端点**JIT 发码 `/internal/codes/mint`+ 一个独立收款服务(`pangolin-pay`TronGrid watcher + epay 支付口)。
>
> **三大组件 / 数据流**
> ```
> 买家 ──下单──▶ 独角数卡(门面, 独立VPS)
> │ 建单 + epay 跳转
>
> pangolin-pay(自建收款服务, 同VPS)
> ├ 派生每单唯一 TRC20 收款地址(watch-only, 只持 xpub)
> ├ TronGrid watcher 轮询到账 + 确认
> └ 到账 ──epay notify──▶ 独角数卡「已支付」
>
> ▼ 独角数卡 API-提货
> pangolin-pay /issue ──HMAC──▶ pangolin 控制面 POST /internal/codes/mint
> └ 生成合法码 + 存 unused + 返回明文
>
> ▼ 独角数卡把码交付买家
> 买家 ──输码──▶ 客户端/用户中心 POST /v1/redeem ──▶ 订阅生效
> ```
>
> **安全红线(贯穿全程)**:① 热服务 `pangolin-pay` **只持 xpub 派生收款地址、绝不持私钥**(能收不能转,
> 被脱库也转不走钱);② 私钥/助记词冷备 + Bitwarden,不入 git、不上热机;③ 收款终点=自托管钱包,变现经
> 交易所出金 US LLC,记账走 accounting agent;④ 全程不碰跑分/中国支付。
>
> **主体归属**`pangolin-pay` + 独角数卡部署在**独立海外小 VPS**(不放 pangolin1:内存紧 + 风险隔离),
> 只经 HTTPS + HMAC 与 pangolin 控制面(`api.yanmeiai.com`)通信。
>
> 参考文档:`docs/payment-a-selfhosted-store-plan.html` / `payment-channels-overview.html` /
> `payment-reseller-fulfillment-design.html`。
---
## Phase 0 — 计划落库 + todo
- [ ] 0.1 本 `.md` 定稿 + 生成 HTML 阅读版 `docs/payment-clean-usdt-plan.html`,登记进 `docs/index.html`「实现计划」
- [ ] 0.2 `/todo`#34 propose 为「待确认」(tier-1`--ref` 指向本 `.md`),等用户 approve 再进 Phase 1
---
## Phase 0.5 — 钱包选型(用哪个钱包)
**先厘清:这套架构的「钱包」是两件事,别当成装一个 app。**
- **主种子(钱在哪)**:一套 BIP39 助记词,冷存。这是资产本体。
- **watcher 收款(派生地址)**:热服务用**库**从 account **xpub(公钥)**派生每单地址——**watch-only,不是钱包 app**,也拿不到私钥。
- **归集签名**:需要动钱时,在冷端用私钥离线签 sweep 交易。
所以「用哪个钱包」= 选①主种子怎么冷存 + ②watcher 用哪个派生库。
**① 主种子 / 冷存 候选**
| 方案 | 冷存安全 | xpub 导出(给 watcher) | 归集签名 | 结论 |
|---|---|---|---|---|
| **离线生成助记词 + 库派生**(推荐) | 高(纸/钢板离线保存,永不联网) | 直接从种子导 account xpub | 离线机用库签 | **最贴合 watch-only 架构、最灵活** |
| **Ledger 硬件钱包**(可叠加) | 最高(私钥永不出设备) | 可导 account 公钥(需配套工具) | 插 Ledger 签 | 想再加一层硬件冷存就用它兜底 |
| TronLink / imToken / Trust(手机热钱包) | 低(热钱包) | 不便批量 per-order 派生 | app 内签 | **不推荐**当收款主钱包(热 + 难集成) |
**② watcher 派生库 候选**(给热服务 watch-only 派生 TRON 地址,非钱包)
| 语言 | 库 |
|---|---|
| Go(与 server 同栈,推荐) | `tyler-smith/go-bip39`(助记词)+ `btcsuite/btcd/btcutil/hdkeychain`BIP32 派生)→ 公钥转 TRON base58 地址(`0x41` 前缀 + Keccak);或成熟 TRON Go SDK |
| Python | `tronpy` + `bip_utils`(一步到位派生 TRON 地址) |
**推荐:** 主种子用 **离线生成 BIP39 助记词**(想再稳就叠一层 **Ledger 冷存**);watcher 用 **Go 库从 account xpub watch-only 派生**(与 server 同栈、好集成、热机无私钥)。**收款钱包 = 你自己的 HD 种子,不是某个 app。**
- [ ] 0.5.1 定主种子方案:离线助记词(推荐)/ 是否叠加 Ledger 冷存;记录选型入本计划
- [ ] 0.5.2 定 watcher 派生库(Go bip39+hdkeychain→TRON 地址 / Python tronpy+bip_utils),跑通派生并与钱包 app 交叉核对同一地址(防派生实现不一致收不到钱)
- [ ] 0.5.3 定归集冷签工具(离线机脚本 / Ledger),确认能对 sweep 交易离线签名
---
## Phase 1 — 自托管钱包(TRC20 HDwatch-only 收款)
目标:一套助记词派生**无限个收款地址**,热服务只拿 xpub(公钥)派生地址收款、拿不到私钥;私钥冷备用于归集。
- [ ] 1.1 **离线**生成 BIP39 助记词(24 词)。TRON 派生路径 `m/44'/195'/0'/0/i`195=TRON coin type)。
工具:离线机器用 `tronpy` / `tronweb` 或硬件钱包(Ledger,导出 account xpub)。**生成过程不联网。**
- [ ] 1.2 密钥分级存放:
- **助记词/私钥** → 冷备(纸/钢板离线)+ Bitwarden 记录,**永不上热机、不入 git**
- **account xpub(公钥)** → 给 `pangolin-pay` 用来 watch-only 派生收款地址(无私钥)
- **HMAC secret**Phase 3/4 用)→ Bitwarden
- [ ] 1.3 定**冷钱包归集地址**cold sweep 目标)+ 记录 USDT-TRC20 合约地址(主网 `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`**核对无误再写死**
- [ ] 1.4 备少量 **TRX** 作归集 gasTRON 转账吃 energy/bandwidth,账户无 energy 时烧 TRX);记 energy 租赁备选
- [ ] 1.5 验证:用 xpub 离线派生前 5 个地址;往 index 0 打 1 USDT 小额测试,确认 watch-only 能查到该地址收款
---
## Phase 2 — pangolin-payTronGrid watcher(自建收款侦测)
目标:一个独立 Go 服务,派生每单唯一地址、轮询 TronGrid 侦测到账、确认后置单已付。
- [ ] 2.1 建服务骨架 `pangolin-pay`Go,复用 server 的 DB 方言层思路;独立仓/独立 cmd 均可)。自带轻量 DB(SQLite 即可)
- [ ] 2.2 数据模型:
- `pay_orders(order_no PK, sku, plan, duration_days, addr_index, address, expect_amount, status, tx_id, confirmations, created_at, expires_at)`
- `addr_cursor(next_index)` — HD 派生游标,单调递增
- [ ] 2.3 **每单唯一地址**:建单时从 xpub 派生 `m/.../0/next_index` 分配给该单(最准,免金额撞车)。次选:单地址+唯一金额(备选降级)
- [ ] 2.4 TronGrid 轮询:对每个 pending 单地址查 TRC20 转入
- endpoint`GET https://api.trongrid.io/v1/accounts/{addr}/transactions/trc20?only_confirmed=true&contract_address=<USDT>`
- 比对:`to==address``token==USDT``value≥expect_amount`(含小额容差处理)
- [ ] 2.5 **确认数**:只认 `only_confirmed`(已固化)交易,或等 ≥N 区块(TRON 约 1 分钟)。到账+确认 → `status=paid`、记 `tx_id`
- [ ] 2.6 到账后触发发货(Phase 4 的 epay notify);**幂等**:同 `tx_id` 只认一次,一个 order 只发一次货
- [ ] 2.7 订单超时(默认 15min)未付 → `status=expired`、释放地址(地址不复用,避免旧付款串单)
- [ ] 2.8 TronGrid API key + 限流退避 + 失败重试;watcher 崩溃可恢复(从 DB 未决单继续轮询)
- [ ] 2.9 测试:mock TronGrid → 单测 金额匹配 / 确认门槛 / 超时 / 幂等(同 tx 重放)/ 崩溃恢复
---
## Phase 3 — pangolin 控制面:JIT 发码端点 `POST /internal/codes/mint`
目标:付款确认时**即时生成一个合法激活码**(这才是 webhook JIT 的落点:售出才产码、才有效)。
现有 `/webhook/store/codes` 要求调用方自带合法码;自建场景由 pangolin 产码更干净(保证格式+校验位单源)。
- [ ] 3.1 新增 `POST /internal/codes/mint`(挂 `/internal` 组,**无 JWT**HMAC 同 webhook
`X-Pangolin-Signature: sha256=<hmac>` + `X-Pangolin-Timestamp`(±5min) + `X-Pangolin-Nonce`(Redis 去重)
- [ ] 3.2 body `{plan: free|pro|team, duration_days, order_ref}` → 复用 `codes.GenerateCode()` 产码 +
`Store.CreateCode(hash, planID, duration, batchID)` 存 unusedchannel=ChannelStore)→ 返回 `{code, expires_at}`
- [ ] 3.3 **幂等**:以 `order_ref` 为幂等键(存 `order_ref→code_hash` 映射);同单重放返回**同一个码**,绝不重复发
- [ ] 3.4 secret 走 Bitwarden → `server.env``CODES_MINT_HMAC_SECRET`);路由挂载(`main.go`),仅 HMAC 保护、不经公网 JWT
- [ ] 3.5 复用现有 webhook 的验签/时间戳/nonce 逻辑(抽公共中间件,避免重复实现)
- [ ] 3.6 测试:验签通过/失败、时间戳越界、nonce 重放、`order_ref` 幂等、产码格式(Canonicalize 可还原+校验位对)
---
## Phase 4 — 独角数卡对接(门面 + 自动发货)
目标:独角数卡做门面/订单/交付,`pangolin-pay` 做 USDT 支付口 + 发货时取码。
- [ ] 4.1 (研究·先做)摸清所用独角数卡版本的**支付接口(epay 兼容规范)**与**API-提货/自动发货商品**机制
(版本相关,需实测;产出对接点清单再实现)
- [ ] 4.2 `pangolin-pay` 实现 **epay 兼容支付接口**:独角数卡建单 → 跳我方支付页(展示该单 USDT 地址 + 应付金额 +
倒计时 + 网络必须 TRC20 强提示)
- [ ] 4.3 到账(Phase 2.5)→ 按 epay 规范 **notify 独角数卡**(带签名)→ 独角数卡标「已支付」
- [ ] 4.4 商品设为 **API-提货**:独角数卡发货时调 `pangolin-pay /issue` → 内部以 HMAC 调 pangolin
`/internal/codes/mint` 取合法码 → 返回独角数卡 → 交付买家(页面/邮件/站内)
- [ ] 4.5 **SKU 映射表**(配置化,不散在代码):独角数卡商品 → `plan + duration_days`
Pro 月=pro/30、Pro 年=pro/365、Team 月/年=team/30|365;对齐 `design/CLAUDE.md §7` 与官网 Pricing
- [ ] 4.6 幂等贯通:独角数卡 order_no 作为 `order_ref` 一路透传到 mint,保证一单一码
---
## Phase 5 — 部署 · 运维 · 安全
- [ ] 5.1 独立海外小 VPS(2C/2G 起)部署:独角数卡(DockerPHP+MySQL+Redis+ `pangolin-pay` + 域名 + HTTPS
**不放 pangolin1**
- [ ] 5.2 密钥落位核对:`pangolin-pay` 只有 **xpub + TronGrid key + epay 签名密钥 + mint HMAC secret**
**无私钥/助记词**(冷备)。全部 Bitwarden,不入 git
- [ ] 5.3 **归集 runbook**:定期把各收款地址 USDT sweep 到冷钱包(**离线签名**,热机不碰私钥);备 TRX gas
- [ ] 5.4 **变现 runbook**:冷钱包 → 交易所(能出金到 US LLC 的,自行核实)→ 卖 USDT→USD → US LLC 银行;
入账交 `code/accounting` agent(本项目不做费用台账)
- [ ] 5.5 监控告警(接现有 Telegram 告警):watcher 存活、TronGrid 配额、到账延迟、mint 失败、订单异常堆积
- [ ] 5.6 **对账**:独角数卡订单 ↔ `pay_orders` ↔ codesmint 条数/redeem 条数)三方日结,差异告警
---
## Phase 6 — 端到端验证
- [ ] 6.1 真单闭环:下单 → USDT 付款(TRC20)→ watcher 确认 → mint 码 → 独角数卡交付 →
客户端/用户中心 `POST /v1/redeem` → 订阅/时长生效、全端同步
- [ ] 6.2 异常路径:超时未付 / 金额不足 / 重复付款 / 确认期网络抖动 / mint 重放 → 各自表现符合预期
- [ ] 6.3 安全核对:热服务 `otool`/进程无私钥;密钥不在 git(红线扫描);redeem 幂等+限频生效;
每单唯一地址不复用
---
## Verification(端到端判据)
- 后端:`cd server && go test ./internal/codes/...`(新增 mint 端点验签/幂等/格式测试全绿)。
- pangolin-pay`go test ./...`watcher 匹配/确认/超时/幂等/恢复)。
- 真机:一笔真 USDT 小额单,从下单到订阅生效全通;异常路径逐条演练。
- 安全:热服务无私钥、密钥不入 git、每单唯一地址、redeem 幂等限频——逐项核对。
## 不在本轮
- 支付宝/微信第三方通道(E 层,灰产,另议且当可替换插件)。
- HD 自动 sweep 归集(先人工离线归集,量大再自动化)。
- 多币种(先只 USDT-TRC20)、watcher 高可用/多实例、加密网关备选路子(B 方案)。
- Telegram Stars / Apple IAP 独立收款口(海外补充,另排)。
+1 -1
View File
@@ -114,7 +114,7 @@ func main() {
defer stop()
go w.Loop(ctx, time.Duration(pollSec)*time.Second)
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second}
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc, env("PAY_CORS_ORIGINS", "https://pangolin.yanmeiai.com,https://pangolin-site.pages.dev")), ReadHeaderTimeout: 10 * time.Second}
go func() {
log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+40
View File
@@ -0,0 +1,40 @@
package httpapi
import (
"net/http"
"strings"
)
// withCORS lets the browser storefront (pangolin website) call the order API
// cross-origin. Auth is server-to-server / bearer-less here (no cookies), so
// Allow-Credentials is not needed. Only whitelisted origins get CORS headers;
// OPTIONS preflight is answered directly.
func withCORS(next http.Handler, allowed map[string]bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && allowed[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type")
h.Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// parseOrigins builds the allowed-origin set from a comma-separated list.
func parseOrigins(csv string) map[string]bool {
m := map[string]bool{}
for _, o := range strings.Split(csv, ",") {
if o = strings.TrimSpace(o); o != "" {
m[o] = true
}
}
return m
}
+5 -3
View File
@@ -14,17 +14,19 @@ import (
type Handler struct{ svc *pay.Service }
// New wires the routes (Go 1.22 method+wildcard patterns).
func New(svc *pay.Service) http.Handler {
// New wires the routes (Go 1.22 method+wildcard patterns) and applies CORS for
// the whitelisted browser origins (comma-separated; empty = no CORS headers).
func New(svc *pay.Service, corsOrigins string) http.Handler {
h := &Handler{svc: svc}
mux := http.NewServeMux()
mux.HandleFunc("POST /order", h.createOrder)
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
mux.HandleFunc("GET /_test", h.testPage) // same-origin manual test harness (Phase E)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
return mux
return withCORS(mux, parseOrigins(corsOrigins))
}
type createReq struct {
+1 -1
View File
@@ -16,7 +16,7 @@ const recvAddr = "TRecv00000000000000000000000000000A"
func TestCreateGetAndConflict(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr})))
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr}), "https://pangolin.yanmeiai.com"))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]any{"user_ref": "u1", "sku": "pro-year", "amount": 5_000000})
+256
View File
@@ -0,0 +1,256 @@
package httpapi
import "net/http"
// testPage serves a self-contained, same-origin manual test page at GET /_test.
// Same origin as the API → no CORS, and pangolin-pay sets no restrictive CSP,
// so its inline JS/CSS run freely. This is a throwaway harness for the
// money-critical Phase E loop (create order → pay exact amount → watch paid),
// NOT the production checkout (that lives on the storefront). It only creates
// orders and polls status — no secrets, no keys.
//
// Styling mirrors the marketing site's warm sand/clay palette (design tokens in
// design/prototype/tokens.css, light theme default, brand = clay-500 #B96A3D).
// Values are inlined here because pangolin-pay is a separate origin and cannot
// @import the site's stylesheet; keep them in sync if the brand palette changes.
func (h *Handler) testPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(testPageHTML))
}
const testPageHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="#B96A3D">
<title>pangolin-pay · 收款测试</title>
<style>
:root{
--bg:#FAF8F4; --surface:#FFFFFF; --surface-2:#F2EEE7;
--fg1:#1F1C18; --fg2:#6B6253; --fg3:#8C8270;
--accent:#B96A3D; --accent-hover:#9E5630; --accent-subtle:#FAF3ED; --accent-border:#E6C7AC;
--border:#E6DFD3; --border-strong:#D2C8B8;
--success:#5B8C5A; --success-subtle:#E9F0E6;
--warning:#D69A3C; --warning-subtle:#F8EED6;
--danger:#C0533B; --danger-subtle:#F6E1DA;
--radius-lg:14px; --radius-md:10px; --radius-sm:8px;
--font-display:'Sora','Noto Sans SC',system-ui,sans-serif;
--font-sans:'Manrope','Noto Sans SC',system-ui,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
--shadow-md:0 4px 14px rgba(45,30,20,.08);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg1);
font-family:var(--font-sans);font-size:15px;line-height:1.55;
-webkit-font-smoothing:antialiased;
display:flex;justify-content:center;padding:40px 16px;}
.wrap{width:100%;max-width:480px}
h1{font-family:var(--font-display);font-size:22px;margin:0 0 5px;font-weight:700;letter-spacing:-.02em}
h1 .dot{color:var(--accent)}
.sub{color:var(--fg2);font-size:13px;margin:0 0 24px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);
padding:22px;margin-bottom:16px;box-shadow:var(--shadow-md)}
label{display:block;font-size:12px;color:var(--fg2);margin:14px 0 6px;font-weight:500}
label:first-child{margin-top:0}
input{width:100%;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius-md);
color:var(--fg1);padding:11px 13px;font-size:15px;outline:none;font-family:inherit;transition:border-color .15s}
input:focus{border-color:var(--accent)}
button{width:100%;margin-top:20px;background:var(--accent);color:#fff;border:0;border-radius:var(--radius-md);
padding:13px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
button:hover:not(:disabled){background:var(--accent-hover)}
button:disabled{opacity:.5;cursor:not-allowed}
button.ghost{background:transparent;border:1px solid var(--border-strong);color:var(--fg1);font-weight:500;margin-top:12px}
button.ghost:hover:not(:disabled){background:var(--surface-2)}
button.copy{margin-top:10px;background:var(--accent-subtle);border:1px solid var(--accent-border);
color:var(--accent-hover);font-weight:600;font-size:13px;padding:9px}
button.copy:hover:not(:disabled){background:#F5E7D8}
.kv{display:flex;justify-content:space-between;align-items:center;gap:12px;
padding:12px 0;border-bottom:1px solid var(--border)}
.kv .k{color:var(--fg2);font-size:13px;white-space:nowrap;flex:none}
.kv .v{text-align:right;font-family:var(--font-mono);font-size:13px;word-break:break-all}
.amtbox{text-align:center;padding:20px 0 6px}
.amtbox .cap{color:var(--fg2);font-size:12px;margin-bottom:8px}
.amtbox .cap b{color:var(--warning)}
.amt{font-family:var(--font-mono);font-size:30px;font-weight:700;color:var(--success);letter-spacing:-.01em}
.amt small{font-size:14px;color:var(--fg3);font-weight:400;margin-left:5px;font-family:var(--font-sans)}
.addrblock{margin:6px 0 2px}
.addrblock .k{color:var(--fg2);font-size:12px;margin-bottom:6px}
.addr{font-family:var(--font-mono);font-size:14px;background:var(--surface-2);border:1px solid var(--border);
border-radius:var(--radius-md);padding:12px 14px;word-break:break-all;line-height:1.5;color:var(--fg1)}
.badge{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;font-family:var(--font-sans)}
.b-pending{background:var(--warning-subtle);color:#9A6B1F}
.b-paid{background:var(--success-subtle);color:#3E6B3D}
.b-expired{background:var(--danger-subtle);color:#8F3826}
.note{font-size:12px;color:var(--fg2);margin-top:16px;line-height:1.65}
.note b{color:var(--fg1)}
.err{color:var(--danger);font-size:13px;margin-top:12px;min-height:18px}
.hidden{display:none}
.warnbox{background:var(--warning-subtle);border:1px solid #EBD7A6;border-radius:var(--radius-md);
padding:12px 14px;font-size:12.5px;color:#7A5514;margin-top:16px;line-height:1.65}
.warnbox b{color:#5E4110}
code{background:var(--surface-2);border:1px solid var(--border);padding:1px 5px;border-radius:5px;font-size:12px;font-family:var(--font-mono)}
</style>
</head>
<body>
<div class="wrap">
<h1>pangolin-pay <span class="dot">·</span> 收款测试</h1>
<p class="sub">USDT-TRC20 · 单地址 + 唯一金额 · 手动测试页(非生产收银台)</p>
<div class="card" id="form-card">
<label>用户标识 user_ref</label>
<input id="user_ref" autocomplete="off">
<label>商品 SKU</label>
<input id="sku" value="pro-year" autocomplete="off">
<label>价格(USDT</label>
<input id="amount" value="0.1" inputmode="decimal" autocomplete="off">
<button id="create">创建订单</button>
<div class="err" id="form-err"></div>
</div>
<div class="card hidden" id="order-card">
<div class="kv">
<span class="k">订单号</span>
<span class="v" id="o-no"></span>
</div>
<div class="kv">
<span class="k">状态</span>
<span class="v"><span class="badge" id="o-status"></span></span>
</div>
<div class="amtbox">
<div class="cap">请支付<b>精确金额</b>(多付少付都不匹配)</div>
<div class="amt" id="o-amount"></div>
</div>
<button class="copy" id="copy-amt">复制金额</button>
<div class="addrblock">
<div class="k">收款地址</div>
<div class="addr" id="o-addr"></div>
</div>
<button class="copy" id="copy-addr">复制地址</button>
<div class="kv" style="margin-top:6px">
<span class="k">TxID</span>
<span class="v" id="o-tx">—</span>
</div>
<div class="kv">
<span class="k">过期倒计时</span>
<span class="v" id="o-ttl"></span>
</div>
<div class="warnbox">
发往此地址、且金额<b>正好等于</b>上面数字的 USDT-TRC20 转账,会在 watcher 下一轮轮询后自动标记
<code>paid</code>。金额不符 → 进 orphan 需人工对账。<br>
⚠️ 需 <code>TRONGRID_API_KEY</code> 已配置,否则 watcher 被限流、侦测很慢。
</div>
<button class="ghost" id="reset">新建另一单</button>
<div class="err" id="order-err"></div>
</div>
<p class="note">
测什么:<b>收款订单闭环</b> —— <code>POST /order</code> 建单拿地址+唯一金额、<code>GET /order/{id}</code>
轮询到 <code>paid</code>。地址模型:所有订单收到<b>同一地址</b>,靠<b>唯一金额</b>(基准价 + 微尾数
≤0.01U)区分。本页无私钥、无密钥。
</p>
</div>
<script>
(function(){
var $ = function(id){ return document.getElementById(id); };
var poll = null, ttlTimer = null, curNo = null, expiresAt = 0;
function rnd(){ return 'test-' + Math.random().toString(36).slice(2, 8); }
$('user_ref').value = rnd();
function fmtUSDT(micro){ return (micro/1e6).toFixed(6).replace(/0+$/,'').replace(/\.$/,''); }
// pay-server 只有 pending/paid/expired 三态;显示层映射为中文(发货/开通归门面,不在此服务)。
var STATUS_LABEL = { pending:'等待付款', paid:'付款完成', expired:'已过期' };
function setStatus(s){
var el = $('o-status');
el.textContent = STATUS_LABEL[s] || s;
el.className = 'badge b-' + s;
}
function copy(text, btn){
navigator.clipboard.writeText(text).then(function(){
var old = btn.textContent; btn.textContent = '已复制 ✓';
setTimeout(function(){ btn.textContent = old; }, 1200);
});
}
function tickTTL(){
if(!expiresAt){ return; }
var left = Math.max(0, Math.floor((expiresAt - Date.now())/1000));
var m = Math.floor(left/60), s = left%60;
$('o-ttl').textContent = left>0 ? (m+':'+(s<10?'0':'')+s) : '已过期';
}
function showOrder(o){
curNo = o.order_no;
expiresAt = new Date(o.expires_at).getTime();
$('o-no').textContent = o.order_no;
$('o-amount').innerHTML = fmtUSDT(o.expect_amount) + '<small>USDT</small>';
$('o-addr').textContent = o.address;
$('o-tx').textContent = o.tx_id || '—';
setStatus(o.status);
$('copy-amt').onclick = function(){ copy(fmtUSDT(o.expect_amount), this); };
$('copy-addr').onclick = function(){ copy(o.address, this); };
$('form-card').classList.add('hidden');
$('order-card').classList.remove('hidden');
tickTTL();
ttlTimer = setInterval(tickTTL, 1000);
startPoll();
}
function startPoll(){
stopPoll();
poll = setInterval(function(){
fetch('/order/' + encodeURIComponent(curNo)).then(function(r){ return r.json(); }).then(function(o){
if(o.error){ return; }
setStatus(o.status);
$('o-tx').textContent = o.tx_id || '—';
if(o.status === 'paid' || o.status === 'expired'){ stopPoll(); }
}).catch(function(){});
}, 5000);
}
function stopPoll(){ if(poll){ clearInterval(poll); poll=null; } }
$('create').onclick = function(){
$('form-err').textContent = '';
var usdt = parseFloat($('amount').value);
if(!(usdt > 0)){ $('form-err').textContent = '价格必须 > 0'; return; }
var body = {
user_ref: $('user_ref').value.trim() || rnd(),
sku: $('sku').value.trim() || 'sku',
amount: Math.round(usdt * 1e6)
};
this.disabled = true; this.textContent = '创建中…';
var btn = this;
fetch('/order', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)})
.then(function(r){ return r.json().then(function(j){ return {code:r.status, j:j}; }); })
.then(function(res){
btn.disabled = false; btn.textContent = '创建订单';
if(res.code === 201){ showOrder(res.j); }
else if(res.code === 409){ $('form-err').textContent = '该 user_ref 已有活跃订单(换一个或等它过期)'; }
else { $('form-err').textContent = '错误:' + (res.j.error || res.code); }
})
.catch(function(e){
btn.disabled = false; btn.textContent = '创建订单';
$('form-err').textContent = '网络错误:' + e.message;
});
};
$('reset').onclick = function(){
stopPoll(); if(ttlTimer){ clearInterval(ttlTimer); ttlTimer=null; }
$('order-card').classList.add('hidden');
$('form-card').classList.remove('hidden');
$('user_ref').value = rnd();
};
})();
</script>
</body>
</html>`
+582 -43
View File
@@ -329,12 +329,12 @@ header .header-meta{color:var(--fg-soft)}
<h1>feature+windows — 项目 TODO</h1>
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>18</strong>全部</div>
<div class="stat-pill"><strong>8</strong>待开始</div>
<div class="stat-pill"><strong>33</strong>全部</div>
<div class="stat-pill"><strong>12</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>2</strong>待验收</div>
<div class="stat-pill"><strong>8</strong>已验收</div>
<div class="stat-pill"><strong>6</strong>待验收</div>
<div class="stat-pill"><strong>15</strong>已验收</div>
<div class="stat-pill gate-stat"><strong>1</strong>待确认</div>
</div>
</div>
</header>
@@ -367,7 +367,7 @@ header .header-meta{color:var(--fg-soft)}
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="network">network</button><button class="filter-chip" data-filter-tag="pangolin1">pangolin1</button><button class="filter-chip" data-filter-tag="reddit">reddit</button><button class="filter-chip" data-filter-tag="warp">warp</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="支付">支付</button><button class="filter-chip" data-filter-tag="数据库">数据库</button><button class="filter-chip" data-filter-tag="文档">文档</button><button class="filter-chip" data-filter-tag="运维">运维</button>
</div>
</div>
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
<div class="section-block" id="section-open">
<div class="section-title st-open" data-toggle="open">
📋 待开始 <span class="s-count">8</span>
📋 待开始 <span class="s-count">12</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-open">
@@ -401,7 +401,12 @@ header .header-meta{color:var(--fg-soft)}
</div>
<div class="item-desc">现状 L0(stub)VpnChannel.swift:69 setKillSwitch 直接 result(nil)、includeAllNetworks()→false。天花板 L3。改 NetworkExtensionincludeAllNetworks=true + NEOnDemandRule 常开 + enforceRoutes,由 neagent 系统级强制、扛 app 崩溃。性价比最高的跨端缺口。依据见 docs/killswitch-design.md §4-6。</div>
<div class="gate-block gate-granted">
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
<span class="gate-kind">二级改动说明</span>
<span class="gate-date">2026-06-30</span></div>
<div class="gate-note">macOS L0→L3,改 2 个原生文件(不动 Dart/服务端)。【VpnChannel.swift】①加缓存 killSwitchEnabled(默认true);②setKillSwitch 落地:存标志+manager已存在则重新applyKillSwitchConfig+saveToPreferences即时生效;③loadOrCreateManager 按标志设 proto.includeAllNetworks/enforceRoutes=true/excludeLocalNetworks=true(放行LAN)+mgr.onDemandRules=[NEOnDemandRuleConnect()]+isOnDemandEnabled;④stop() gotcha:先关 isOnDemandEnabled+save 再 stopVPNTunnel,否则on-demand把断开拉回;⑤start() options 带 killSwitch 传扩展。【PacketTunnelProvider.swift】startTunnel 读 killSwitch option 缓存,includeAllNetworks() 回调返回该值与NE层对齐。无需新增entitlement。验证靠真机:连上后杀扩展/拔网验证流量被阻断(过L1→L2质变线)+on-demand开机/掉线自动拉起+手动断开不反弹。</div><div class="gate-ref">📄 详见 <code>docs/killswitch-design.html</code></div>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span></div>
@@ -569,14 +574,76 @@ header .header-meta{color:var(--fg-soft)}
</li>
<li class="todo-card s-open"
data-id="16"
data-id="31"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="Web,文档">
<div class="card-header">
<span class="item-id">#31</span>
<span class="item-title">官网文档页:撰写 快速开始/FAQ/协议与安全/隐私政策 4 页,接回 Docs 卡片链接</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">web/website Docs 区当前 4 张卡为占位信息卡(不可点)。需撰写真实文档页(可用 Astro content/MDX 或 docs 站),写好后把 Docs.astro 卡片改回 &lt;a href&gt; 并恢复「阅读」CTA、页脚 文档/FAQ/隐私政策 指向对应页。注意:隐私政策属法律文本,由创始人审定,Claude 仅出初稿;对「无日志」VPN,隐私政策是软性上线门槛。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-05</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="32"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="前端,后端,Android,iOS">
<div class="card-header">
<span class="item-id">#32</span>
<span class="item-title">控制面 fallback:CF 域名被 SNI 封时客户端退回直连节点 IP 的 https 控制口</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">#25 采用 CF Tunnel 前置控制面(api.yanmeiai.com)。已知弱点:该域名可被 GFW 单独 SNI 封锁,届时登录/拉配置不可用(已连隧道不受影响,数据面 REALITY:443 直连也不受影响)。加固:客户端 api 层保留一个直连某健康节点 IP 的 https 控制面 fallback——域名连不上/握手被 RST 时自动退回直连,保证仍能登录/刷新配置/换节点。服务端需在节点上也暴露一个可直连的 https 控制口(或复用现有端口+证书)。属 #25 的后续加固,不在 #25 本轮范围。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-06</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="34"
data-level="mid"
data-status="open"
data-tier="1"
data-tags="后端,前端">
data-tags="后端,支付,运维">
<div class="card-header">
<span class="item-id">#16</span>
<span class="item-title">启用设备数量限制 + 超限 UX(踢旧/选择移除,非硬拒登)</span>
<span class="item-id">#34</span>
<span class="item-title">独角数卡 + USDT 收款闭环</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
@@ -586,14 +653,14 @@ header .header-meta{color:var(--fg-soft)}
</div>
</div>
<div class="item-desc">现状:plans.max_devices(free1/pro5/team10)+ devices/service.go:216 强制逻辑 + DEVICE_LIMIT_EXCEEDED 错误码 + 账户API DevicesMax 都在,但 main.go:534 登录注册传 MaxDevices:0(不启用)。原因:free 用户重装会换 device UUID,登录硬卡会把人锁外面。需:① 登录/注册从套餐读 max_devices 传入 RegisterIfAbsent;② 超限不直接拒登,改为'踢最旧设备/让用户选哪台下线'的 UX(客户端弹窗 + 设备管理页操作);③ 处理 free 重装 churn(同机复用而非每次新增)。tier-1:涉及登录接口 + 跨后端/前端 + 策略设计</div>
<div class="item-desc">自建发卡网(独角数卡,独立小 VPS 隔离部署,不放 pangolin1)+ USDT(TRC20)收款,打通『下单→付款→自动发激活码→客户端/用户中心兑换→订阅生效』闭环。复用已就绪后端:POST /webhook/store/codes(HMAC+时间戳+nonce)、POST /v1/redeem(JWT)、批次生成/导出(server/internal/codes)。收款终点=单一自托管 USDT 钱包,变现经交易所出金到 US LLC(记账走 accounting)。真相源三文档:docs/payment-a-selfhosted-store-plan.html · payment-channels-overview.html · payment-reseller-fulfillment-design.html。tier-1:含新增 /internal/codes/mint(模型B 单源产码)+ 跨 server/发卡网/客户端/运维。待定:钱包(自托管热钱包 vs 交易所地址)、USDT 路子(自建 TronGrid watcher vs 加密网关)、起步用模型A(预充卡密,零胶水当天可卖)。开工前按 tier-1 走 plan→propose→approve</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="支付">支付</span> <span class="tag t-tag" data-tag="运维">运维</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-06-30</span>
<span class="meta-date">🕐 2026-07-08</span>
</div>
</div>
@@ -626,6 +693,68 @@ header .header-meta{color:var(--fg-soft)}
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-06-22</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="19"
data-level="low"
data-status="open"
data-tier="3"
data-tags="前端,Android,iOS">
<div class="card-header">
<span class="item-id">#19</span>
<span class="item-title">连接页 resume 时整页闪 spinner(锁屏久重开)</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-low">一般 / 优化</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">长锁屏重开 app 连接页整页 blank 成全屏转圈再回来。根因:MeNotifier.build 里 watch(authProvider),token 过期后 resume 首个请求 401 刷 token,authProvider 变,meProvider 重建进 AsyncLoading,meLoadingProvider(=me.isLoading)true,connect_page:38 整页转圈。功能无碍(VPN 没断),仅体验跳。改:meLoadingProvider 只在真首次(还没成功拿过账户数据)整页转圈;已有数据后的刷新保持连接页不动、后台静默更新,同时保留原意(别先闪免费再跳会员)。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-01</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="24"
data-level="low"
data-status="open"
data-tier="1"
data-tags="前端,后端,数据库,mac,Windows,Android,iOS">
<div class="card-header">
<span class="item-id">#24</span>
<span class="item-title">联系我们渠道二级页:Telegram/LINE 多频道/群组,DB 配置</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-low">一般 / 优化</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">点渠道进二级页,按 kind 分组(频道/群组/Bot)列多条链接,内容 DB 配置。新表 contact_link + GET /v1/contact + 各端 UI。设计:docs/contact-telegram-channels-design.html。待用户统一交互后开发。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
</div>
</div>
</li>
@@ -645,75 +774,253 @@ header .header-meta{color:var(--fg-soft)}
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">2</span>
🔍 待验收 <span class="s-count">6</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
<ul class="todo-list" id="list-done">
<li class="todo-card s-done"
data-id="18"
data-level="mid"
data-id="25"
data-level="high"
data-status="done"
data-tier="3"
data-tags="mac,前端">
data-tier="2"
data-tags="后端,前端,CI/CD">
<div class="card-header">
<span class="item-id">#18</span>
<span class="item-title">看门狗后台唤醒误判节点死→假重连</span>
<span class="item-id">#25</span>
<span class="item-title">控制面上 TLS:域名 + 反代证书,客户端默认 https(F1)</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3"></span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2"></span>
<button class="reject-btn" data-id="18" data-title="看门狗后台唤醒误判节点死→假重连">拒绝验收</button>
<button class="reject-btn" data-id="25" data-title="控制面上 TLS:域名 + 反代证书,客户端默认 https(F1)">拒绝验收</button>
</div>
</div>
<div class="item-desc">app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判&gt;45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。</div>
<div class="item-desc">审查 F1(P0 安全):当前客户端默认 http://103.119.13.48:8080 明文,密码/JWT 裸奔公网。需先有域名/HTTP 服务:域名解析到 pangolin1 → Caddy/nginx 自动 Let's Encrypt 反代 :8080 → 客户端 api_config.dart 默认改 https → Android 移除 usesCleartextTraffic → 8080 收回 loopback。见 docs/code-review-2026-07.html</div>
<div class="gate-block gate-granted">
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
<span class="gate-kind">二级改动说明</span>
<span class="gate-date">2026-07-06</span></div>
<div class="gate-note">控制面上 TLS 走 CF Tunnel:cloudflared 出站隧道前置 api.yanmeiai.com→127.0.0.1:8080(不占 :443,sing-box REALITY 不动)。6 个 Task:①建隧道+装 cloudflared(加法上线,与旧口并存)②客户端默认切 https+去 Android 明文③CI 守护禁明文④PANGOLIN_PUBLIC_URL 切 https(规则集下载基址)⑤待客户端迁移后收 8080 loopback+关防火墙+修健康检查⑥文档。fallback 拆到 #32。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code></div>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-01</span>
<span class="meta-date">🕐 2026-07-02</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="19"
data-id="26"
data-level="high"
data-status="done"
data-tier="3"
data-tags="后端,CI/CD,数据库">
<div class="card-header">
<span class="item-id">#26</span>
<span class="item-title">SQLite 每日备份 + 异地容灾(F2)</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="26" data-title="SQLite 每日备份 + 异地容灾(F2">拒绝验收</button>
</div>
</div>
<div class="item-desc">审查 F2(P0 运维):pangolin1 生产 SQLite 无任何备份,单盘单机。最简:cron 每日 sqlite3 .backup + rclone 推 R2/S3 异地保留 30 天;更优 Litestream 持续复制。恢复流程写 runbook 并演练。见 docs/code-review-2026-07.html</div>
<div class="gate-block gate-info">
<div class="gate-head"><span class="gate-badge info">改动说明</span><span class="gate-kind">三级 · 可直接执行</span></div>
<div class="gate-note">定时 Gitea Actions 备份工作流(镜像 jiu backup.yml):scripts/ci/backup-db.sh(ssh pangolin1 → sqlite3 online .backup 取 WAL 一致快照 + 远端 integrity_check → gzip 流回 → 存 NAS /volume1/docker/backups/pangolin,保留30天)+ .gitea/workflows/backup.yml(每日02:00北京 cron + 手动,ubuntu-latest,复用 DEPLOY_SSH_KEY,Telegram 通知)。异地=家里NAS≠VPS机房。不动 pangolin1。</div>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="29"
data-level="high"
data-status="done"
data-tier="1"
data-tags="warp,network,reddit,pangolin1">
<div class="card-header">
<span class="item-id">#29</span>
<span class="item-title">给 pangolin1 挂 Cloudflare WARP 出站分流:信誉敏感站走 WARP 干净 IP</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<button class="reject-btn" data-id="29" data-title="给 pangolin1 挂 Cloudflare WARP 出站分流:信誉敏感站走 WARP 干净 IP">拒绝验收</button>
</div>
</div>
<div class="item-desc">背景与目标:
pangolin1 出口是机房脏 IP,访问 Reddit 会触发 403 / 验证码。用 Cloudflare WARP 作为 pangolin1 的一条出站(egress),按目的地分流——把信誉敏感流量(Reddit 等)从 WARP 出,出口 IP 变成 Cloudflare 干净 IP,验证码/403 基本消失;其余流量仍直出保满速。WARP 免费(住宅代理按 GB 收费)。参考本地知识库 /Users/wangjia/code/reddit-karma/docs/sni-throttle-warp.html。
实现步骤(pangolin1sing-box 数据面):
1) 在 pangolin1 用 wgcf(或 warp-go)注册并生成 WARP 的 WireGuard 配置 profile。
2) sing-box 增加一个 wireguard 类型 outboundtag=warp。
3) 增加路由规则:下方「走 WARP 的域名」匹配到 outbound=warp;其余走 direct。
4) 重启 sing-box 并验证。
走 WARP 的域名(用 domain_suffix,自动覆盖子域):
- reddit.com(含 www / old / new / sh / oauth.reddit.com API
- redd.it(短链)
- i.redd.it、v.redd.it(图片/视频媒体)
- redditstatic.com(静态资源)
- redditmedia.com(媒体)
先只放 Reddit 全家桶;后续若遇其它易弹验证码的社交站,再按同样方式往这条规则里追加,规则集可扩展。
保持直出(不走 WARP):
- 其余所有流量,尤其大流量/大下载——避免 WARP 免费版吞吐瓶颈,保满速。
约束:
- 改机器前遵循 notify-before-system-changes:先只读勘察 pangolin1 现有 sing-box 配置与是否已装 wgcf → 给出改动清单请用户确认 → 再改 + 重启验证。
- WARP 配置/密钥不入 gitwgcf 生成的 profile 落 pangolin1 本地(/etc 下),敏感值走 Bitwarden 记录,不提交明文。
- WARP 本质仍是数据中心 IP,对最严反欺诈可能仍被标记,但对 Reddit 足够;WARP 不作翻墙用途。
验收标准:
- 经 pangolin 访问 reddit.com 不再出现 403 / 验证码;
- curl 确认 reddit 路由的出口 IP 属 Cloudflare 段,而其它流量出口仍为 pangolin1;
- 直出流量速度不受影响。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="warp">warp</span> <span class="tag t-tag" data-tag="network">network</span> <span class="tag t-tag" data-tag="reddit">reddit</span> <span class="tag t-tag" data-tag="pangolin1">pangolin1</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="30"
data-level="high"
data-status="done"
data-tier="1"
data-tags="CI/CD,后端,前端,mac,Windows,Android,iOS">
<div class="card-header">
<span class="item-id">#30</span>
<span class="item-title">CI/CD 全流程:tag 触发编译/发版/部署到 pangolin1(server+agent+client 产物+官网)</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<span class="tag sub-progress-badge">2/2 子任务</span>
<button class="reject-btn" data-id="30" data-title="CI/CD 全流程:tag 触发编译/发版/部署到 pangolin1(server+agent+client 产物+官网)">拒绝验收</button>
</div>
</div>
<div class="item-desc">参考 jiu 的 .gitea/workflows(checks/deploy-server/deploy-site/deploy-client/build-windows/backup),套路:tag 触发(server-v*/site-v*/client-v*)→ checkout → provision(幂等)→ compile(scripts/ci/*.sh)→ go test/flutter test → 建 Gitea/Forgejo release 传产物 → ssh 部署目标机;国内镜像 goproxy.cn/flutter-io.cn。pangolin 差异:①部署机=pangolin1(103.119.13.48,非 jiu 的 ali/EC2),走 ssh 别名;②runner label=nas;③server 端要跑 migrate(SQLite,先备份再迁);④client 产出 apk(split-per-abi arm64)+ macOS 公证 dmg + Windows exe + 未来 iOS,这些产物正是官网下载链接的来源(接 web/website Download.astro 的 href)。现有仅 .gitea/workflows/ci.yml(校验,无部署)。含:官网 Astro 构建+部署、server/agent/migrate 编译部署、客户端多端出包与 release 挂载。</div>
<div class="gate-block gate-granted">
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
<span class="gate-kind">一级方案规划</span>
<span class="gate-date">2026-07-05</span></div>
<div class="gate-note">三期 11 任务(基座+官网+服务端 / Android / macOS+Windows);镜像 jiu scripts/ci+workflows,runner nas/mac/windows,tag 触发;服务端部署固化 F3/F4 备份/迁移/回滚;下载链接接 release;密钥已建对齐 jiu。设计 docs/cicd-design.html,计划 docs/cicd-plan.html。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-05-cicd.md</code></div>
</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">2 / 2 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:100%"></div></div>
</div>
<ul class="subtask-list"><li class="subtask-item s-done" data-sid="30A">
<span class="sub-icon s-done"></span>
<span class="sub-sid">30A</span>
<span class="sub-title">官网入口接真实 URL:部署后恢复 Header 登录(→usercenter)+ 下载按钮 href(→客户端产物)</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-done">待验收</span></div>
</li>
<li class="subtask-item s-done" data-sid="30B">
<span class="sub-icon s-done"></span>
<span class="sub-sid">30B</span>
<span class="sub-title">官网部署:静态站发布到 pangolin.yanmeiai.com + CF DNS 记录指向服务器</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-done">待验收</span></div>
</li></ul>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="CI/CD">CI/CD</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-05</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="33"
data-level="mid"
data-status="done"
data-tier="1"
data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD">
data-tags="前端,Web,mac,Windows,Android,iOS">
<div class="card-header">
<span class="item-id">#19</span>
<span class="item-title">前端设计系统治理重构(ds-flow 全端)</span>
<span class="item-id">#33</span>
<span class="item-title">三端多语言(6语)+ 默认英文</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-1">一级</span>
<button class="reject-btn" data-id="19" data-title="前端设计系统治理重构(ds-flow 全端)">拒绝验收</button>
<button class="reject-btn" data-id="33" data-title="三端多语言(6语)+ 默认英文">拒绝验收</button>
</div>
</div>
<div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
<div class="gate-block gate-granted">
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
<span class="gate-kind">一级方案规划</span>
<span class="gate-date">2026-07-07</span></div>
<div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div>
</div>
<div class="item-desc">客户端/官网/用户中心新增 日/韩/俄/西(共6语 zh/en/ja/ko/ru/es),默认语种改英文。官网根路由/=英文、中文挪/zh/、新增/ja//ko//ru//es/;用户中心补入 site-v* CI 部署。B1:硬编码UI文案6语化+修auth错误消息永远显中文bug;B2:语言选择持久化(shared_preferences)。红线扫描0违规。已发 client-v1.0.60 + site-v0.0.6;B1/B2 待 client-v1.0.61。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-07</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="18"
data-level="low"
data-status="done"
data-tier="3"
data-tags="前端,Android,iOS">
<div class="card-header">
<span class="item-id">#18</span>
<span class="item-title">节点判死无备用时自动重连当前节点(而非断开报错)</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-low">一般 / 优化</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="18" data-title="节点判死无备用时自动重连当前节点(而非断开报错)">拒绝验收</button>
</div>
</div>
<div class="item-desc">长挂机后数据面睡死→看门狗 path A 正确判上游死,但智能模式仅1节点/找不到备用时只会 _disconnect+报节点异常,逼用户手动重连(实测挂机2h回来即遇)。改 _onNodeUnhealthy:无备用节点时自动重连当前节点重建通路,加有限次数保护避免节点真挂时死循环。connection_provider.dart 一处。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-01</span>
</div>
</div>
</li>
@@ -722,12 +1029,244 @@ header .header-meta{color:var(--fg-soft)}
</div>
<div class="section-block" id="section-accepted">
<div class="section-title st-accepted" data-toggle="accepted">
✅ 已验收 <span class="s-count">8</span>
✅ 已验收 <span class="s-count">15</span>
<span class="s-arrow">▾ 展开</span>
</div>
<div class="section-list-wrap collapsed" id="list-wrap-accepted">
<ul class="todo-list" id="list-accepted">
<li class="todo-card s-accepted"
data-id="28"
data-level="mid"
data-status="accepted"
data-tier="3"
data-tags="后端,前端">
<div class="card-header">
<span class="item-id">#28</span>
<span class="item-title">disconnect 撤销每设备凭证而非账户级(F4)</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">审查 F4:ConnectNode 发每设备 devDp,DisconnectNode 却吊销账户级 ent.DpUUID 且无 device_id 入参——断开从未真正吊销,凭证活到 TTL。修:disconnect 收 device_id,吊销该设备 dp_uuid(账户级作遗留兜底);客户端带上 device_id。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
<span class="meta-date">✅ 验收 2026-07-05</span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="27"
data-level="high"
data-status="accepted"
data-tier="1"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-id">#27</span>
<span class="item-title">同机换账号设备注册 403 死结:devices 唯一键改 (user_id,uuid)F3</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">审查 F3:device_id 跨账号复用 + devices.uuid 全局 UNIQUE → 第二账号注册永远 403 → 连接被 DEVICE_NOT_REGISTERED 卡死。落地 migration16 注释里推迟的 UNIQUE(user_id,uuid) 表重建,注册查找改按 (user,uuid)。见 docs/code-review-2026-07.html</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
<span class="meta-date">✅ 验收 2026-07-05</span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="22"
data-level="high"
data-status="accepted"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-id">#22</span>
<span class="item-title">用量分钟数超计(多设备并发按 dp_uuid 累加)</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">agent 每 dp_uuid 每窗口计 SessionMinutes=1,控制面 ReportUsage 逐条累加进 usage_daily.minutes_used → N 台并发=N×墙上时钟(chenxin 2903/天)。免费卡控下会错误判耗尽。改:账户维度按 user 分组去重(minutes=max,bytes=sum),每设备维度不变。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
<span class="meta-date">✅ 验收 2026-07-02 · <span class="ver-badge">v1.0.47</span></span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="21"
data-level="high"
data-status="accepted"
data-tier="1"
data-tags="后端,前端,数据库,mac,Windows,Android,iOS">
<div class="card-header">
<span class="item-id">#21</span>
<span class="item-title">免费版10分钟卡控+累加式看广告加时</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">连接期倒计时+到点切断;耗尽按钮灰化;点击弹广告(占位流程)看完+10分钟(累加,每日封顶120);桌面硬10分钟不可延+升级提示。额度全账户共享。服务端ConnectNode按remaining卡控+TTL硬切;ad_bonus_minutes累加模型;DevVerifier占位。</div>
<div class="gate-block gate-granted">
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
<span class="gate-kind">一级方案规划</span>
<span class="gate-date">2026-07-01</span></div>
<div class="gate-note">已进 plan 模式出方案并经用户 ExitPlanMode 批准</div><div class="gate-ref">📄 详见 <code>/Users/wangjia/.claude/plans/crystalline-exploring-lerdorf.md</code></div>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="数据库">数据库</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-01</span>
<span class="meta-date">✅ 验收 2026-07-02 · <span class="ver-badge">v1.0.44</span></span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="23"
data-level="mid"
data-status="accepted"
data-tier="3"
data-tags="mac,前端">
<div class="card-header">
<span class="item-id">#23</span>
<span class="item-title">macOS 最小化托盘后点 Dock 图标无法唤回窗口</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">windowManager.hide() 走 orderOut: 后窗口不可见,点 Dock 图标只发 applicationShouldHandleReopen(hasVisibleWindows=false),AppDelegate 未接管 → 点了没反应。修:AppDelegate 实现 applicationShouldHandleReopen 唤回 mainFlutterWindow。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-02</span>
<span class="meta-date">✅ 验收 2026-07-02 · <span class="ver-badge">v1.0.48</span></span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="20"
data-level="mid"
data-status="accepted"
data-tier="3"
data-tags="Android,iOS,前端">
<div class="card-header">
<span class="item-id">#20</span>
<span class="item-title">看门狗后台唤醒/切后台误判节点死→假断连(已修)</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">后台/挂起期 urltest 被限流停更但 speed 在流→path A 误判上游死断连。修:statsLive 闸 + 切后台(paused/hidden)停看门狗、resumed 重启并重置基准。另修连接时长墙上时钟(切后台不漏计)。已合 main 07861ea/37c1724/58211d2,含回归测试。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-01</span>
<span class="meta-date">✅ 验收 2026-07-01</span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="16"
data-level="mid"
data-status="accepted"
data-tier="1"
data-tags="后端,前端">
<div class="card-header">
<span class="item-id">#16</span>
<span class="item-title">启用设备数量限制 + 超限 UX(踢旧/选择移除,非硬拒登)</span>
<div class="card-badges">
<span class="tag status-badge s-accepted">已验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">现状:plans.max_devices(free1/pro5/team10)+ devices/service.go:216 强制逻辑 + DEVICE_LIMIT_EXCEEDED 错误码 + 账户API DevicesMax 都在,但 main.go:534 登录注册传 MaxDevices:0(不启用)。原因:free 用户重装会换 device UUID,登录硬卡会把人锁外面。需:① 登录/注册从套餐读 max_devices 传入 RegisterIfAbsent;② 超限不直接拒登,改为'踢最旧设备/让用户选哪台下线'的 UX(客户端弹窗 + 设备管理页操作);③ 处理 free 重装 churn(同机复用而非每次新增)。tier-1:涉及登录接口 + 跨后端/前端 + 策略设计。</div>
<div class="gate-block gate-pending">
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
<div class="gate-note">登录卡超限(非硬拒登:token照发+返回device_limit信号,客户端挡在移除设备页)+连接backstop兜底;选择移除+一键踢最旧;服务端按last_seen自动清理久不活跃(staleWindow=30d,活跃count排除+登录best-effort删僵尸)。复用现成 DeleteDevice/ResolvePlan/CheckDeviceQuota/ConnectNode。后端:auth service/handler(recordLogin回传+LoginOutcome.DeviceLimit)、devices(DeviceLimitStatus+活跃count+prune)、main wiring、nodes ConnectNode backstop。前端:auth_api解析、auth_provider路由、DeviceLimitScreen、l10n。无DB schema变更。</div><div class="gate-ref">📄 详见 <code>~/.claude/plans/crystalline-exploring-lerdorf.md</code></div>
<button class="approve-btn" data-id="16">确认执行 ✓</button>
<div class="approve-cmd" id="approve-cmd-16" style="display:none">
<div class="approve-cmd-label">在终端运行:</div>
<code class="approve-cmd-code">/todo approve 16</code>
<button class="approve-copy-btn" data-cmd="/todo approve 16">复制命令</button>
</div>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-06-30</span>
<span class="meta-date">✅ 验收 2026-07-01</span>
</div>
</div>
</li>
<li class="todo-card s-accepted"
data-id="11"
data-level="low"
+362 -26
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "feature+windows — 项目 TODO",
"updated_at": "2026-07-07T17:45:18.036Z"
"updated_at": "2026-07-08T14:10:36.344Z"
},
"seq": 19,
"seq": 34,
"items": [
{
"id": 1,
@@ -19,6 +19,14 @@
"done": false,
"completed_at": null,
"version": null,
"gate": {
"kind": "change",
"note": "macOS L0→L3,改 2 个原生文件(不动 Dart/服务端)。【VpnChannel.swift】①加缓存 killSwitchEnabled(默认true);②setKillSwitch 落地:存标志+manager已存在则重新applyKillSwitchConfig+saveToPreferences即时生效;③loadOrCreateManager 按标志设 proto.includeAllNetworks/enforceRoutes=true/excludeLocalNetworks=true(放行LAN)+mgr.onDemandRules=[NEOnDemandRuleConnect()]+isOnDemandEnabled;④stop() gotcha:先关 isOnDemandEnabled+save 再 stopVPNTunnel,否则on-demand把断开拉回;⑤start() options 带 killSwitch 传扩展。【PacketTunnelProvider.swift】startTunnel 读 killSwitch option 缓存,includeAllNetworks() 回调返回该值与NE层对齐。无需新增entitlement。验证靠真机:连上后杀扩展/拔网验证流量被阻断(过L1→L2质变线)+on-demand开机/掉线自动拉起+手动断开不反弹。",
"ref": "docs/killswitch-design.html",
"approval": "granted",
"proposed_at": "2026-06-30T15:40:37.653Z",
"approved_at": "2026-06-30T15:46:23.512Z"
},
"owner": "agent"
},
{
@@ -264,25 +272,34 @@
"后端",
"前端"
],
"status": "open",
"status": "accepted",
"created_at": "2026-06-30T14:33:48.996Z",
"done": false,
"completed_at": null,
"done": true,
"completed_at": "2026-07-01T14:59:20.954Z",
"version": null,
"gate": {
"kind": "plan",
"note": "登录卡超限(非硬拒登:token照发+返回device_limit信号,客户端挡在移除设备页)+连接backstop兜底;选择移除+一键踢最旧;服务端按last_seen自动清理久不活跃(staleWindow=30d,活跃count排除+登录best-effort删僵尸)。复用现成 DeleteDevice/ResolvePlan/CheckDeviceQuota/ConnectNode。后端:auth service/handler(recordLogin回传+LoginOutcome.DeviceLimit)、devices(DeviceLimitStatus+活跃count+prune)、main wiring、nodes ConnectNode backstop。前端:auth_api解析、auth_provider路由、DeviceLimitScreen、l10n。无DB schema变更。",
"ref": "~/.claude/plans/crystalline-exploring-lerdorf.md",
"approval": "pending",
"proposed_at": "2026-07-01T11:05:42.018Z",
"approved_at": null
},
"owner": "agent"
},
{
"id": 18,
"title": "看门狗后台唤醒误判节点死→假重连",
"desc": "app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。",
"level": "mid",
"title": "节点判死无备用时自动重连当前节点(而非断开报错)",
"desc": "长挂机后数据面睡死→看门狗 path A 正确判上游死,但智能模式仅1节点/找不到备用时只会 _disconnect+报节点异常,逼用户手动重连(实测挂机2h回来即遇)。改 _onNodeUnhealthy:无备用节点时自动重连当前节点重建通路,加有限次数保护避免节点真挂时死循环。connection_provider.dart 一处。",
"level": "low",
"tier": 3,
"tags": [
"mac",
"前端"
"前端",
"Android",
"iOS"
],
"status": "done",
"created_at": "2026-06-30T23:00:25.193Z",
"created_at": "2026-07-01T04:11:47.293Z",
"done": false,
"completed_at": null,
"version": null,
@@ -290,33 +307,352 @@
},
{
"id": 19,
"title": "前端设计系统治理重构(ds-flow 全端)",
"desc": "用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md",
"title": "连接页 resume 时整页闪 spinner(锁屏久重开)",
"desc": "长锁屏重开 app 连接页整页 blank 成全屏转圈再回来。根因:MeNotifier.build 里 watch(authProvider),token 过期后 resume 首个请求 401 刷 token,authProvider 变,meProvider 重建进 AsyncLoading,meLoadingProvider(=me.isLoading)true,connect_page:38 整页转圈。功能无碍(VPN 没断),仅体验跳。改:meLoadingProvider 只在真首次(还没成功拿过账户数据)整页转圈;已有数据后的刷新保持连接页不动、后台静默更新,同时保留原意(别先闪免费再跳会员)。",
"level": "low",
"tier": 3,
"tags": [
"前端",
"Android",
"iOS"
],
"status": "open",
"created_at": "2026-07-01T04:13:36.446Z",
"done": false,
"completed_at": null,
"version": null,
"owner": "agent"
},
{
"id": 20,
"title": "看门狗后台唤醒/切后台误判节点死→假断连(已修)",
"desc": "后台/挂起期 urltest 被限流停更但 speed 在流→path A 误判上游死断连。修:statsLive 闸 + 切后台(paused/hidden)停看门狗、resumed 重启并重置基准。另修连接时长墙上时钟(切后台不漏计)。已合 main 07861ea/37c1724/58211d2,含回归测试。",
"level": "mid",
"tier": 3,
"tags": [
"Android",
"iOS",
"前端"
],
"status": "accepted",
"created_at": "2026-07-01T04:14:03.051Z",
"done": true,
"completed_at": "2026-07-01T14:59:21.052Z",
"version": null,
"owner": "agent"
},
{
"id": 21,
"title": "免费版10分钟卡控+累加式看广告加时",
"desc": "连接期倒计时+到点切断;耗尽按钮灰化;点击弹广告(占位流程)看完+10分钟(累加,每日封顶120);桌面硬10分钟不可延+升级提示。额度全账户共享。服务端ConnectNode按remaining卡控+TTL硬切;ad_bonus_minutes累加模型;DevVerifier占位。",
"level": "high",
"tier": 1,
"tags": [
"后端",
"前端",
"数据库",
"mac",
"Windows",
"Android",
"iOS"
],
"status": "accepted",
"created_at": "2026-07-01T15:30:27.692Z",
"done": true,
"completed_at": "2026-07-02T03:21:30.577Z",
"version": "v1.0.44",
"gate": {
"kind": "plan",
"note": "已进 plan 模式出方案并经用户 ExitPlanMode 批准",
"ref": "/Users/wangjia/.claude/plans/crystalline-exploring-lerdorf.md",
"approval": "granted",
"proposed_at": "2026-07-01T15:30:53.245Z",
"approved_at": "2026-07-01T15:31:08.948Z"
},
"owner": "agent"
},
{
"id": 22,
"title": "用量分钟数超计(多设备并发按 dp_uuid 累加)",
"desc": "agent 每 dp_uuid 每窗口计 SessionMinutes=1,控制面 ReportUsage 逐条累加进 usage_daily.minutes_used → N 台并发=N×墙上时钟(chenxin 2903/天)。免费卡控下会错误判耗尽。改:账户维度按 user 分组去重(minutes=max,bytes=sum),每设备维度不变。",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "accepted",
"created_at": "2026-07-02T01:34:10.026Z",
"done": true,
"completed_at": "2026-07-02T03:21:42.079Z",
"version": "v1.0.47",
"owner": "agent"
},
{
"id": 23,
"title": "macOS 最小化托盘后点 Dock 图标无法唤回窗口",
"desc": "windowManager.hide() 走 orderOut: 后窗口不可见,点 Dock 图标只发 applicationShouldHandleReopen(hasVisibleWindows=false),AppDelegate 未接管 → 点了没反应。修:AppDelegate 实现 applicationShouldHandleReopen 唤回 mainFlutterWindow。",
"level": "mid",
"tier": 3,
"tags": [
"mac",
"前端"
],
"status": "accepted",
"created_at": "2026-07-02T02:50:26.908Z",
"done": true,
"completed_at": "2026-07-02T03:21:30.418Z",
"version": "v1.0.48",
"owner": "agent"
},
{
"id": 24,
"title": "联系我们渠道二级页:Telegram/LINE 多频道/群组,DB 配置",
"desc": "点渠道进二级页,按 kind 分组(频道/群组/Bot)列多条链接,内容 DB 配置。新表 contact_link + GET /v1/contact + 各端 UI。设计:docs/contact-telegram-channels-design.html。待用户统一交互后开发。",
"level": "low",
"tier": 1,
"tags": [
"前端",
"后端",
"数据库",
"mac",
"Windows",
"Android",
"iOS"
],
"status": "open",
"created_at": "2026-07-02T04:22:44.667Z",
"done": false,
"completed_at": null,
"version": null,
"owner": "agent"
},
{
"id": 25,
"title": "控制面上 TLS:域名 + 反代证书,客户端默认 https(F1)",
"desc": "审查 F1(P0 安全):当前客户端默认 http://103.119.13.48:8080 明文,密码/JWT 裸奔公网。需先有域名/HTTP 服务:域名解析到 pangolin1 → Caddy/nginx 自动 Let's Encrypt 反代 :8080 → 客户端 api_config.dart 默认改 https → Android 移除 usesCleartextTraffic → 8080 收回 loopback。见 docs/code-review-2026-07.html",
"level": "high",
"tier": 2,
"tags": [
"后端",
"前端",
"CI/CD"
],
"status": "done",
"created_at": "2026-07-02T05:07:08.281Z",
"done": false,
"completed_at": null,
"version": null,
"gate": {
"kind": "change",
"note": "控制面上 TLS 走 CF Tunnel:cloudflared 出站隧道前置 api.yanmeiai.com→127.0.0.1:8080(不占 :443,sing-box REALITY 不动)。6 个 Task:①建隧道+装 cloudflared(加法上线,与旧口并存)②客户端默认切 https+去 Android 明文③CI 守护禁明文④PANGOLIN_PUBLIC_URL 切 https(规则集下载基址)⑤待客户端迁移后收 8080 loopback+关防火墙+修健康检查⑥文档。fallback 拆到 #32。",
"ref": "docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md",
"approval": "granted",
"proposed_at": "2026-07-06T08:24:55.562Z",
"approved_at": "2026-07-06T08:27:20.492Z"
},
"owner": "agent"
},
{
"id": 26,
"title": "SQLite 每日备份 + 异地容灾(F2",
"desc": "审查 F2(P0 运维):pangolin1 生产 SQLite 无任何备份,单盘单机。最简:cron 每日 sqlite3 .backup + rclone 推 R2/S3 异地保留 30 天;更优 Litestream 持续复制。恢复流程写 runbook 并演练。见 docs/code-review-2026-07.html",
"level": "high",
"tier": 3,
"tags": [
"后端",
"CI/CD",
"数据库"
],
"status": "done",
"created_at": "2026-07-02T05:07:08.354Z",
"done": false,
"completed_at": null,
"version": null,
"gate": {
"kind": "change",
"note": "定时 Gitea Actions 备份工作流(镜像 jiu backup.yml):scripts/ci/backup-db.sh(ssh pangolin1 → sqlite3 online .backup 取 WAL 一致快照 + 远端 integrity_check → gzip 流回 → 存 NAS /volume1/docker/backups/pangolin,保留30天)+ .gitea/workflows/backup.yml(每日02:00北京 cron + 手动,ubuntu-latest,复用 DEPLOY_SSH_KEY,Telegram 通知)。异地=家里NAS≠VPS机房。不动 pangolin1。",
"ref": null,
"approval": "none",
"proposed_at": "2026-07-06T06:53:10.439Z",
"approved_at": null
},
"owner": "agent"
},
{
"id": 27,
"title": "同机换账号设备注册 403 死结:devices 唯一键改 (user_id,uuid)F3",
"desc": "审查 F3:device_id 跨账号复用 + devices.uuid 全局 UNIQUE → 第二账号注册永远 403 → 连接被 DEVICE_NOT_REGISTERED 卡死。落地 migration16 注释里推迟的 UNIQUE(user_id,uuid) 表重建,注册查找改按 (user,uuid)。见 docs/code-review-2026-07.html",
"level": "high",
"tier": 1,
"tags": [
"后端",
"数据库"
],
"status": "accepted",
"created_at": "2026-07-02T05:07:56.717Z",
"done": true,
"completed_at": "2026-07-05T08:54:08.901Z",
"version": null,
"owner": "agent"
},
{
"id": 28,
"title": "disconnect 撤销每设备凭证而非账户级(F4)",
"desc": "审查 F4:ConnectNode 发每设备 devDp,DisconnectNode 却吊销账户级 ent.DpUUID 且无 device_id 入参——断开从未真正吊销,凭证活到 TTL。修:disconnect 收 device_id,吊销该设备 dp_uuid(账户级作遗留兜底);客户端带上 device_id。",
"level": "mid",
"tier": 3,
"tags": [
"后端",
"前端"
],
"status": "accepted",
"created_at": "2026-07-02T05:20:47.843Z",
"done": true,
"completed_at": "2026-07-05T08:54:08.986Z",
"version": null,
"owner": "agent"
},
{
"id": 29,
"title": "给 pangolin1 挂 Cloudflare WARP 出站分流:信誉敏感站走 WARP 干净 IP",
"desc": "背景与目标:\npangolin1 出口是机房脏 IP,访问 Reddit 会触发 403 / 验证码。用 Cloudflare WARP 作为 pangolin1 的一条出站(egress),按目的地分流——把信誉敏感流量(Reddit 等)从 WARP 出,出口 IP 变成 Cloudflare 干净 IP,验证码/403 基本消失;其余流量仍直出保满速。WARP 免费(住宅代理按 GB 收费)。参考本地知识库 /Users/wangjia/code/reddit-karma/docs/sni-throttle-warp.html。\n\n实现步骤(pangolin1sing-box 数据面):\n1) 在 pangolin1 用 wgcf(或 warp-go)注册并生成 WARP 的 WireGuard 配置 profile。\n2) sing-box 增加一个 wireguard 类型 outboundtag=warp。\n3) 增加路由规则:下方「走 WARP 的域名」匹配到 outbound=warp;其余走 direct。\n4) 重启 sing-box 并验证。\n\n走 WARP 的域名(用 domain_suffix,自动覆盖子域):\n- reddit.com(含 www / old / new / sh / oauth.reddit.com API\n- redd.it(短链)\n- i.redd.it、v.redd.it(图片/视频媒体)\n- redditstatic.com(静态资源)\n- redditmedia.com(媒体)\n先只放 Reddit 全家桶;后续若遇其它易弹验证码的社交站,再按同样方式往这条规则里追加,规则集可扩展。\n\n保持直出(不走 WARP):\n- 其余所有流量,尤其大流量/大下载——避免 WARP 免费版吞吐瓶颈,保满速。\n\n约束:\n- 改机器前遵循 notify-before-system-changes:先只读勘察 pangolin1 现有 sing-box 配置与是否已装 wgcf → 给出改动清单请用户确认 → 再改 + 重启验证。\n- WARP 配置/密钥不入 gitwgcf 生成的 profile 落 pangolin1 本地(/etc 下),敏感值走 Bitwarden 记录,不提交明文。\n- WARP 本质仍是数据中心 IP,对最严反欺诈可能仍被标记,但对 Reddit 足够;WARP 不作翻墙用途。\n\n验收标准:\n- 经 pangolin 访问 reddit.com 不再出现 403 / 验证码;\n- curl 确认 reddit 路由的出口 IP 属 Cloudflare 段,而其它流量出口仍为 pangolin1;\n- 直出流量速度不受影响。",
"level": "high",
"tier": 1,
"tags": [
"warp",
"network",
"reddit",
"pangolin1"
],
"status": "done",
"created_at": "2026-07-02T05:25:38.314Z",
"done": false,
"completed_at": null,
"version": null,
"owner": "agent"
},
{
"id": 30,
"title": "CI/CD 全流程:tag 触发编译/发版/部署到 pangolin1(server+agent+client 产物+官网)",
"desc": "参考 jiu 的 .gitea/workflows(checks/deploy-server/deploy-site/deploy-client/build-windows/backup),套路:tag 触发(server-v*/site-v*/client-v*)→ checkout → provision(幂等)→ compile(scripts/ci/*.sh)→ go test/flutter test → 建 Gitea/Forgejo release 传产物 → ssh 部署目标机;国内镜像 goproxy.cn/flutter-io.cn。pangolin 差异:①部署机=pangolin1(103.119.13.48,非 jiu 的 ali/EC2),走 ssh 别名;②runner label=nas;③server 端要跑 migrate(SQLite,先备份再迁);④client 产出 apk(split-per-abi arm64)+ macOS 公证 dmg + Windows exe + 未来 iOS,这些产物正是官网下载链接的来源(接 web/website Download.astro 的 href)。现有仅 .gitea/workflows/ci.yml(校验,无部署)。含:官网 Astro 构建+部署、server/agent/migrate 编译部署、客户端多端出包与 release 挂载。",
"level": "high",
"tier": 1,
"tags": [
"CI/CD",
"后端",
"前端",
"mac",
"Windows",
"Android",
"iOS"
],
"status": "done",
"created_at": "2026-07-05T09:27:34.399Z",
"done": false,
"completed_at": null,
"version": null,
"subtasks": [
{
"sid": "30A",
"title": "官网入口接真实 URL:部署后恢复 Header 登录(→usercenter)+ 下载按钮 href(→客户端产物)",
"tier": 2,
"deps": [],
"status": "done",
"created_at": "2026-07-05T09:56:30.954Z"
},
{
"sid": "30B",
"title": "官网部署:静态站发布到 pangolin.yanmeiai.com + CF DNS 记录指向服务器",
"tier": 2,
"deps": [],
"status": "done",
"created_at": "2026-07-05T11:19:59.363Z"
}
],
"gate": {
"kind": "plan",
"note": "三期 11 任务(基座+官网+服务端 / Android / macOS+Windows);镜像 jiu scripts/ci+workflows,runner nas/mac/windows,tag 触发;服务端部署固化 F3/F4 备份/迁移/回滚;下载链接接 release;密钥已建对齐 jiu。设计 docs/cicd-design.html,计划 docs/cicd-plan.html。",
"ref": "docs/superpowers/plans/2026-07-05-cicd.md",
"approval": "granted",
"proposed_at": "2026-07-05T15:41:17.465Z",
"approved_at": "2026-07-05T15:45:12.499Z"
},
"owner": "agent"
},
{
"id": 31,
"title": "官网文档页:撰写 快速开始/FAQ/协议与安全/隐私政策 4 页,接回 Docs 卡片链接",
"desc": "web/website Docs 区当前 4 张卡为占位信息卡(不可点)。需撰写真实文档页(可用 Astro content/MDX 或 docs 站),写好后把 Docs.astro 卡片改回 <a href> 并恢复「阅读」CTA、页脚 文档/FAQ/隐私政策 指向对应页。注意:隐私政策属法律文本,由创始人审定,Claude 仅出初稿;对「无日志」VPN,隐私政策是软性上线门槛。",
"level": "mid",
"tier": 2,
"tags": [
"Web",
"文档"
],
"status": "open",
"created_at": "2026-07-05T10:08:52.391Z",
"done": false,
"completed_at": null,
"version": null,
"owner": "agent"
},
{
"id": 32,
"title": "控制面 fallback:CF 域名被 SNI 封时客户端退回直连节点 IP 的 https 控制口",
"desc": "#25 采用 CF Tunnel 前置控制面(api.yanmeiai.com)。已知弱点:该域名可被 GFW 单独 SNI 封锁,届时登录/拉配置不可用(已连隧道不受影响,数据面 REALITY:443 直连也不受影响)。加固:客户端 api 层保留一个直连某健康节点 IP 的 https 控制面 fallback——域名连不上/握手被 RST 时自动退回直连,保证仍能登录/刷新配置/换节点。服务端需在节点上也暴露一个可直连的 https 控制口(或复用现有端口+证书)。属 #25 的后续加固,不在 #25 本轮范围。",
"level": "mid",
"tier": 2,
"tags": [
"前端",
"后端",
"Android",
"iOS"
],
"status": "open",
"created_at": "2026-07-06T08:15:57.519Z",
"done": false,
"completed_at": null,
"version": null,
"owner": "agent"
},
{
"id": 33,
"title": "三端多语言(6语)+ 默认英文",
"desc": "客户端/官网/用户中心新增 日/韩/俄/西(共6语 zh/en/ja/ko/ru/es),默认语种改英文。官网根路由/=英文、中文挪/zh/、新增/ja//ko//ru//es/;用户中心补入 site-v* CI 部署。B1:硬编码UI文案6语化+修auth错误消息永远显中文bug;B2:语言选择持久化(shared_preferences)。红线扫描0违规。已发 client-v1.0.60 + site-v0.0.6;B1/B2 待 client-v1.0.61。",
"level": "mid",
"tier": 1,
"tags": [
"前端",
"Web",
"mac",
"iOS",
"Android",
"Windows",
"CI/CD"
"Android",
"iOS"
],
"owner": "agent",
"status": "done",
"created_at": "2026-07-07T15:41:25.760Z",
"created_at": "2026-07-07T08:36:36.295Z",
"done": false,
"completed_at": null,
"version": null,
"gate": {
"kind": "plan",
"note": "6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。",
"ref": "docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md",
"approval": "granted",
"proposed_at": "2026-07-07T15:41:36.536Z",
"approved_at": "2026-07-07T15:54:54.386Z"
}
"version": null
},
{
"id": 34,
"title": "独角数卡 + USDT 收款闭环",
"desc": "自建发卡网(独角数卡,独立小 VPS 隔离部署,不放 pangolin1)+ USDT(TRC20)收款,打通『下单→付款→自动发激活码→客户端/用户中心兑换→订阅生效』闭环。复用已就绪后端:POST /webhook/store/codes(HMAC+时间戳+nonce)、POST /v1/redeem(JWT)、批次生成/导出(server/internal/codes)。收款终点=单一自托管 USDT 钱包,变现经交易所出金到 US LLC(记账走 accounting)。真相源三文档:docs/payment-a-selfhosted-store-plan.html · payment-channels-overview.html · payment-reseller-fulfillment-design.html。tier-1:含新增 /internal/codes/mint(模型B 单源产码)+ 跨 server/发卡网/客户端/运维。待定:钱包(自托管热钱包 vs 交易所地址)、USDT 路子(自建 TronGrid watcher vs 加密网关)、起步用模型A(预充卡密,零胶水当天可卖)。开工前按 tier-1 走 plan→propose→approve。",
"level": "mid",
"tier": 1,
"tags": [
"后端",
"支付",
"运维"
],
"owner": "agent",
"status": "open",
"created_at": "2026-07-08T14:10:36.343Z",
"done": false,
"completed_at": null,
"version": null
}
]
}