ceabc7977b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
16 KiB
HTML
115 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>jiu 授权续费对接 pay v2 改造计划 — 酒库管理系统</title><style>
|
||
:root{
|
||
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
|
||
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
|
||
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
|
||
}
|
||
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.7;max-width:1000px;}
|
||
h1{font-size:22px;margin:0 0 4px;}
|
||
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
|
||
h3{font-size:14.5px;margin:18px 0 8px;color:var(--primary-dark);}
|
||
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
|
||
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;font-size:13.5px;}
|
||
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
|
||
pre{background:#0F1722;color:#D8E1EC;border-radius:8px;padding:12px 14px;overflow-x:auto;font-size:12px;line-height:1.55;}
|
||
pre code{background:none;color:inherit;padding:0;}
|
||
ul,ol{margin:6px 0;padding-left:22px;} li{margin:4px 0;font-size:13px;}
|
||
table{border-collapse:collapse;width:100%;margin:10px 0;font-size:12.5px;background:#fff;}
|
||
th,td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;}
|
||
th{background:var(--head);color:var(--primary-dark);white-space:nowrap;}
|
||
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;margin-right:6px;font-weight:600;}
|
||
.tag.keep{background:var(--success-bg);color:var(--success);}
|
||
.tag.change{background:var(--warn-bg);color:var(--warn);}
|
||
.tag.risk{background:var(--danger-bg);color:var(--danger);}
|
||
.chk{color:var(--success);font-weight:700;margin-right:4px;}
|
||
.hl{background:var(--warn-bg);padding:0 3px;border-radius:3px;}
|
||
.wrap{overflow-x:auto;}
|
||
</style></head><body>
|
||
<h1>jiu 授权续费对接 pay v2 改造计划</h1>
|
||
<div class="sub">2026-07-10 · 状态:<b>已批准,执行中</b> · 执行真相源 = <code>docs/plans/2026-07-10-pay-v2-integration.md</code>(checkbox),本页为阅读版 · pay v2 契约读自 <code>design/pay-v2</code> 分支代码(非文档口径)· 已并入授权管理独立屏三 tab 实现(原型评审通过 13b7274)</div>
|
||
|
||
<h2>Context:调研结论改变了任务定位</h2><div class="card">
|
||
<p style="margin:4px 0"><b>jiu 两侧的 pay v1 对接已经完整上线</b>,本任务不是从零接入,而是<b>契约升级(v1 → v2)</b>:</p>
|
||
<ul>
|
||
<li>后端 <code>backend/internal/service/pay.go</code> 已有 <code>PayService</code> 四职责:签名下单 <code>CreatePurchase</code> / webhook 入账 <code>HandleCallback→settle</code>(FOR UPDATE + out_trade_no 幂等 + 金额核对)/ 续期 <code>entitle</code>(max(现到期,now)+时长)/ 60s 查单兜底 <code>reconcileOnce</code>;表 <code>license_purchases</code>、配置 <code>PAY_BASE_URL/PAY_SECRET/PAY_RETURN_URL</code>、路由 <code>POST /api/v1/pay/callback</code> 全部就位</li>
|
||
<li>客户端 <code>purchase_card.dart</code> 已有三态状态机(选套餐 → launchUrl 拉起收银台 → 3s 轮询 + 30s 心跳兜底),iOS <code>hideExternalPurchaseUi</code> 合规开关、promo 限购、到期横幅入口都在</li>
|
||
<li>官网 <code>web/checkout.njk:167</code> 直读下单响应的 <code>d.pay_url</code> 跳收银台——<span class="hl">兼容约束的来源</span></li>
|
||
</ul>
|
||
<p style="margin:4px 0"><span class="tag keep">零改动沿用</span>HMAC 签名算法(<code>util.PaySign</code>:base64(HMAC-SHA256(secret, "jiu\n ts\n nonce\n rawBody"))、±5min 窗)、out_trade_no 幂等键、entitle 续期语义、路由挂载、PAY_* 配置链、iOS 合规开关、轮询/心跳。</p>
|
||
</div>
|
||
|
||
<h2>v2 契约 delta(已读 design/pay-v2 代码核实)</h2><div class="wrap">
|
||
<table>
|
||
<tr><th>项</th><th>v1(现实现)</th><th>v2(本计划目标)</th></tr>
|
||
<tr><td><b>下单</b></td><td><code>POST /api/v1/orders</code><br>{product_id, biz_system, biz_ref, return_url, client_type}</td><td><code>POST /api/v2/orders</code> {sku, method, biz_system, biz_ref, return_url};<b>sku 直接用 biz_code</b>(productID 查询+10min 缓存整段删除);<span class="tag risk">无 client_type</span></td></tr>
|
||
<tr><td><b>下单响应</b></td><td>{pay_url, out_trade_no, amount("2999.00"), subject}</td><td>{order_no, session:{render_type, payload, expires_at?}};alipay 落 <code>redirect</code> + payload.url;<span class="tag change">不回传金额</span></td></tr>
|
||
<tr><td><b>查单</b></td><td><code>GET /api/v1/orders/:otn</code>,四态</td><td><code>GET /api/v2/orders/:no</code>(无鉴权,见风险节),{order_no, status, subject, amount_minor, currency, paid_at?},八态 created|pending|paid|canceled|expired|refunding|partially_refunded|refunded;不回传 biz_ref</td></tr>
|
||
<tr><td><b>webhook</b></td><td>扁平 payload,amount 元串,有 trade_no</td><td>+<code>event_type</code>(当前仅 payment.succeeded)+ X-Pay-Event 头(<b>不参与签名</b>);amount_minor int64 + currency;<b>无 trade_no</b>;重投固定 60s 无退避无死信;成功判定 = 200 且 body 含 "SUCCESS"(大小写不敏感,现回包 {"code":"SUCCESS"} 天然满足)</td></tr>
|
||
<tr><td><b>签名</b></td><td colspan="2"><span class="tag keep">完全一致</span>四段 \n 拼接 HMAC-SHA256 → base64 标准编码,<code>util.PaySign/PaySignVerify</code> 零改动;pay 侧无 nonce 去重,jiu 侧自建(D3)</td></tr>
|
||
<tr><td><b>金额</b></td><td>string 元</td><td>amount_minor int64(CNY=分)+ currency 大写码,全链路禁 float</td></tr>
|
||
<tr><td><b>retry / cancel</b></td><td>无</td><td>retry:409 currency_mismatch=需换方式新建单(jiu 单渠道不接,D6);cancel:恒 200 {"data":{"canceled":bool}},仅 pending 翻转成功(jiu 接,防单堆积)</td></tr>
|
||
</table></div>
|
||
|
||
<div class="card"><b>pay 侧欠账(jiu 无法解决,列联调前置依赖,不阻塞开发):</b>
|
||
<ol>
|
||
<li><span class="tag risk">wap 缺口</span><b>手机拉起支付宝 App 断线的唯一根因</b>:v2 下单无 client_type、gateway 不设 Metadata["is_mobile"] → alipay 恒走 page.pay。redirect 机制本身没有问题;pay 补契约后 jiu 跟进一行</li>
|
||
<li><code>qr</code> render_type 仅文档口径无代码实现——<b>体验增强项而非缺失</b>:alipay PC 收银台页面本身含二维码,桌面扫码今天即可用,qr 内嵌只是省一次浏览器跳转</li>
|
||
<li><code>seedPlans</code> 仅 alipay_sandbox 开关下执行,生产套餐种子需部署时确认</li>
|
||
<li>pay 侧需配置 <code>BIZ_JIU_SECRET</code> / <code>BIZ_JIU_CALLBACK_URL</code></li>
|
||
<li>查单/retry/cancel <b>无鉴权本身是 pay 既定惯例</b>(不可猜 ID + payload 无敏感字段,与 refunds 查询同型,P4 终审已归档为模式一致),非设计缺陷;jiu 侧也无未鉴权暴露(客户端/官网只轮询 jiu 自己的 JWT+shop_id 接口)。真正的加固点仅两处:order_no 熵偏薄(≈32 位)、<b>改状态端点</b>(cancel/retry)可被持单号者骚扰——建议 pay 仓增熵 + 对改状态端点限流</li>
|
||
</ol></div>
|
||
|
||
<h2>已定设计决策</h2><div class="card"><ul>
|
||
<li><b>D1 金额链路(价格权威在 pay 不变)</b>:v2 下单不回金额 → 下单成功后<b>立即查单</b>回填 amount_minor/currency/subject(best-effort,失败不阻断);settle 时本地 amount_minor==0(残单)先补查再核对,仍拿不到则拒绝入账 fail-closed 等 pay 重投。核对 = int64 相等 + currency 相等</li>
|
||
<li><b>D2 事件分发</b>:以 payload.event_type 为准(X-Pay-Event 头不参与签名仅日志)。payment.succeeded → settle;<b>其余事件(refund.* 等)记 ALERT 日志后 ack SUCCESS</b>——pay v2 重投无退避无死信,回 FAIL 会 60s 永久重投;退款接入按任务书排除、另起任务</li>
|
||
<li><b>D3 nonce 防重放</b>:jiu 侧内存去重(map + 10min 滚动清理),命中回 401。pay 每次重投重新生成 nonce/签名不受影响;真正幂等仍靠 settle 短路;单实例部署,重启丢失由幂等兜底</li>
|
||
<li><b>D4 表结构</b>:license_purchases 新增 <code>amount_minor bigint</code> + <code>currency varchar(8)</code>;旧 amount 列保留只读,启动 Go 回填存量(复用 toCents,参照 backfillPinyin),观察一版后另行 DROP(同定价消歧惯例)</li>
|
||
<li><b>D5 兼容铁律</b>:后端下单/查单响应<b>同时输出</b>新字段与弃用字段(pay_url=redirect 时的 payload.url、amount=格式化元串)——官网 checkout 与已发行客户端不升级也不能坏;<b>官网本期零改动</b></li>
|
||
<li><b>D6 retry 不接 / cancel 接</b>:单渠道 alipay 无换方式需求,409 currency_mismatch 按约定=新建订单(「重新选择」已覆盖);cancel 轻量接入,「重新选择」时透传 pay,防 pending 堆积挤占 reconcile 每轮 50 条限额</li>
|
||
<li><b>D7 客户端 render_type 分发</b>:redirect → launchUrl(现行为);qr → 卡内嵌 QrImageView(新增 qr_flutter,纯 Dart 全平台,按文档口径 qr_content 实现、联调校准);未知 → toast 提示升级</li>
|
||
<li><b>D8 状态映射</b>:reconcile 遇 canceled/expired → 标 failed(不扩本地枚举);created/pending 继续等;refund 系 no-op 记日志</li>
|
||
<li><b>D9 配置零新增</b>:PAY_* 三件套已在 viper/production.env/render-env.sh/Bitwarden 链上</li>
|
||
<li><b>D10 UI 治理(按 2026-07-10 铁律:原型=代码 100% 一致,前端改动先改原型)</b>:授权管理已完成 design-first(原型 license.html / m-license.html 三 tab 已评审提交 13b7274),实现逐像素落地、原型与代码同提交;qr 内嵌态动 PurchaseCard 前仍需先补原型</li>
|
||
<li><b>D11 授权管理提升(2026-07-10 评审通过并入)</b>:授权管理提升独立一级屏(桌面侧边栏「系统」组 + <code>/license</code>;移动 <code>/me/license</code>),三 tab:授权信息 / 在线购买续费(iOS 合规态整 tab 隐藏)/ 订单管理。「继续支付」=打开下单时存储的 pay_url(不接 retry,维持 D6);「授权续期至」=settle 回写 renewed_to;模型加 <code>pay_url</code>/<code>renewed_to</code> 两列</li>
|
||
</ul></div>
|
||
|
||
<h2>任务分解(8 任务,每任务 TDD + 独立提交)</h2>
|
||
<div class="card"><ul>
|
||
<li><span class="chk">1</span><b>表结构 + 存量回填</b> — model 加 AmountMinor/Currency;<code>BackfillPurchaseAmountMinor</code>("2999.00"→299900+CNY,幂等只处理 amount_minor=0 行)挂 main.go;schema.sql / testutil SQLite 建表同步。测试:回填正确、已有值不覆盖</li>
|
||
<li><span class="chk">2</span><b>下单+查单切 /api/v2</b> — 请求体 {sku, method:"alipay", biz_system, biz_ref, return_url};解析 order_no+session;下单后查单回填;删 productID/prodCache;<code>PurchaseResult</code> 改 {out_trade_no, render_type, payload, amount_minor, currency, subject, <s>pay_url</s>, <s>amount</s>}(弃用字段照 D5 输出);queryOrder 八态。测试:mock v2 双端点断言请求形状/签名可验/兼容字段</li>
|
||
<li><span class="chk">3</span><b>webhook v2</b> — payNotification 改 event_type/amount_minor/currency(删 amount/trade_no);事件分发(D2)+ nonce 防重放(D3);settle 签名改 int64 + 残单补查(D1);handler Callback 零改动。测试:入账/幂等重投/nonce 重放 401/refund ack/金额差 1 分拒绝/残单兜底</li>
|
||
<li><span class="chk">4</span><b>reconcile 八态映射</b> — paid→settle;canceled/expired→failed;refund 系 no-op。测试 ×4 状态</li>
|
||
<li><span class="chk">5</span><b>取消透传</b> — <code>POST /api/v1/license/purchase/:otn/cancel</code>(admin,license 组=LicenseGuard 豁免区)→ pay cancel → canceled==true 才标 failed(false=已付竞态,保持 pending 等入账)。测试:成功/竞态/跨店 404</li>
|
||
<li><span class="chk">6</span><b>客户端模型层</b> — PurchaseOrder{renderType, payload, amountMinor, currency} + redirectUrl/qrContent getter;<code>yuanFromMinor</code>(299900→"2,999.00")落 core/utils/money.dart;repository +cancelPurchase。测试:fromJson/格式化</li>
|
||
<li><span class="chk">7</span><b>PurchaseCard 分发</b> — _submit 按 renderType:redirect 外跳 / qr 内嵌 QrImageView(180) / 未知 toast;金额展示 yuanFromMinor;「重新选择」fire-and-forget cancel;pubspec +qr_flutter ^4.1.0。验收:flutter analyze+test、settings golden 无 diff、check_ds_code.mjs 过闸</li>
|
||
<li><span class="chk">9</span><b>订单列表接口</b> — <code>GET /api/v1/license/purchases</code>:分页/状态筛选/summary(累计·笔数·待支付),仅管理员,跨店隔离;pending 单回 pay_url 供继续支付;LEFT JOIN users 取下单人</li>
|
||
<li><span class="chk">10</span><b>授权管理独立屏三 tab(双端)</b> — 按已评审原型逐像素实现:路由 <code>/license</code>+侧边栏项+settings 摘除授权子导航;订单 tab 桌面 DsTable+抽屉 / 移动 MCard 流+sheet;继续支付/取消订单/重新购买切购买 tab;settings golden 更新 + 新屏 golden ×3 + fidelity 注册</li>
|
||
<li><span class="chk">8</span><b>文档同步(收尾)</b> — <code>docs/pay支付对接开发指南.html</code> v2 化(标注 v1 断代);db-schema.html 四新列;CONTRACT 台账回填;用户手册补「授权管理/订单管理」节(两侧同步)</li>
|
||
</ul>
|
||
<p style="margin:6px 0 0;font-size:12.5px;color:var(--muted)">执行顺序:后端链 1→2→3→4→5→9 与前端链 6→7→10 交错推进,Task 8 收尾。</p></div>
|
||
|
||
<h2>联调 checklist(pay v2 部署后,不阻塞开发)</h2><div class="card"><ul>
|
||
<li>pay v2 合 main 部署;生产 accounts 配 alipay、seedPlans 套餐落库(seed 现仅 sandbox 开关下执行,需确认)</li>
|
||
<li>密钥对齐:pay 侧 BIZ_JIU_SECRET = jiu 侧 PAY_SECRET(Bitwarden 单源);BIZ_JIU_CALLBACK_URL=https://jiu.51yanmei.com/api/v1/pay/callback;jiu 侧 PAY_BASE_URL 指 v2</li>
|
||
<li>¥0.01/¥1 真单走通(jiu payPlans 白名单无 test_liandiao:用 promo ¥1,或临时加 test_liandiao 映射联调后移除):下单→收银台→实付→webhook 续期→轮询 success→写权限即时恢复(InvalidateLicensePhase)</li>
|
||
<li>阻断 webhook 验证 reconcile 5 分钟查单兜底;接收器临时回 FAIL 观察 60s 重投与幂等</li>
|
||
<li>官网 checkout(pay_url 兼容字段)+ 旧版客户端购买路径回归</li>
|
||
<li><b>pay 侧欠账跟进</b>:端型透传补契约 → jiu 加回 client_type 一行 → 真机验证手机拉起支付宝;查单/cancel 无鉴权加固(限流+增熵);refund 事件消费另起任务</li>
|
||
<li>取消路径:客户端「重新选择」→ pay 订单翻 canceled</li>
|
||
</ul></div>
|
||
|
||
<h2>红线与 DoD</h2><div class="card"><ul>
|
||
<li>金额禁 float;幂等(60s 无限重投安全);Status/Cancel 带 shop_id(JWT 取);<b>零写真实库</b>(SQLite in-memory + httptest);<b>不部署不发版不打 tag</b></li>
|
||
<li><code>go build/vet/test ./...</code> + <code>flutter analyze/test</code> + <code>check_ds_code.mjs</code> + <code>check-l1-sync.mjs</code> 全绿后停在本地提交态,todo 标 done 等验收</li>
|
||
</ul></div>
|
||
|
||
<h2>批准后落地流程</h2><div class="card"><ol>
|
||
<li>计划双产物:.md(checkbox 真相源)落 <code>docs/plans/2026-07-10-pay-v2-integration.md</code> + 本 HTML 落 <code>docs/plans/pay-v2-integration.html</code> 登记 index.html</li>
|
||
<li>todo 登记(全局 todo skill)并标 doing</li>
|
||
<li>subagent-driven-development 逐任务派发执行,每任务一提交</li>
|
||
</ol></div>
|
||
</body></html>
|