Files
jiu/docs/plans/pay-v2-integration.html
T

115 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>10 个任务全部完成</b>(本地提交态,DoD 全绿;不部署不发版,等用户验收)· 执行真相源 = <code>docs/plans/2026-07-10-pay-v2-integration.md</code>(checkbox),本页为阅读版 · pay v2 契约读自 <code>design/pay-v2</code> 分支代码(非文档口径)· 已并入授权管理独立屏三 tab 实现(原型评审通过 13b7274)· Task 8 文档同步已收尾</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>扁平 payloadamount 元串,有 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 int64CNY=分)+ currency 大写码,全链路禁 float</td></tr>
<tr><td><b>retry / cancel</b></td><td></td><td>retry409 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/subjectbest-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.goschema.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→settlecanceled/expired→failedrefund 系 no-op。测试 ×4 状态</li>
<li><span class="chk">5</span><b>取消透传</b><code>POST /api/v1/license/purchase/:otn/cancel</code>adminlicense 组=LicenseGuard 豁免区)→ pay cancel → canceled==true 才标 failedfalse=已付竞态,保持 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.dartrepository +cancelPurchase。测试:fromJson/格式化</li>
<li><span class="chk">7</span><b>PurchaseCard 分发</b> — _submit 按 renderTyperedirect 外跳 / qr 内嵌 QrImageView(180) / 未知 toast;金额展示 yuanFromMinor;「重新选择」fire-and-forget cancelpubspec +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;继续支付/取消订单/重新购买切购买 tabsettings 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>联调 checklistpay v2 部署后,不阻塞开发)</h2><div class="card"><ul>
<li>pay v2 合 main 部署;生产 accounts 配 alipay、seedPlans 套餐落库(seed 现仅 sandbox 开关下执行,需确认)</li>
<li>密钥对齐:pay 侧 BIZ_JIU_SECRET = jiu 侧 PAY_SECRETBitwarden 单源);BIZ_JIU_CALLBACK_URL=https://jiu.51yanmei.com/api/v1/pay/callbackjiu 侧 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>官网 checkoutpay_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_idJWT 取);<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>