Files
jiu/docs/pay支付对接开发指南.html
T

283 lines
29 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>pay 支付对接开发指南 — 酒库管理系统</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);line-height:1.65;padding:26px 30px;max-width:1000px;margin:0 auto;}
h1{font-size:23px;margin:0 0 4px;}
.sub{color:var(--muted);font-size:13px;margin-bottom:6px;}
.meta{color:var(--muted);font-size:12px;margin-bottom:18px;}
h2{font-size:17px;margin:32px 0 8px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14.5px;margin:20px 0 6px;color:var(--ink);}
p{margin:8px 0;font-size:13.5px;}
ul,ol{margin:8px 0;padding-left:22px;font-size:13.5px;} li{margin:4px 0;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#0f1830;color:#d6e2f5;border-radius:8px;padding:13px 15px;overflow-x:auto;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.6;margin:10px 0;}
pre .c{color:#7f93b5;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:15px 18px;margin:12px 0;}
table{width:100%;border-collapse:collapse;font-size:12.8px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;text-align:left;padding:7px 10px;border-bottom:1px solid var(--border);}
td{padding:6px 10px;border-bottom:1px solid #EEF1F6;vertical-align:top;}
tr:last-child td{border-bottom:0;}
.mono{font-family:ui-monospace,Menlo,monospace;}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:10px 0;font-size:12.8px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
.callout.ok{border-color:var(--success);background:var(--success-bg);}
.tag{display:inline-block;font-size:10px;padding:1px 7px;border-radius:9px;font-weight:600;}
.tag.jiu{background:var(--success-bg);color:var(--success);}
.tag.pay{background:#E5EEF9;color:var(--primary);}
.flow{background:#0f1830;color:#d6e2f5;border-radius:8px;padding:15px;overflow-x:auto;font-family:ui-monospace,Menlo,monospace;font-size:11.8px;line-height:1.7;white-space:pre;margin:10px 0;}
a{color:var(--primary);text-decoration:none;} a:hover{text-decoration:underline;}
.n{display:inline-block;width:22px;height:22px;border-radius:50%;background:var(--primary);color:#fff;text-align:center;line-height:22px;font-size:12px;font-weight:700;margin-right:6px;}
</style>
</head>
<body>
<p style="font-size:12px;"><a href="index.html">← 文档索引</a></p>
<h1>pay 支付对接开发指南</h1>
<div class="sub">jiu 门店在应用内购买/续费授权:付款走 pay 收款服务,付成功后 pay 回调 jiu,jiu 直接给门店续期。</div>
<div class="meta">v2.0 · 2026-07-11 · 面向 jiu 后端开发 · pay 侧 v2 契约已就绪(下单+签名+webhook 推送+查单+取消均已联调验证)· 本文档 = jiu 侧要实现的部分</div>
<div class="callout ok"><b>给开发者:</b>pay 服务(收款、支付宝渠道、签名鉴权、支付成功 webhook 推送)<b>已开发并联调验证完毕</b>。jiu 这一侧已实现 6 块:① 购买接口 ② webhook 接收器 ③ 续期逻辑 ④ 查单兜底 ⑤ 取消透传 ⑥ 订单列表(授权管理·订单管理 tab)。接口契约、签名算法、权益映射见下。</div>
<div class="callout warn"><b>v1→v2 断代(2026-07-11):</b>jiu 对接 pay 服务的契约已从 <code>/api/v1/orders</code> 全面切到 <code>/api/v2/orders</code>——下单请求体、下单/查单响应形状、金额口径(string 元 → int64 最小单位)、webhook payload(新增 <code>event_type</code> 事件模型)均已变更。<b>v1 契约历史内容保留在文末「附录 A」仅供留痕参考,新对接一律照本文档主体(v2)实现。</b> jiu 自己对外的 4 个业务接口路径不变(仍是 <code>/api/v1/license/...</code>,这是 jiu 自己的 API 版本号,与 pay 服务的 <code>/api/v2</code> 无关,两者独立编号,未来变化各走各的)。</div>
<h2>1. 名词与地址</h2>
<table>
<tr><th></th><th></th></tr>
<tr><td>pay 服务地址</td><td class="mono">https://pay.51yanmei.com</td></tr>
<tr><td>pay 下单接口</td><td class="mono">POST /api/v2/orders</td></tr>
<tr><td>pay 查单接口</td><td class="mono">GET /api/v2/orders/{order_no}</td></tr>
<tr><td>pay 取消接口</td><td class="mono">POST /api/v2/orders/{order_no}/cancel</td></tr>
<tr><td>pay retry 接口</td><td class="mono">POST /api/v2/orders/{order_no}/retry</td></tr>
<tr><td>jiu 回调接收器(<b>已实现</b></td><td class="mono">POST /api/v1/pay/callback</td></tr>
<tr><td>jiu 购买/查单/取消/订单列表(<b>已实现</b></td><td class="mono">见 §4,均挂 <code>/api/v1/license/...</code></td></tr>
<tr><td>共享密钥</td><td>双向共用一把 HMAC 密钥,存 Bitwarden,两边经环境变量注入(pay: <code>BIZ_JIU_SECRET</code>jiu 侧 <code>PAY_SECRET</code>,同值)</td></tr>
</table>
<div class="callout">jiu <b>不接 retry</b>(门店场景单渠道支付宝,无换支付方式需求;<code>POST /orders/:no/retry</code> 409 <code>currency_mismatch</code> 按约定等价于「新建订单」,客户端「重新选择」语义已覆盖,见 §8)。<b>接 cancel</b>:客户端「重新选择」时透传取消,防 pending 单堆积挤占查单兜底每轮 50 条限额。</div>
<h2>2. 整体流程</h2>
<div class="flow">jiu 客户端(已登录, 知道 shop_id) jiu 后端 pay 服务 支付宝
│ │ │ │
①选套餐, 点购买 ──────────────────────►│ │ │
│ ②建 Purchase(pending, shop_id, biz_code) │ │
│ 调 pay v2 下单 (sku=biz_code + biz_ref=purchase_id, 签名) ─►建订单│
│ │◄─ {order_no, session:{render_type,payload}} ──────│
│ ②b best-effort 查单回填 amount_minor/currency/subject(失败不阻断)│
│◄──── 返回 render_type(redirect/qr) + payload + amount_minor ─────────│ │
③redirect→打开 payload.url 付款 / qr→卡内嵌二维码扫码 ────────────────────────────────────►付款
│ │ ④支付宝→pay 入账 ✓ │
│ │◄ ⑤pay 签名 webhook(event_type=payment.succeeded) ─│
│ ⑥验签→时间窗→nonce 防重放→按 event_type 分发→settle:金额核对→续期→回 SUCCESS│
④客户端跳回 return_url / 3s轮询+30s心跳兜底 → 已续期 ✓│ │
│ (若「重新选择」放弃本单 → POST /orders/:no/cancel 透传取消,防 pending 堆积) │</div>
<h2>3. 签名算法(两个方向都用它)</h2>
<div class="card">
<p><b>签名串</b>4 段用换行 <code>\n</code> 连接,再 HMAC-SHA256,最后 base64):</p>
<pre>sign = base64( HMAC_SHA256( secret, system + "\n" + timestamp + "\n" + nonce + "\n" + rawBody ) )</pre>
<p><b>请求头</b></p>
<table>
<tr><th>Header</th><th>说明</th></tr>
<tr><td class="mono">X-Pay-System</td><td>固定 <code>jiu</code></td></tr>
<tr><td class="mono">X-Pay-Timestamp</td><td>Unix 秒(服务端校验 ±5 分钟窗口)</td></tr>
<tr><td class="mono">X-Pay-Nonce</td><td>随机串(如 uuid</td></tr>
<tr><td class="mono">X-Pay-Sign</td><td>上面算出的签名</td></tr>
</table>
<p><code>rawBody</code> = HTTP 请求体的<b>原始字节</b>(验签/签名都对同一份原始 body,勿先反序列化再重拼)。</p>
<div class="callout warn">方向:<b>jiu→pay 下单</b> 由 jiu <b>签名</b>、pay 验签;<b>pay→jiu 回调</b> 由 pay 签名、jiu <b>验签</b>。两边同一把密钥、同一算法,v1→v2 <b>零改动</b></div>
<div class="callout"><b>v2 新增两点(jiu 侧已实现):</b>
<ul style="margin:6px 0 0;">
<li><code class="mono">X-Pay-Event</code> 头(webhook 请求另带的事件类型提示)<b>不参与签名</b>,只作日志辅助;事件类型判定<b>以 body 里的 <code>event_type</code> 字段为准</b>(见 §5.2)。</li>
<li><b>nonce 防重放</b>:pay v2 无退避无死信、60s 固定重投,重投时会带新的 <code>X-Pay-Nonce</code>;jiu 侧自建内存去重表(10 分钟滚动窗口),<b>同一 nonce 原样重发</b>直接拒签(401),合法重投(新 nonce)不受影响——真正的幂等仍靠 §4②的 <code>out_trade_no</code> 短路。</li>
</ul>
</div>
</div>
<h3>Go 参考实现(与 pay 侧 <code>util.HMACSign</code> 完全一致)</h3>
<pre><span class="c">// 签名(下单时调用)/ 验签(收 webhook 时对比)</span>
func hmacSign(secret string, parts ...string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(strings.Join(parts, "\n")))
return base64.StdEncoding.EncodeToString(m.Sum(nil))
}
<span class="c">// 验签:hmac.Equal(([]byte)hmacSign(secret, "jiu", ts, nonce, rawBody), got)</span></pre>
<h2>4. jiu 已实现的六块</h2>
<h3><span class="n">1</span>购买接口 <span class="tag jiu">jiu</span> <code>POST /api/v1/license/purchase</code></h3>
<p>鉴权 = jiu 自己的登录态(仅管理员/超管,handler 内判权)。步骤:</p>
<ol>
<li>校验登录态,拿到当前 <code>shop_id</code>;入参 <code>{"biz_code": "annual_standard"}</code>(前端传套餐稳定码)。</li>
<li><code>LicensePurchase</code> 记录(status=pending,存 shop_id / user_id / product_biz_code)。</li>
<li>调 pay v2 下单(见 §5.1):<code>sku=biz_code</code><code>biz_ref=purchase_id</code><code>return_url</code>=jiu 结果页。</li>
<li>解析响应拿到 <code>order_no</code> + <code>session:{render_type,payload}</code>,回写 <code>out_trade_no</code><b>redirect</b> 态额外把 <code>payload.url</code> 存进 <code>pay_url</code> 列(订单管理「继续支付」复用同一收银台链接,避免二次下单)。</li>
<li><b>best-effort 查单回填</b>v2 下单响应<b>不回传金额</b>(D1:价格权威永远在 pay),下单成功后立即调一次查单(§5.3)拿 <code>amount_minor/currency/subject</code> 回填;查单失败不阻断下单(金额留 0,等 webhook/查单兜底时再补)。</li>
<li><code>render_type/payload/amount_minor/currency</code> 连同兼容字段 <code>pay_url</code>redirect 态=payload.url)、<code>amount</code>(分转元字符串,官网/旧客户端读这两个)一并返给客户端。</li>
</ol>
<div class="callout"><code>license_purchases</code> 已落库(详见 <a href="db-schema.html#license_purchases">db-schema.html</a>):<span class="mono">id · shop_id · user_id · product_biz_code · amount_minor(分) · currency · pay_url · renewed_to · amount(Deprecated) · out_trade_no(uk) · status(pending/paid/failed) · trade_no · channel · paid_at · created_at</span><code>out_trade_no</code> 兼作对账键与幂等键;jiu 自身的三态 <code>status</code>(区别于 pay 的八态,见 §5.3)不再新增枚举,pay 侧 canceled/expired 统一映射本地 failed。</div>
<h3><span class="n">2</span>webhook 接收器 <span class="tag jiu">jiu</span> <code>POST /api/v1/pay/callback</code>(核心)</h3>
<ol>
<li><b>读原始 body</b>(勿被框架提前解析掉),取 3 个签名头(<code>X-Pay-Timestamp/Nonce/Sign</code><code>X-Pay-Event</code> 不参与验签)。</li>
<li><b>验签</b><code>hmac.Equal(hmacSign(secret,"jiu",ts,nonce,rawBody), X-Pay-Sign)</code><b>校验时间戳</b> ±5 分钟;<b>nonce 防重放</b>(§3)。任一不过 → 401,不处理。</li>
<li><b><code>event_type</code> 分发</b><code>payment.succeeded</code> → 走入账(settle);其余事件(<code>refund.*</code> 等,本期不接,另起任务)→ 记 <code>ALERT</code> 日志后<b>照样回 SUCCESS ack</b>(v2 无退避无死信,回非 SUCCESS 会被 60s 永久重投)。</li>
<li><b>入账(settle)幂等</b>:按 <code>out_trade_no</code> 查 Purchase<code>FOR UPDATE</code> 行锁);若已 paid → 直接短路成功(pay 会重发,必须幂等)。</li>
<li><b>金额校验</b>payload 的 <code>amount_minor+currency</code> 与 Purchase 一致(int64 精确比较,防篡改);若购买单金额仍为 0(下单回填失败的残单),<b>先补查一次</b>(§5.3)再核对,仍拿不到则 <code>ErrPayAmount</code> 拒绝入账(fail-closed,等 pay 重投)。</li>
<li><b>续期</b>:按 <code>product_biz_code</code> 映射权益(§6),给该 shop 的 License <b>直接叠加 ExpiresAt</b>(§7),并把续期后的到期日回写 Purchase.<code>renewed_to</code>(订单管理「授权续期至」展示用)。</li>
<li>标记 Purchase=paid<b>回 HTTP 200 + <code>{"code":"SUCCESS"}</code></b>(pay 认响应体前 4096 字节大小写不敏感含 SUCCESS 才算成功,否则 60s 重投)。</li>
</ol>
<div class="callout danger">这是「钱已到账」的入口——<b>验签 + nonce 防重放 + 幂等 + 金额核对</b>四者缺一不可。验签失败绝不能续期(防伪造「已付款」骗授权)。</div>
<h3><span class="n">3</span>续期逻辑 <span class="tag jiu">jiu</span></h3>
<p>复用/参照现有 license 服务(见 <a href="architecture/license-design.md">授权体系设计</a>)。方式 = <b>直接叠加</b>(不生成兑换码),v1→v2 <b>逻辑零改动</b>,仅多一步回写 <code>Purchase.renewed_to</code></p>
<pre>base := license.ExpiresAt
if base == nil || base.Before(now) { base = now } <span class="c">// 已过期从现在起算</span>
license.ExpiresAt = base + duration(bizCode) <span class="c">// +30 或 +365 天</span>
license.Tier = tier(bizCode) <span class="c">// standard / pro</span>
license.Type = billType(bizCode) <span class="c">// monthly / annual</span>
license.MaxDevices = maxDevices(bizCode) <span class="c">// 2 / 5</span>
license.Features = features(bizCode) <span class="c">// 仓库数/图片额度/AI 分析</span>
license.IsActive = true
purchase.RenewedTo = license.ExpiresAt <span class="c">// v2 新增:订单流水「授权续期至」</span></pre>
<h3><span class="n">4</span>查单兜底 <span class="tag jiu">jiu</span></h3>
<p>防 webhook 全丢:后台每 60s 扫一次 pending 超 5 分钟的 Purchase,主动查 pay §5.3。按 v2 订单<b>八态</b>映射:<code>paid</code> → 走与 webhook 相同的 settle 入账(同样幂等);<code>canceled/expired</code> → 本地标 <code>failed</code><code>refunding/partially_refunded/refunded</code> → no-op 记日志(退款接入另起任务);<code>created/pending</code> → 继续等下一轮。</p>
<h3><span class="n">5</span>取消透传 <span class="tag jiu">jiu</span> <code>POST /api/v1/license/purchase/:out_trade_no/cancel</code></h3>
<p>仅管理员;只对本店 <code>status=pending</code> 的单生效(非 pending 幂等无害不外呼 pay)。调 pay §5.4,<code>canceled=true</code> 时本地条件更新 <code>WHERE id=? AND status='pending'</code><code>failed</code><code>canceled=false</code>(取消请求到达 pay 时单已被支付的竞态)时<b>本地保持 pending</b>,等 webhook/查单兜底正常入账——绝不能因为收到 <code>canceled:false</code> 就误标失败,否则钱已收但门店权益丢失。客户端「重新选择」套餐时 fire-and-forget 调用本接口(失败静默,查单兜底兜底)。</p>
<h3><span class="n">6</span>订单列表 <span class="tag jiu">jiu</span> <code>GET /api/v1/license/purchases?page=&amp;page_size=&amp;status=</code></h3>
<p>仅管理员;授权管理「订单管理」tab 数据源。按 <code>created_at DESC</code> 分页,可选 <code>status</code> 筛选;返回每笔购买流水(含 <code>amount_minor/amount/pay_url/renewed_to</code><code>pay_url</code> 仅 pending 单非空)+ <code>summary</code>(累计已付金额/已付笔数/待支付笔数/总笔数,独立于分页/筛选,只看店内全量)+ <code>user_name</code>(下单人显示名,<code>real_name</code> 为空回退 <code>username</code>)。</p>
<h2>5. 与 pay 的接口契约(v2</h2>
<h3>5.1 下单 <span class="tag pay">pay</span> <code>POST https://pay.51yanmei.com/api/v2/orders</code></h3>
<pre><span class="c">// Headers: Content-Type: application/json + 4 个签名头(§3)</span>
<span class="c">// Body(对这份原始 body 签名):</span>
{
"sku": "annual_standard", <span class="c">// 直接用 biz_code 当 sku,无需再查 product_idv1 的 productID 缓存整段已删除)</span>
"method": "alipay",
"biz_system": "jiu",
"biz_ref": "&lt;purchase_id&gt;", <span class="c">// jiu 的购买记录 id,回调原样带回</span>
"return_url": "https://jiu.51yanmei.com/license/result"
}
<span class="c">// 成功响应(不回传金额——D1:价格权威在 pay,下单后需另调 §5.3 查单拿金额):</span>
{ "data": { "order_no": "pay-x1...", "session": { "render_type": "redirect", "payload": { "url": "https://openapi.alipay.com/..." } } } }
<span class="c">// render_type 可能的值:redirect(外跳收银台,今天唯一实装) | qr(卡内嵌二维码,payload.qr_contentpay 侧文档有口径但暂无实现) | 未知值(客户端 toast 提示升级)</span></pre>
<p><code>data.order_no</code> 回写 Purchase.<code>out_trade_no</code><code>render_type=="redirect"</code> 时把 <code>payload.url</code> 存进 <code>pay_url</code> 兼容列。<b>金额以 pay 为权威</b>,下单响应不带金额,紧接着调 §5.3 查单回填。</p>
<h3>5.2 回调 <span class="tag pay">pay</span><span class="tag jiu">jiu</span>(pay 主动 POST 到你的接收器,路径不变)</h3>
<pre><span class="c">// Headers: X-Pay-Timestamp/Nonce/Sign 3 个签名头(§3),你要验签;X-Pay-Event 头带事件类型提示但不参与签名</span>
{
"event_type": "payment.succeeded", <span class="c">// v2 新增:事件分发以此字段为准;未来会有 refund.* 等事件(本期 jiu 只 ack 不处理)</span>
"out_trade_no": "pay-x1...",
"biz_system": "jiu",
"biz_ref": "&lt;purchase_id&gt;",
"product_biz_code": "annual_standard",
"amount_minor": 299900, <span class="c">// v2int64 最小单位(CNY=分),不再是 v1 的 "2999.00" 字符串</span>
"currency": "CNY",
"channel": "alipay",
"paid_at": "2026-07-03T14:36:30+08:00"
<span class="c">// 无 trade_no 字段(v1 有,v2 去掉了渠道交易号)</span>
}
<span class="c">// 你必须回:HTTP 200 + body 前 4096 字节含 "SUCCESS"(大小写不敏感),标准回包 {"code":"SUCCESS"};否则 pay 每 60s 重试,固定无退避无死信</span></pre>
<h3>5.3 查单 <span class="tag pay">pay</span> <code>GET https://pay.51yanmei.com/api/v2/orders/{order_no}</code>(无鉴权)</h3>
<pre><span class="c">// 成功响应(不回传 biz_ref/trade_no):</span>
{ "data": { "order_no": "pay-x1...", "status": "paid", "subject": "岩美酒库·标准版年付", "amount_minor": 299900, "currency": "CNY", "paid_at": "2026-07-03T14:36:30+08:00" } }
<span class="c">// status 八态:created | pending | paid | canceled | expired | refunding | partially_refunded | refunded(见 §4④映射规则)</span></pre>
<p>用途两处:①下单后 best-effort 回填金额(§4①);②查单兜底/webhook 残单核对前补查(§4②④)。查单无鉴权是 pay 既定惯例(不可猜 ID + payload 无敏感字段),非设计缺陷。</p>
<h3>5.4 取消 <span class="tag pay">pay</span> <code>POST https://pay.51yanmei.com/api/v2/orders/{order_no}/cancel</code>(无签名无请求体)</h3>
<pre><span class="c">// 成功响应,恒 200</span>
{ "data": { "canceled": true } } <span class="c">// true=确认取消(钱未扣/未入账);false=已终态或已支付竞态,本地不得跟着标失败(见 §4⑤)</span></pre>
<h2>6. 套餐 biz_code → 权益映射</h2>
<table>
<tr><th>套餐</th><th>biz_code</th><th>价格</th><th>时长</th><th>tier</th><th>客户端(MaxDevices)</th><th>权益(Features)</th></tr>
<tr><td>月付·标准</td><td class="mono">monthly_standard</td><td>¥299</td><td>+30 天</td><td>standard</td><td>2</td><td>单店/单仓库 · 千张图片分享</td></tr>
<tr><td>年付·标准</td><td class="mono">annual_standard</td><td>¥2999</td><td>+365 天</td><td>standard</td><td>2</td><td>单店/单仓库 · 千张图片分享</td></tr>
<tr><td>月付·高级</td><td class="mono">monthly_pro</td><td>¥599</td><td>+30 天</td><td>pro</td><td>5</td><td>单店/多仓库 · 万张照片分享 · 免费 AI 周/月度商业数据分析</td></tr>
<tr><td>年付·高级</td><td class="mono">annual_pro</td><td>¥5999</td><td>+365 天</td><td>pro</td><td>5</td><td>单店/多仓库 · 万张照片分享 · 免费 AI 周/月度商业数据分析</td></tr>
</table>
<p>建议 <code>Features</code>License.Features JSON)编码:<code>{"max_warehouses": 1|0(0=不限), "image_quota": 1000|10000, "ai_analysis": false|true}</code>。具体键名由 jiu 侧按现有能力开关定义,本表给权益语义。上表价格仍是人类可读展示;线上口径以 pay 查单/webhook 返回的 <code>amount_minor</code>(分)为准,如 ¥2999 ⇔ <code>299900</code></p>
<div class="callout warn">jiu 当前 Tier 未做能力差异(仅 standard)。标准/高级的<b>实际功能开关</b>(多仓库限制、图片额度、AI 分析)需 jiu 侧后续按此权益表落地;先把 tier/max_devices/features 正确写入 License,能力 gating 逐步接。</div>
<h2>7. 安全红线</h2>
<ul>
<li><b>验签必过</b>才处理(防伪造付款回调)。</li>
<li><b>幂等</b>:同一 out_trade_no 只续一次(pay 会重发)。</li>
<li><b>金额核对</b>:回调 <code>amount_minor+currency</code> == Purchase 落库值(int64 精确比较,不用浮点)。</li>
<li><b>时间戳窗口</b> ±5 分钟,防重放。</li>
<li><b>nonce 防重放</b>:10 分钟窗口内同 nonce 拒签(§3)。</li>
<li>密钥只走环境变量 / Bitwarden<b>不写代码/配置/仓库</b></li>
<li>全程 HTTPSjiu.51yanmei.com 已有)。</li>
</ul>
<h2>8. 客户端(Web + App</h2>
<ul>
<li><b>render_type 分发</b>(§5.1):<code>redirect</code>(今天唯一实装)→ 外跳 <code>payload.url</code>Web <code>window.location</code>App <code>launchUrl</code> 外部浏览器/支付宝 App);<code>qr</code> → 卡片内嵌二维码扫码(不外跳);未知值 → toast「当前版本暂不支持该支付方式,请升级应用」。</li>
<li>手机端 pay 走支付宝 <code>wap.pay</code>(H5)自动拉起支付宝 App;App 端型透传(<code>is_mobile</code>)是 pay 侧已知欠账,补契约前恒走 page.pay,不影响 PC 扫码/浏览器跳转的正常路径。</li>
<li>金额展示统一用 <code>amount_minor</code> 转元(分/100,2 位小数),0 时显示「—」(金额尚未回填,等轮询/reconcile 补齐)。</li>
<li>到账<b>以 webhook 续期为准</b>,客户端 return 后<b>轮询 jiu 购买单状态</b>(3s 间隔 + 30s 心跳兜底)确认,而非只信 return 跳转。</li>
<li>「重新选择」套餐(放弃当前 pending 单)时调 §4⑤ 取消接口,防旧单堆积;失败静默,查单兜底会最终收敛。</li>
<li><b>授权管理·订单管理</b> tab(§4⑥数据源):待支付单「继续支付」= 复用下单时存的 <code>pay_url</code> 重新打开收银台(不新建订单,不接 retry);「取消订单」= 同上取消接口;已支付单展示「授权续期至」= <code>renewed_to</code></li>
</ul>
<h2>9. 联调 Checklist</h2>
<ol>
<li>pay v2 合 main 部署;生产 <code>accounts</code> 配置 alipay 且套餐种子落库;共享密钥就位(pay <code>BIZ_JIU_SECRET</code> = jiu <code>PAY_SECRET</code>,同值,走 Bitwarden);pay 侧配 <code>BIZ_JIU_CALLBACK_URL</code> = jiu 接收器地址;jiu 侧 <code>PAY_BASE_URL</code> 指向 v2 部署。</li>
<li>¥0.01/¥1 真单走通:下单 → redirect 收银台 → 实付 → webhook 入账续期 → 客户端轮询转 success → 权限即时恢复。</li>
<li>阻断 webhook(临时改 callback_url)验证查单兜底(reconcile,每 60s 扫 pending&gt;5 分钟单)能补上账。</li>
<li>重投验证:webhook 接收器临时回 FAIL,观察 pay 60s 重投与 jiu 幂等(不重复续期)。</li>
<li>官网 checkout(读 <code>pay_url</code> 兼容字段)+ 旧版客户端购买路径回归,确认零改动仍可用。</li>
<li>取消路径:客户端「重新选择」→ 观察 pay 订单翻 <code>canceled</code>、本地翻 <code>failed</code></li>
<li>真机验证手机拉起支付宝 App(等 pay 侧端型透传补契约后再测,见 §8)。</li>
</ol>
<p style="margin-top:26px;color:var(--muted);font-size:12px;">相关:<a href="architecture/license-design.md">授权体系设计(兑换券模型)</a> · <a href="db-schema.html">数据库 Schema</a> · <a href="plans/pay-v2-integration.html">pay v2 改造计划(阅读版)</a> · pay 侧设计见 pay 仓 <code>docs/pay接入jiu授权续费对接方案.html</code></p>
<h2 style="margin-top:36px;">附录 Av1 契约(已弃用,2026-07-11 起停用,仅供历史参考)</h2>
<div class="callout warn">以下为 v1.02026-07-03)原始内容,<b>不再是当前实现</b>,保留仅为历史留痕/排障对照。新对接一律看上面 v2 主体内容。</div>
<div class="card">
<h3 style="margin-top:0;">v1 名词与地址</h3>
<table>
<tr><th></th><th></th></tr>
<tr><td>pay 下单接口</td><td class="mono">POST /api/v1/orders</td></tr>
<tr><td>pay 查单接口</td><td class="mono">GET /api/v1/orders/{out_trade_no}</td></tr>
<tr><td>pay 套餐列表</td><td class="mono">GET /api/v1/products(含 biz_code</td></tr>
</table>
<h3>v1 下单 <span class="tag pay">pay</span> <code>POST /api/v1/orders</code></h3>
<pre><span class="c">// Body</span>
{
"product_id": 3, <span class="c">// pay 套餐 id,需先 GET /products 按 biz_code 查</span>
"biz_system": "jiu",
"biz_ref": "&lt;purchase_id&gt;",
"return_url": "https://jiu.51yanmei.com/license/result"
}
<span class="c">// 成功响应:</span>
{ "data": { "pay_url": "https://openapi.alipay.com/...", "out_trade_no": "yanmei-...", "amount": "2999.00", "subject": "年付标准" } }</pre>
<h3>v1 回调 <span class="tag pay">pay</span><span class="tag jiu">jiu</span></h3>
<pre>{
"out_trade_no": "yanmei-20260703...-xxxx",
"biz_system": "jiu",
"biz_ref": "&lt;purchase_id&gt;",
"product_biz_code": "annual_standard",
"amount": "2999.00", <span class="c">// v1:元字符串,v2 改为 amount_minor(int64,分)</span>
"trade_no": "2026070322001...", <span class="c">// v2 已去掉此字段</span>
"channel": "alipay",
"paid_at": "2026-07-03T14:36:30+08:00"
}
<span class="c">// 回:HTTP 200 + {"code":"SUCCESS"}(否则 pay 每 60s 重试,24h 内——v2 改为固定无退避无死信)</span></pre>
<p style="margin-bottom:0;">v1 建议表结构 <span class="mono">id · shop_id · product_biz_code · amount · out_trade_no · status(pending/paid/failed) · paid_at · created_at</span> 已实际落库为 <code>license_purchases</code>v2 在此基础上新增 <code>amount_minor/currency/pay_url/renewed_to</code> 四列,旧 <code>amount</code> 列保留只读兼容(观察一版后 DROP),详见 <a href="db-schema.html#license_purchases">db-schema.html</a></p>
</div>
</body>
</html>