Files
pay/docs/pay接入jiu授权续费对接方案.html
wangjia fd5f56f7de feat: 微信V3 + 支付宝手机站/UA自适应 + 业务对接(签名下单/webhook) + 通用多业务
- 微信支付 V3 Native 渠道 (wechat.go):Native下单/回调AES-GCM解密验签/查单
- 支付宝:手机网站支付 wap.pay + 按 UA 自适应(PC page.pay扫码 / 手机拉App);qr_pay_mode=2 完整扫码收银台
- 业务对接:下单接口扩展(biz_system/biz_ref/return_url)+ HMAC 签名鉴权;支付成功 webhook 主动推送业务方 + 60s 重试 + BizNotifyLog
- 通用多业务:config.biz 改 map,加业务只改配置(BIZ_<SYS>_SECRET/_CALLBACK_URL)
- seedPlans:四档真实套餐 + promo_first_month(¥1) + test_liandiao(0.01),均挂 biz_code;/products 暴露 biz_code
- 删除沙箱 pay.html;对接设计文档入 docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
2026-07-03 22:44:34 +08:00

215 lines
17 KiB
HTML
Raw Permalink 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>pay × jiu · 授权续费对接方案</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--amber:#d29922;--red:#f85149;--radius:14px}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:920px;margin:0 auto;padding:48px 22px 90px}
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
h1{font-size:30px;margin:10px 0 6px}
.lead{color:var(--fg-soft);margin:0 0 10px}
.meta{color:var(--muted);font-size:12.5px;margin-bottom:26px}
h2{font-size:19px;margin:38px 0 12px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
h3{font-size:15.5px;margin:22px 0 8px;color:var(--fg)}
a{color:var(--accent);text-decoration:none}
p{margin:8px 0}
code{background:#0b1020;border:1px solid var(--border);border-radius:6px;padding:1px 6px;font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:13px;color:#c9d6e6}
pre{background:#0b1020;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:12.8px;line-height:1.6;color:#c9d6e6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin:14px 0}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.6px}
th,td{border:1px solid var(--border);padding:8px 11px;text-align:left;vertical-align:top}
th{background:var(--card-2);color:var(--fg-soft);font-weight:600}
td code{font-size:12.5px}
.tag{display:inline-block;font-size:11.5px;padding:2px 9px;border-radius:999px;border:1px solid var(--border);margin-right:6px}
.tag.pay{color:var(--accent);border-color:var(--accent)}
.tag.jiu{color:var(--green);border-color:var(--green)}
.tag.new{color:var(--amber);border-color:var(--amber)}
.flow{background:#0b1020;border:1px solid var(--border);border-radius:10px;padding:16px;overflow-x:auto;font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:12.6px;line-height:1.75;color:#c9d6e6;white-space:pre}
.warn{background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.4);border-radius:10px;padding:12px 15px;margin:14px 0;color:#e8d3a0;font-size:13.6px}
.ok{color:var(--green)} .amber{color:var(--amber)} .red{color:var(--red)}
ol,ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
.step{display:flex;gap:12px;margin:10px 0}
.step .n{flex:0 0 26px;height:26px;border-radius:50%;background:var(--accent);color:#08111f;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:13px}
.back{display:inline-block;margin-bottom:18px;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="./index.html">← 返回文档索引</a>
<div class="eyebrow">Integration Design · pay × jiu</div>
<h1>pay 接入 jiu 授权续费 · 对接方案</h1>
<p class="lead">让 jiu(岩美酒库)门店在应用内购买/续费授权:付款走 pay 收款中枢,付成功后 pay 回调 jiu,jiu 直接给该门店续期。</p>
<p class="meta">v1.0 · 2026-07-03 · 方案 B(付款直接续期门店)· server-to-server + 签名 webhook · 待评审</p>
<h2>1. 背景与目标</h2>
<div class="card">
<p>pay 已是独立收款中枢(支付宝电脑网站支付 <code>page.pay</code> + 手机网站支付 <code>wap.pay</code> UA 自适应,已上线验证到账)。jiu 已有完整授权体系(<code>License</code> / <code>LicenseCode</code> / 兑换续期)。<b>目标</b>:把两者接起来——jiu 门店在应用内选套餐付款,付成功后<b>无感自动续期</b>,不再靠线下发兑换码。</p>
</div>
<h2>2. 现状盘点</h2>
<h3>pay 现有对外接口</h3>
<table>
<tr><th>方法/路径</th><th>说明</th></tr>
<tr><td><code>POST /api/v1/orders</code></td><td>下单,返回 <code>pay_url</code>(UA 自适应 PC 扫码 / 手机拉 App)。<span class="red">当前只收 product_id,无业务标识、无鉴权</span></td></tr>
<tr><td><code>GET /api/v1/orders/:out_trade_no</code></td><td>查订单状态</td></tr>
<tr><td><code>POST /api/v1/notify/{alipay,wechat}</code></td><td>支付宝/微信异步回调(入账,已实现)</td></tr>
</table>
<h3>jiu 现有授权模型</h3>
<table>
<tr><th>模型</th><th>关键字段</th><th>语义</th></tr>
<tr><td><code>License</code></td><td>ShopID · Type(trial/monthly/annual/lifetime) · Tier · ExpiresAt · MaxDevices · IsActive</td><td>门店当前授权状态</td></tr>
<tr><td><code>LicenseCode</code></td><td>Code · DurationDays · Tier · Type · Status(unused/redeemed/void) · RedeemedShopID</td><td>兑换券:兑换时把 DurationDays 叠加到门店到期时间;一码一次</td></tr>
</table>
<p>现有续期路径:平台生成码 → 用户获得 → 门店兑换。<b>「购买」这步目前是线下/手动</b>——本方案就是把它自动化。</p>
<h2>3. 总体架构(方案 B</h2>
<div class="flow">用户(jiu 客户端, 已登录, 知道自己 shop) jiu 后端 pay 服务 支付宝/微信
│ │ │ │
①点“购买/续费”选套餐 ───────────────────────►│ │ │
│ ②建 purchase(pending) │ │
│ 调 pay 下单(biz_ref=shop_id, 签名) ───►│ │
│ │◄──── pay_url ────────────│ │
│◄──────────── 返回 pay_url ─────────────────│ │ │
③打开 pay_url 付款(PC扫码/手机拉App) ───────────────────────────────────────────────────────────►│
│ │ ④收 notify → 入账 ✓ │
│ │◄─ ⑤pay 签名 webhook ─────│ │
│ ⑥验签+幂等 → 给 shop 续期(复用 license) │ │
│ 回 ack ────────────────────────────►│ │
④'客户端刷新 → 已续期 ✓ │ │ │</div>
<p><b>为什么是 B</b>:购买发生在已登录门店,jiu 天然知道是哪个 <code>ShopID</code>,付成功直接续期,用户无感,体验最好。</p>
<h2>4. 数据模型改动</h2>
<h3><span class="tag pay">pay</span> Order 加业务字段 <span class="tag new">新增</span></h3>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>BizSystem</code></td><td>string(index)</td><td>业务来源,如 <code>jiu</code>;决定回调哪个 webhook / 用哪个密钥</td></tr>
<tr><td><code>BizRef</code></td><td>string</td><td>业务引用,jiu 传(如 <code>shop_id</code> 或 jiu purchase_id);webhook 原样回传</td></tr>
<tr><td><code>BizReturnURL</code></td><td>string(可选)</td><td>付完跳回业务自己的页面;空则用 pay 默认结果页</td></tr>
</table>
<h3><span class="tag pay">pay</span> Product 加稳定套餐码 <span class="tag new">新增</span></h3>
<p><code>BizCode</code>(如 <code>annual_standard</code>):webhook 回传给 jiujiu 按<b>稳定码</b>而非数字 product_id 映射权益,避免硬编码耦合。价格权威仍在 pay 套餐表。</p>
<h3><span class="tag pay">pay</span> BizNotifyLog 业务回调日志 <span class="tag new">新增</span></h3>
<p>记录每次向业务系统回调的 URL / payload / 响应 / 重试次数,用于排障与重放。</p>
<h3><span class="tag jiu">jiu</span> Purchase 购买记录 <span class="tag new">新增</span></h3>
<table>
<tr><th>字段</th><th>说明</th></tr>
<tr><td>ShopID · ProductBizCode · Amount</td><td>谁买、买什么、多少钱</td></tr>
<tr><td>OutTradeNo</td><td>pay 订单号(对账 + 幂等键)</td></tr>
<tr><td>Status(pending/paid/failed) · PaidAt</td><td>状态机</td></tr>
</table>
<h2>5. 接口契约</h2>
<h3>5.1 <span class="tag pay">pay</span> 下单接口扩展 <code>POST /api/v1/orders</code></h3>
<p>请求头带签名(见 §6)。Body 扩展:</p>
<pre>{
"product_id": 3,
"biz_system": "jiu",
"biz_ref": "shop_1024",
"return_url": "https://jiu.51yanmei.com/license/result" // 可选
}
Headers:
X-Pay-System: jiu
X-Pay-Timestamp: 1751520000
X-Pay-Nonce: 8f3a...
X-Pay-Sign: base64(HMAC-SHA256(secret, system+timestamp+nonce+body))</pre>
<p>pay 校验签名 → 按 product_id 取<b>服务端权威价格</b>建单、存 biz 字段 → 返回 <code>pay_url</code></p>
<h3>5.2 <span class="tag pay">pay</span><span class="tag jiu">jiu</span> 业务 webhook(入账后主动推)</h3>
<p>pay 在 <code>applyPaid</code> 成功后,按 order 的 <code>biz_system</code> 找到回调配置(URL+secret),POST</p>
<pre>POST {jiu 配置的回调 URL}
Headers: X-Pay-Timestamp, X-Pay-Nonce, X-Pay-Sign (HMAC-SHA256)
Body:
{
"out_trade_no": "yanmei-20260703...-xxxx",
"biz_system": "jiu",
"biz_ref": "shop_1024",
"product_biz_code": "annual_standard",
"amount": "268.00",
"trade_no": "20260703...", // 支付宝/微信交易号
"channel": "alipay",
"paid_at": "2026-07-03T14:36:30+08:00"
}
期望响应:HTTP 200 + {"code":"SUCCESS"};否则 pay 按退避重试</pre>
<h3>5.3 <span class="tag jiu">jiu</span> 购买接口 <code>POST /api/v1/license/purchase</code></h3>
<p>鉴权=jiu 自己的登录态(当前用户/门店)。选套餐 → 建 Purchase(pending) → 调 pay §5.1 下单 → 返回 <code>pay_url</code> 给客户端。</p>
<h3>5.4 <span class="tag jiu">jiu</span> webhook 接收器 <code>POST /api/v1/pay/callback</code></h3>
<div class="step"><div class="n">1</div><div><b>验签</b>:用共享密钥校验 X-Pay-Sign + 时间戳窗口,确认真是 pay 发来的</div></div>
<div class="step"><div class="n">2</div><div><b>幂等</b>:按 <code>out_trade_no</code> 查 Purchase,已处理则直接回 SUCCESS</div></div>
<div class="step"><div class="n">3</div><div><b>映射</b><code>product_biz_code</code> → (duration_days, tier, type)</div></div>
<div class="step"><div class="n">4</div><div><b>续期</b>:给该 ShopID 延长授权(复用现有 license 服务:生成 LicenseCode 即兑换,或直接叠加 <code>License.ExpiresAt</code></div></div>
<div class="step"><div class="n">5</div><div><b>回 ack</b>HTTP 200 + <code>{"code":"SUCCESS"}</code>;标记 Purchase=paid</div></div>
<h2>6. 安全设计</h2>
<div class="card">
<ul>
<li><b>共享密钥</b>jiu↔pay 各方向一对 HMAC 密钥,存 Bitwarden(如条目 <code>pay-jiu webhook secret</code>),部署经 rbw 灌环境变量,<b>不落配置/仓库</b></li>
<li><b>签名</b>HMAC-SHA256 覆盖 <code>system+timestamp+nonce+body</code>,防篡改。</li>
<li><b>防重放</b>timestamp 5 分钟窗口 + nonce 去重(拒绝过期/重复请求)。</li>
<li><b>幂等</b>:两端都按 <code>out_trade_no</code> 唯一处理——pay 可能重发 webhook、jiu 只续一次。</li>
<li><b>价格权威</b>pay 按 product_id 取服务端价格,不信任传入金额(延续既有红线)。</li>
<li><b>传输</b>:全程 HTTPSjiu.51yanmei.com / pay.51yanmei.com 均已 HTTPS)。</li>
</ul>
</div>
<h2>7. 套餐映射</h2>
<p>pay 套餐表持有价格(99/268/888/2280),每个套餐挂稳定 <code>biz_code</code>jiu 侧一张映射表把 <code>biz_code</code> 翻成时长/档位:</p>
<table>
<tr><th>pay product</th><th>biz_code</th><th>价格</th><th>jiu 续期(直接叠加 ExpiresAt</th></tr>
<tr><td>月付·标准</td><td><code>monthly_standard</code></td><td>¥299</td><td>+30 天, tier=standard, type=monthly</td></tr>
<tr><td>月付·高级</td><td><code>monthly_pro</code></td><td>¥599</td><td>+30 天, tier=pro, type=monthly</td></tr>
<tr><td>年付·标准</td><td><code>annual_standard</code></td><td>¥2999</td><td>+365 天, tier=standard, type=annual</td></tr>
<tr><td>年付·高级</td><td><code>annual_pro</code></td><td>¥5999</td><td>+365 天, tier=pro, type=annual</td></tr>
</table>
<p><b>权益差异(2026-07-03 已定,都是单店):</b></p>
<table>
<tr><th></th><th>客户端</th><th>仓库</th><th>图片分享</th><th>AI 商业分析</th></tr>
<tr><td>标准(299/2999</td><td>2</td><td>单仓库</td><td>千张</td><td></td></tr>
<tr><td>高级(599/5999</td><td>5</td><td>多仓库</td><td>万张</td><td>免费 AI 周/月度商业数据分析</td></tr>
</table>
<p class="amber">pay 侧只透传 biz_code,不关心权益细节;权益→License(tier/max_devices/features) 的落地在 jiu 侧,见 jiu 仓 <code>docs/pay支付对接开发指南.html</code></p>
<h2>8. 失败 / 重试 / 对账</h2>
<ul>
<li><b>webhook 失败重试</b>pay 对未收到 SUCCESS ack 的回调按退避重试若干次(如 1m/5m/30m/2h…),记 BizNotifyLog。</li>
<li><b>兜底查单</b>jiu 侧对 pending 超时的 Purchase 主动查 pay <code>GET /orders/:out_trade_no</code> 补偿(防 webhook 全丢)。</li>
<li><b>对账</b>pay orders 与 jiu purchases 按 out_trade_no 对齐,定期核对金额/状态。</li>
<li><b>用户已付但续期失败</b>:webhook 重试 + 查单兜底应能自愈;仍失败则告警人工处理(钱已到,不可让用户吃亏)。</li>
</ul>
<h2>9. 分阶段实施计划</h2>
<div class="card">
<p><b>阶段 1 · pay 侧</b>Order 加 biz 字段 + Product 加 biz_code;下单接口收 biz + 签名鉴权;BizNotifyLog。</p>
<p><b>阶段 2 · pay 侧</b>:入账后业务 webhook 推送 + 退避重试;回调配置(每个 biz_system 的 URL+secret)。</p>
<p><b>阶段 3 · jiu 侧</b>Purchase 模型 + 购买接口(调 pay 下单)。</p>
<p><b>阶段 4 · jiu 侧</b>:webhook 接收器(验签+幂等+映射+续期,复用 license 服务)+ 查单兜底。</p>
<p><b>阶段 5</b>:jiu 客户端购买/续费 UI;真实 1 分钱端到端联调;对账脚本。</p>
</div>
<h2>10. 决策(2026-07-03 已定)</h2>
<div class="card">
<table>
<tr><th>#</th><th>问题</th><th class="ok">决策</th></tr>
<tr><td>1</td><td>套餐定义</td><td>月 ¥299/¥599、年 ¥2999/¥59992 档 × 月/年,见 §7)。标准/高级权益差异待定</td></tr>
<tr><td>2</td><td>续期实现</td><td><b>直接叠加 <code>License.ExpiresAt</code></b>(不走生成兑换码)</td></tr>
<tr><td>3</td><td>biz_ref</td><td><b>jiu 的 <code>purchase_id</code></b>(利对账/多态)</td></tr>
<tr><td>4</td><td>付款后</td><td><b>跳回 jiu 结果页</b>return_url</td></tr>
<tr><td>5</td><td>客户端形态</td><td><b>Web 入口 + App 入口都要</b>App 用<b>内置 webview</b> 打开 pay_url。付完 return_url 跳回;webview 内拉起支付宝 App 需处理 scheme 唤起/回跳</td></tr>
</table>
</div>
<div class="warn">
<b>权益已定</b>(见 §7 表):标准=2 客户端/单仓库/千图;高级=5 客户端/多仓库/万图/AI 分析。
<br><b>App webview 注意:</b>应用内 webview 里跳支付宝需 scheme 白名单 / 拦截 <code>alipays://</code> 唤起 App,付完靠 return_url 回跳 jiu 页面(比 PC 多一层客户端处理,Phase 5 联调)。
</div>
<p style="margin-top:30px;color:var(--muted);font-size:12.5px">相关:<a href="./支付宝收款页时序图与架构.html">支付宝收款页时序与架构</a> · <a href="./上线状态与部署Runbook.html">上线状态与部署 Runbook</a></p>
</div>
</body>
</html>