3b5d84a7e3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
29 KiB
HTML
283 lines
29 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>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=&page_size=&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_id(v1 的 productID 缓存整段已删除)</span>
|
||
"method": "alipay",
|
||
"biz_system": "jiu",
|
||
"biz_ref": "<purchase_id>", <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_content,pay 侧文档有口径但暂无实现) | 未知值(客户端 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": "<purchase_id>",
|
||
"product_biz_code": "annual_standard",
|
||
"amount_minor": 299900, <span class="c">// v2:int64 最小单位(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>全程 HTTPS(jiu.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>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;">附录 A:v1 契约(已弃用,2026-07-11 起停用,仅供历史参考)</h2>
|
||
<div class="callout warn">以下为 v1.0(2026-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": "<purchase_id>",
|
||
"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": "<purchase_id>",
|
||
"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>
|