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

208 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>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">v1.0 · 2026-07-03 · 面向 jiu 后端开发 · pay 侧已就绪(下单+签名+webhook 推送已实现验证)· 本文档 = jiu 侧要实现的部分</div>
<div class="callout ok"><b>给开发者:</b>pay 服务(收款、支付宝渠道、签名鉴权、支付成功 webhook 推送)<b>已开发并联调验证完毕</b>。你只需实现 jiu 这一侧的 4 块:① 购买接口 ② webhook 接收器 ③ 续期逻辑 ④ 查单兜底。接口契约、签名算法、权益映射见下,照着写即可。</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/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>
<tr><td>jiu 回调接收器(<b>你要实现</b></td><td class="mono">POST /api/v1/pay/callback</td></tr>
<tr><td>共享密钥</td><td>双向共用一把 HMAC 密钥,存 Bitwarden,两边经环境变量注入(pay: <code>BIZ_JIU_SECRET</code>jiu 侧自定,同值)</td></tr>
</table>
<h2>2. 整体流程</h2>
<div class="flow">jiu 客户端(已登录, 知道 shop_id) jiu 后端 pay 服务 支付宝
│ │ │ │
①选套餐, 点购买 ──────────────────────►│ │ │
│ ②建 Purchase(pending, shop_id, biz_code) │ │
│ 调 pay 下单 (product_id + biz_ref=purchase_id, 签名) ─►建订单 │
│ │◄──── {pay_url} ─────────────│ │
│◄────────── 返回 pay_url ──────────────│ │ │
③打开 pay_url 付款 (PC扫码/手机拉App) ───────────────────────────────────────────────────►付款
│ │ ④支付宝→pay 入账 ✓ │
│ │◄── ⑤pay 签名 webhook ───────│ │
│ ⑥验签→幂等→按 biz_code 给 shop 续期→回 SUCCESS │
④'客户端跳回 return_url / 刷新 → 已续期 ✓│ │ │</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>。两边同一把密钥、同一算法。</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 自己的登录态(当前用户/门店)。步骤:</p>
<ol>
<li>校验登录态,拿到当前 <code>shop_id</code>;入参为套餐(前端传 biz_code 或 plan 标识)。</li>
<li><code>Purchase</code> 记录(status=pending,存 shop_id / product_biz_code / amount)。</li>
<li>调 pay 下单(见 §5.1),<code>biz_ref = purchase_id</code><code>return_url</code> = jiu 结果页。</li>
<li>把 pay 返回的 <code>pay_url</code>(和 out_trade_no,回写 Purchase)返给客户端。</li>
</ol>
<div class="callout">建议新增表 <code>license_purchases</code><span class="mono">id · shop_id · product_biz_code · amount · out_trade_no(index) · status(pending/paid/failed) · paid_at · created_at</span><code>out_trade_no</code> 兼作对账键与幂等键。</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>(勿被框架提前解析掉),取 4 个签名头。</li>
<li><b>验签</b><code>hmac.Equal(hmacSign(secret,"jiu",ts,nonce,rawBody), X-Pay-Sign)</code><b>校验时间戳</b> ±5 分钟。任一不过 → 401,不处理。</li>
<li><b>幂等</b>:按 <code>out_trade_no</code> 查 Purchase;若已 paid → 直接回 <code>{"code":"SUCCESS"}</code>pay 会重发,必须幂等)。</li>
<li><b>金额校验</b>payload.amount 与 Purchase.amount 一致(分级比较,防篡改)。</li>
<li><b>续期</b>:按 <code>product_biz_code</code> 映射权益(§6),给该 shop 的 License <b>直接叠加 ExpiresAt</b>(§7)。</li>
<li>标记 Purchase=paid<b>回 HTTP 200 + <code>{"code":"SUCCESS"}</code></b>pay 认响应体含 SUCCESS 才算成功,否则退避重试)。</li>
</ol>
<div class="callout danger">这是「钱已到账」的入口——<b>验签 + 幂等 + 金额校验</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>(不生成兑换码):</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</pre>
<h3><span class="n">4</span>查单兜底 <span class="tag jiu">jiu</span></h3>
<p>防 webhook 全丢:对 pending 超时(如 &gt;5 分钟)的 Purchase,定时主动查 pay <code>GET /api/v1/orders/{out_trade_no}</code>,若返回 <code>status=paid</code> 则走与 webhook 相同的续期入账(同样幂等)。</p>
<h2>5. 与 pay 的接口契约</h2>
<h3>5.1 下单 <span class="tag pay">pay</span> <code>POST https://pay.51yanmei.com/api/v1/orders</code></h3>
<pre><span class="c">// Headers: Content-Type: application/json + 4 个签名头(§3)</span>
<span class="c">// Body(对这份原始 body 签名):</span>
{
"product_id": 3, <span class="c">// pay 套餐 id(见 §6,或先 GET /products 按 biz_code 查 id</span>
"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">// 成功响应:</span>
{ "data": { "pay_url": "https://openapi.alipay.com/...", "out_trade_no": "yanmei-...", "amount": "2999.00", "subject": "年付标准" } }</pre>
<p><code>data.pay_url</code> 交给客户端打开;<code>out_trade_no</code> 回写 Purchase。<b>金额以 pay 套餐表为准</b>,不要自己传金额。</p>
<h3>5.2 回调 <span class="tag pay">pay</span><span class="tag jiu">jiu</span>(pay 主动 POST 到你的接收器)</h3>
<pre><span class="c">// Headers: 4 个签名头(§3),你要验签</span>
{
"out_trade_no": "yanmei-20260703...-xxxx",
"biz_system": "jiu",
"biz_ref": "&lt;purchase_id&gt;",
"product_biz_code": "annual_standard",
"amount": "2999.00",
"trade_no": "2026070322001...", <span class="c">// 支付宝交易号</span>
"channel": "alipay",
"paid_at": "2026-07-03T14:36:30+08:00"
}
<span class="c">// 你必须回:HTTP 200 + {"code":"SUCCESS"}(否则 pay 每 60s 重试,24h 内)</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 侧按现有能力开关定义,本表给权益语义。</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>:回调 amount == Purchase.amount。</li>
<li><b>时间戳窗口</b> ±5 分钟,防重放。</li>
<li>密钥只走环境变量 / Bitwarden<b>不写代码/配置/仓库</b></li>
<li>全程 HTTPSjiu.51yanmei.com 已有)。</li>
</ul>
<h2>8. 客户端(Web + App</h2>
<ul>
<li><b>Web 入口</b>:拿到 pay_url 直接 <code>window.location</code> 跳转;付完支付宝跳回 <code>return_url</code>(带 out_trade_no),结果页轮询 jiu 授权状态。</li>
<li><b>App 入口(Flutter</b>:用<b>内置 webview</b> 打开 pay_url。</li>
<li>手机端 pay 会走支付宝 <code>wap.pay</code>(H5)自动拉起支付宝 Appwebview 需<b>放行 <code>alipays://</code> / <code>alipay://</code> scheme 唤起</b>(拦截非 http(s) 跳转交系统打开),付完靠 <code>return_url</code> 回跳 webview 页面。</li>
<li>到账<b>以 webhook 续期为准</b>,客户端 return 后应<b>查 jiu 授权状态</b>确认,而非只信 return。</li>
</ul>
<h2>9. 联调 Checklist</h2>
<ol>
<li>约定并配置共享密钥(pay <code>BIZ_JIU_SECRET</code> = jiu 侧同值);pay 侧配 <code>BIZ_JIU_CALLBACK_URL</code> = jiu 接收器地址。</li>
<li>pay 侧 seed 4 个真实套餐(biz_code 见 §6),jiu 记下对应 product_id(或用 GET /products 动态取)。</li>
<li>jiu 实现购买接口 → 下单 → 拿 pay_url(先验证签名被 pay 接受)。</li>
<li>jiu 实现 webhook 接收器 → 用 pay 真实 1 分钱订单触发(临时把某套餐改 0.01)→ 验签/幂等/续期全走通。</li>
<li>验证幂等(pay 重发不重复续期)、查单兜底(模拟 webhook 丢失)。</li>
<li>客户端 Web + App webview 两端各跑一遍付款→回跳→授权已续。</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> · pay 侧设计见 pay 仓 <code>docs/pay接入jiu授权续费对接方案.html</code></p>
</body>
</html>