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
This commit is contained in:
2026-07-03 22:44:34 +08:00
parent 6da6964451
commit fd5f56f7de
22 changed files with 861 additions and 122 deletions
+6 -4
View File
@@ -1,6 +1,6 @@
# pay — 独立支付服务
多业务 × 多渠道的收款服务。**支付宝先行(PC 网页支付),微信留接口**(无沙箱,待真实商户号)。
多业务 × 多渠道的收款服务。**支付宝(电脑网站支付)+ 微信(V3 Native 扫码)**微信无沙箱,待真实商户号配置后可用)。
栈:Go 1.26 · Gin · GORMSQLite 纯 Go 驱动,零 cgo)。
## 目录结构
@@ -16,7 +16,7 @@ pay/
│ ├── handler/ # HTTP:收款页·套餐·下单·回调·查状态
│ ├── router/ # 路由装配
│ └── util/ # 响应 / 金额(分) / 订单号生成
├── web/ # pay.html 收款页 · result.html 结果页
├── web/ # result.html 支付结果页 · qrcode 二维码渲染
└── docs/ # 设计文档(index.html 索引)
```
@@ -31,13 +31,15 @@ pay/
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/` | 收款页 |
| GET | `/result?out_trade_no=` | 结果页(return_url 落地) |
| GET | `/qrcode?text=` | 把码串渲染成二维码 PNG(微信 code_url / 支付宝扫码) |
| GET | `/health` | 健康检查 |
| GET | `/api/v1/products` | 上架套餐列表 |
| POST | `/api/v1/orders` | 下单,返回 `pay_url` |
| POST | `/api/v1/orders` | 网页支付下单,返回 `pay_url`(支付宝电脑网站支付跳转) |
| POST | `/api/v1/orders/qr` | 扫码下单,返回 `qr_code` 码串(微信 Native / 支付宝当面付) |
| GET | `/api/v1/orders/:out_trade_no` | 查订单状态(结果页轮询) |
| POST | `/api/v1/notify/alipay` | 支付宝异步回调 |
| POST | `/api/v1/notify/wechat` | 微信异步回调(V3 解密验签) |
## 沙箱联调步骤
+51
View File
@@ -2,6 +2,7 @@ package config
import (
"log"
"os"
"strings"
"github.com/spf13/viper"
@@ -11,7 +12,10 @@ type Config struct {
Server ServerConfig
Database DatabaseConfig
AlipaySandbox AlipaySandboxConfig `mapstructure:"alipay_sandbox"`
Wechat WechatConfig `mapstructure:"wechat"`
QuerySync QuerySyncConfig `mapstructure:"query_sync"`
// Biz 通用:任意业务系统(jiu / dudu / …)在 config 的 biz.<name> 下声明即可接入,无需改代码。
Biz map[string]BizSystemConfig `mapstructure:"biz"`
}
type ServerConfig struct {
@@ -37,6 +41,35 @@ type AlipaySandboxConfig struct {
AlipayPublicKey string `mapstructure:"alipay_public_key"` // 支付宝公钥(公钥模式)
}
// WechatConfig 启动时据此 upsert 一个微信商户(V3 Native)。
// 密钥(商户私钥 / APIv3 密钥)严禁写进 config.yaml,走环境变量(生产由 Bitwarden 经 rbw 灌入)。
type WechatConfig struct {
Enabled bool `mapstructure:"enabled"`
MerchantCode string `mapstructure:"merchant_code"` // 业务标识,如 yanmei-wx(与支付宝商户区分)
MerchantName string `mapstructure:"merchant_name"`
MchID string `mapstructure:"mch_id"` // 微信支付商户号
AppID string `mapstructure:"app_id"` // 绑定的 APPID(公众号/小程序/移动应用;Native 可用商户号绑定的任一)
CertSerial string `mapstructure:"cert_serial"` // 商户 API 证书序列号
PrivateKey string `mapstructure:"private_key"` // 商户 API 私钥 apiclient_key.pem(走环境变量)
APIv3Key string `mapstructure:"apiv3_key"` // APIv3 密钥(回调解密,走环境变量)
}
// BizSystemConfig 单个业务系统的对接配置。secret 双向共用(校验其下单请求签名 + 给回调 payload 签名)。
// 密钥/回调按约定从环境变量注入:BIZ_<SYSTEM>_SECRET / BIZ_<SYSTEM>_CALLBACK_URL,严禁写进 config.yaml。
type BizSystemConfig struct {
CallbackURL string `mapstructure:"callback_url"` // 支付成功回调业务方接收器地址
Secret string `mapstructure:"secret"` // HMAC 共享密钥
}
// BizByName 按业务系统名取配置(供下单鉴权 / webhook 回调用)。未声明或无密钥则视为未接入。
func (c *Config) BizByName(system string) (BizSystemConfig, bool) {
cfg, ok := c.Biz[system]
if !ok || cfg.Secret == "" {
return BizSystemConfig{}, false
}
return cfg, true
}
// QuerySyncConfig 兜底主动查单:定时把待支付订单拿去 alipay.trade.query 核对,防回调丢失。
type QuerySyncConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -58,6 +91,8 @@ func Load() {
// 敏感项支持环境变量覆盖(部署时不写进 config.yaml
_ = viper.BindEnv("alipay_sandbox.app_private_key", "ALIPAY_APP_PRIVATE_KEY")
_ = viper.BindEnv("alipay_sandbox.alipay_public_key", "ALIPAY_PUBLIC_KEY")
_ = viper.BindEnv("wechat.private_key", "WECHAT_MCH_PRIVATE_KEY")
_ = viper.BindEnv("wechat.apiv3_key", "WECHAT_APIV3_KEY")
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
viper.SetDefault("server.port", "8080")
@@ -68,6 +103,9 @@ func Load() {
viper.SetDefault("alipay_sandbox.enabled", false)
viper.SetDefault("alipay_sandbox.merchant_code", "yanmei")
viper.SetDefault("alipay_sandbox.merchant_name", "演么测试商户")
viper.SetDefault("wechat.enabled", false)
viper.SetDefault("wechat.merchant_code", "yanmei-wx")
viper.SetDefault("wechat.merchant_name", "岩美(北京)技术有限公司")
viper.SetDefault("query_sync.enabled", true)
viper.SetDefault("query_sync.interval_sec", 30)
viper.SetDefault("query_sync.max_age_min", 30)
@@ -78,4 +116,17 @@ func Load() {
if err := viper.Unmarshal(&C); err != nil {
log.Fatalf("[config] 解析配置失败: %v", err)
}
// 各业务系统密钥/回调按约定从环境变量注入(不写进 config.yaml):
// BIZ_<SYSTEM>_SECRET / BIZ_<SYSTEM>_CALLBACK_URLSYSTEM 为大写业务名,如 BIZ_JIU_SECRET)。
for name, bc := range C.Biz {
up := strings.ToUpper(name)
if bc.Secret == "" {
bc.Secret = os.Getenv("BIZ_" + up + "_SECRET")
}
if bc.CallbackURL == "" {
bc.CallbackURL = os.Getenv("BIZ_" + up + "_CALLBACK_URL")
}
C.Biz[name] = bc
}
}
+25
View File
@@ -25,6 +25,31 @@ alipay_sandbox:
app_private_key: "" # 留空!走环境变量 ALIPAY_APP_PRIVATE_KEY
alipay_public_key: "" # 留空!走环境变量 ALIPAY_PUBLIC_KEY
# 启动时据此 upsert 一个微信商户(V3 Native 扫码)。微信无沙箱:enabled=true 即正式收款。
# 密钥严禁写进本文件!走环境变量(生产由 Bitwarden 经 rbw 灌入):
# WECHAT_MCH_PRIVATE_KEY(商户私钥 apiclient_key.pem / WECHAT_APIV3_KEYAPIv3 密钥)
wechat:
enabled: false
merchant_code: "yanmei-wx" # 与支付宝商户区分
merchant_name: "岩美(北京)技术有限公司"
mch_id: "" # 微信支付商户号(入驻后拿到)
app_id: "" # 绑定的 APPID
cert_serial: "" # 商户 API 证书序列号
private_key: "" # 留空!走环境变量 WECHAT_MCH_PRIVATE_KEY
apiv3_key: "" # 留空!走环境变量 WECHAT_APIV3_KEY
# 业务系统对接(通用)。任意业务系统在 biz.<name> 下加一段即可接入,无需改代码。
# 契约见 pay-contract 仓(OpenAPI + README)。业务方带签名下单 + 入账后 pay 回调其 webhook。
# 每个业务的 secret/callback 按约定走环境变量:BIZ_<NAME 大写>_SECRET / BIZ_<NAME 大写>_CALLBACK_URL
# 严禁写本文件(callback 可写这里,secret 一律走 env)。
biz:
jiu:
callback_url: "https://jiu.51yanmei.com/api/v1/pay/callback" # 支付成功回调 jiu 的接收器
secret: "" # 留空!走环境变量 BIZ_JIU_SECRET
# dudu: # 将来接入 dudu:加这段 + 设 BIZ_DUDU_SECRET
# callback_url: "https://.../api/v1/pay/callback"
# secret: ""
# 兜底主动查单:定时把待支付订单拿去查,防异步回调丢失
query_sync:
enabled: true
+6
View File
@@ -40,6 +40,12 @@
<div class="meta">v1.0 · 2026-06-24 · onboarding · 含备案依赖路线图</div>
</a>
<a class="doc" href="./pay接入jiu授权续费对接方案.html">
<div class="title">pay × jiu · 授权续费对接方案</div>
<div class="desc">让 jiu 门店在应用内购买/续费授权:付款走 pay 收款中枢,付成功后 pay 签名 webhook 回调 jiu、jiu 直接给该门店续期(方案 B,无感)。含总体架构与时序、pay/jiu 两侧数据模型改动(Order 加 biz 字段·Product 加 biz_code·Purchase·BizNotifyLog)、接口契约(下单扩展/webhook/购买/接收器)、HMAC 签名+防重放+幂等安全设计、套餐映射、失败重试对账、分阶段实施计划、开放问题清单。</div>
<div class="meta">v1.0 · 2026-07-03 · 方案 B(付款直接续期)· server-to-server + 签名 webhook · 待评审</div>
</a>
<h2>📋 实现计划</h2>
<p class="empty">见仓库根 README.md(运行与联调步骤)</p>
@@ -0,0 +1,214 @@
<!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>
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartwalle/alipay/v3 v3.2.29
github.com/spf13/viper v1.21.0
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
gorm.io/gorm v1.31.1
)
+5
View File
@@ -1,3 +1,5 @@
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -109,6 +111,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -119,6 +122,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+20
View File
@@ -32,6 +32,23 @@ func newAlipay(m *model.Merchant) (Channel, error) {
func (a *alipayChannel) Name() string { return "alipay" }
func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) {
// 手机浏览器:手机网站支付 wap.pay,H5 收银台可直接拉起支付宝 App 付款。
if req.IsMobile {
var p = alipay.TradeWapPay{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
p.TotalAmount = req.Amount
p.ProductCode = "QUICK_WAP_WAY"
p.NotifyURL = req.NotifyURL
p.ReturnURL = req.ReturnURL
u, err := a.client.TradeWapPay(p)
if err != nil {
return "", fmt.Errorf("支付宝手机网站下单失败: %w", err)
}
return u.String(), nil
}
// PC 浏览器:电脑网站支付 page.pay。
var p = alipay.TradePagePay{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
@@ -39,6 +56,9 @@ func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
p.NotifyURL = req.NotifyURL
p.ReturnURL = req.ReturnURL
// qr_pay_mode=2 跳转模式:跳到支付宝完整扫码收银台(含收款方/金额/订单详情),
// 而非默认「登录付款」页;区别于 4(光秃秃的嵌入式二维码)。
p.QRPayMode = "2"
u, err := a.client.TradePagePay(p)
if err != nil {
return "", fmt.Errorf("支付宝下单失败: %w", err)
+1
View File
@@ -17,6 +17,7 @@ type CreateReq struct {
Amount string // 元,两位小数
ReturnURL string
NotifyURL string
IsMobile bool // 客户端是否手机浏览器:支付宝据此走手机网站支付(wap.pay 拉起 App) 而非电脑网站支付(page.pay 扫码)
}
// NotifyResult 异步通知验签解析后的统一结果。
+13
View File
@@ -43,6 +43,19 @@ func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error)
return ch, &m, err
}
// FirstWechat 取一个启用中的微信商户及其渠道,用于回调解密验签。
// 微信 V3 回调正文加密,需先用商户凭证(平台证书 + APIv3 密钥)解密才能拿到 out_trade_no
// 同一公司主体共用一个 mch_id/apiv3_key/证书,故取任一启用的微信商户即可解密,
// 解密后再按 out_trade_no 定位到订单真正归属的商户入账。
func (r *Registry) FirstWechat() (Channel, *model.Merchant, error) {
var m model.Merchant
if err := r.db.First(&m, "channel = ? AND enabled = ?", "wechat", true).Error; err != nil {
return nil, nil, fmt.Errorf("没有启用的微信商户: %w", err)
}
ch, err := r.get(&m)
return ch, &m, err
}
func (r *Registry) get(m *model.Merchant) (Channel, error) {
r.mu.RLock()
if ch, ok := r.cache[m.ID]; ok {
+132 -12
View File
@@ -3,30 +3,150 @@ package channel
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
)
// ErrNotImplemented 渠道尚未实现。
var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现")
// wechatChannel 微信支付 V3 · Native(扫码)实现。
// PC 网站场景微信只提供 Native 扫码(无支付宝那种网页跳转收银台),所以:
// - PreCreate → Native 下单,返回 code_url(前端经 /qrcode 渲染成二维码)
// - PagePay → 不适用,返回错误引导改用扫码
// - VerifyNotify / Query → 回调解密验签 / 主动查单
type wechatChannel struct {
mchID string
appID string
client *core.Client
native *native.NativeApiService
handler *notify.Handler
}
// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。
type wechatChannel struct{ m *model.Merchant }
func newWechat(m *model.Merchant) (Channel, error) {
if m.MchID == "" || m.WxAppID == "" || m.WxPrivateKey == "" || m.CertSerial == "" || m.APIv3Key == "" {
return nil, fmt.Errorf("商户 %s 的微信凭证不完整(mch_id/wx_app_id/cert_serial/wx_private_key/apiv3_key", m.Code)
}
func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil }
mchPrivateKey, err := utils.LoadPrivateKey(m.WxPrivateKey)
if err != nil {
return nil, fmt.Errorf("加载微信商户私钥失败: %w", err)
}
// 初始化时自动下载并周期性更新微信支付平台证书(需公网可达微信 API)。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(
m.MchID, m.CertSerial, mchPrivateKey, m.APIv3Key,
))
if err != nil {
return nil, fmt.Errorf("初始化微信支付客户端失败: %w", err)
}
// 回调处理器:用平台证书验签 + APIv3 密钥解密。
certVisitor := downloader.MgrInstance().GetCertificateVisitor(m.MchID)
handler, err := notify.NewRSANotifyHandler(m.APIv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor))
if err != nil {
return nil, fmt.Errorf("初始化微信回调处理器失败: %w", err)
}
return &wechatChannel{
mchID: m.MchID,
appID: m.WxAppID,
client: client,
native: &native.NativeApiService{Client: client},
handler: handler,
}, nil
}
func (w *wechatChannel) Name() string { return "wechat" }
// PagePay 微信无网页跳转收银台(PC 网站走 Native 扫码),改用 PreCreate。
func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
return "", errors.New("微信支付无网页跳转收银台,请用扫码下单(CreateQR / PreCreate")
}
func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
// PreCreate Native 下单,返回 code_url 供渲染二维码。
func (w *wechatChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) {
cents, err := util.AmountToCents(req.Amount)
if err != nil {
return "", fmt.Errorf("金额换算失败: %w", err)
}
resp, _, err := w.native.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.appID),
Mchid: core.String(w.mchID),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OutTradeNo),
NotifyUrl: core.String(req.NotifyURL),
Amount: &native.Amount{
Total: core.Int64(cents),
Currency: core.String("CNY"),
},
})
if err != nil {
return "", fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", errors.New("微信 Native 下单未返回 code_url")
}
return *resp.CodeUrl, nil
}
func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) {
return nil, ErrNotImplemented
// VerifyNotify 解密验签微信异步回调,解析成统一结果。
func (w *wechatChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
tx := new(payments.Transaction)
if _, err := w.handler.ParseNotifyRequest(ctx, r, tx); err != nil {
return nil, fmt.Errorf("微信回调验签/解密失败: %w", err)
}
return txToNotifyResult(tx), nil
}
func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) {
return nil, ErrNotImplemented
// Query 主动查单(out_trade_no 维度)。
func (w *wechatChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) {
tx, _, err := w.native.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(outTradeNo),
Mchid: core.String(w.mchID),
})
if err != nil {
// 交易不存在等:视为未找到,交由上层决定(与支付宝实现一致)。
return &QueryResult{Found: false}, nil
}
res := txToNotifyResult(tx)
return &QueryResult{
Found: true,
OutTradeNo: res.OutTradeNo,
TradeNo: res.TradeNo,
Amount: res.Amount,
Paid: res.Paid,
}, nil
}
// txToNotifyResult 把微信交易对象归一化成本服务的统一结构(金额分→元字符串)。
func txToNotifyResult(tx *payments.Transaction) *NotifyResult {
res := &NotifyResult{}
if tx.OutTradeNo != nil {
res.OutTradeNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
res.TradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
res.Amount = util.CentsToAmount(*tx.Amount.Total)
}
if tx.Payer != nil && tx.Payer.Openid != nil {
res.BuyerLogonID = *tx.Payer.Openid
}
// 交易状态:SUCCESS 视为已支付(其余 NOTPAY/CLOSED/REFUND 等均非成功)。
res.Paid = tx.TradeState != nil && *tx.TradeState == "SUCCESS"
return res
}
+81 -3
View File
@@ -1,15 +1,33 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/service"
"github.com/wangjia/pay/internal/util"
)
// isMobileUA 粗略判断是否手机浏览器(决定支付宝走 wap.pay 拉起 App 还是 page.pay 扫码)。
func isMobileUA(ua string) bool {
ua = strings.ToLower(ua)
for _, kw := range []string{"android", "iphone", "ipod", "mobile", "harmony", "windows phone"} {
if strings.Contains(ua, kw) {
return true
}
}
return false
}
type OrderHandler struct {
svc *service.OrderService
}
@@ -19,17 +37,67 @@ func NewOrderHandler(svc *service.OrderService) *OrderHandler {
}
type createOrderRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
ProductID uint64 `json:"product_id"`
BizSystem string `json:"biz_system,omitempty"` // 业务对接来源(如 jiu);带则需签名鉴权
BizRef string `json:"biz_ref,omitempty"` // 业务引用(jiu purchase_id
ReturnURL string `json:"return_url,omitempty"` // 自定义付款后跳回地址
}
// verifyBizSign 校验业务方下单请求的 HMAC 签名(防外部乱下单/伪造业务单)。
func verifyBizSign(c *gin.Context, system string, rawBody []byte) error {
cfg, ok := config.C.BizByName(system)
if !ok {
return fmt.Errorf("未知或未配置的业务系统: %s", system)
}
if c.GetHeader("X-Pay-System") != system {
return errors.New("X-Pay-System 与 biz_system 不一致")
}
ts := c.GetHeader("X-Pay-Timestamp")
nonce := c.GetHeader("X-Pay-Nonce")
sign := c.GetHeader("X-Pay-Sign")
if ts == "" || nonce == "" || sign == "" {
return errors.New("缺少签名头 X-Pay-Timestamp/Nonce/Sign")
}
tsi, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("时间戳格式错误")
}
if d := time.Now().Unix() - tsi; d > 300 || d < -300 {
return errors.New("请求已过期(时间戳超 5 分钟窗口)")
}
if !util.HMACVerify(cfg.Secret, sign, system, ts, nonce, string(rawBody)) {
return errors.New("签名校验失败")
}
return nil
}
// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。
// 独立收款(浏览器 /paytest):只传 product_id,无需签名。
// 业务对接(jiu 后端):带 biz_system/biz_ref + HMAC 签名头,服务端校验后受理。
func (h *OrderHandler) Create(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "读取请求失败")
return
}
var req createOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
return
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP())
if req.ProductID == 0 {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 product_id")
return
}
if req.BizSystem != "" {
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP(),
isMobileUA(c.Request.UserAgent()),
service.BizParams{System: req.BizSystem, Ref: req.BizRef, ReturnURL: req.ReturnURL})
if err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
@@ -80,6 +148,16 @@ func (h *OrderHandler) AlipayNotify(c *gin.Context) {
c.String(http.StatusOK, "success")
}
// WechatNotify POST /api/v1/notify/wechat —— 微信异步回调。
// 成功回 200 + {"code":"SUCCESS"};失败回非 200 + {"code":"FAIL"} 让微信按策略重发。
func (h *OrderHandler) WechatNotify(c *gin.Context) {
if err := h.svc.HandleWechatNotify(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "OK"})
}
// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。
func (h *OrderHandler) GetStatus(c *gin.Context) {
out := c.Param("out_trade_no")
+1 -5
View File
@@ -20,11 +20,6 @@ func NewPageHandler(db *gorm.DB, webDir string) *PageHandler {
return &PageHandler{db: db, webDir: webDir}
}
// PayPage GET / —— 收款页。
func (h *PageHandler) PayPage(c *gin.Context) {
c.File(h.webDir + "/pay.html")
}
// ResultPage GET /result —— 支付结果页(return_url 落地)。
func (h *PageHandler) ResultPage(c *gin.Context) {
c.File(h.webDir + "/result.html")
@@ -59,6 +54,7 @@ func (h *PageHandler) ListProducts(c *gin.Context) {
"name": p.Name,
"description": p.Description,
"price": p.Price,
"biz_code": p.BizCode, // 业务方按 biz_code 查 product_id
})
}
util.RespondSuccess(c, out)
+13
View File
@@ -0,0 +1,13 @@
package model
// BizNotifyLog 业务系统回调(pay → 业务方 webhook)日志,供排障 / 重放追踪。
type BizNotifyLog struct {
Base
OutTradeNo string `gorm:"index;size:64" json:"out_trade_no"`
BizSystem string `gorm:"size:32" json:"biz_system"`
URL string `gorm:"size:255" json:"url"`
Payload string `gorm:"type:text" json:"payload"`
RespCode int `json:"resp_code"`
RespBody string `gorm:"type:text" json:"resp_body"`
OK bool `json:"ok"`
}
+5
View File
@@ -25,4 +25,9 @@ type Order struct {
BuyerLogonID string `gorm:"size:128" json:"buyer_logon_id"`
PaidAt *time.Time `json:"paid_at"`
ClientIP string `gorm:"size:64" json:"client_ip"`
// —— 业务对接(如 jiu 授权续费)——
BizSystem string `gorm:"index;size:32" json:"biz_system,omitempty"` // 业务来源,如 jiu;决定入账后回调哪个 webhook。空=独立收款(/paytest 等)
BizRef string `gorm:"size:128" json:"biz_ref,omitempty"` // 业务引用(jiu 的 purchase_id),webhook 原样回传
BizNotified bool `gorm:"default:false" json:"biz_notified"` // 业务 webhook 是否已成功回调(幂等/重试用)
}
+3
View File
@@ -10,4 +10,7 @@ type Product struct {
Price string `gorm:"size:20;not null" json:"price"` // 元,两位小数
Active bool `gorm:"default:true" json:"active"`
Sort int `json:"sort"`
// BizCode 稳定套餐码(如 annual_standard),业务 webhook 回传,供业务方按码映射权益(时长/档位),
// 避免业务方硬编码数字 product_id。空=无业务语义(纯测试套餐)。
BizCode string `gorm:"size:64;index" json:"biz_code,omitempty"`
}
+1 -1
View File
@@ -19,7 +19,6 @@ func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderServ
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
// 页面
r.GET("/", pageH.PayPage)
r.GET("/result", pageH.ResultPage)
r.GET("/qrcode", pageH.QRCode)
@@ -30,6 +29,7 @@ func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderServ
v1.POST("/orders/qr", orderH.CreateQR)
v1.GET("/orders/:out_trade_no", orderH.GetStatus)
v1.POST("/notify/alipay", orderH.AlipayNotify)
v1.POST("/notify/wechat", orderH.WechatNotify)
}
return orderSvc
+171 -6
View File
@@ -1,15 +1,22 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/channel"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
@@ -30,8 +37,15 @@ func NewOrderService(db *gorm.DB, reg *channel.Registry, baseURL string) *OrderS
return &OrderService{db: db, reg: reg, baseURL: baseURL}
}
// BizParams 业务对接下单参数(独立收款场景全空)。
type BizParams struct {
System string // 业务来源,如 jiu
Ref string // 业务引用,如 jiu 的 purchase_id
ReturnURL string // 自定义付款后跳回地址(空则用 pay 默认结果页)
}
// prepare 校验套餐、取渠道、落库一张待支付订单(金额一律取服务端套餐价,不信任前端)。
func (s *OrderService) prepare(productID uint64, clientIP string) (channel.Channel, *model.Merchant, *model.Order, error) {
func (s *OrderService) prepare(productID uint64, clientIP string, biz BizParams) (channel.Channel, *model.Merchant, *model.Order, error) {
var p model.Product
if err := s.db.First(&p, "id = ? AND active = ?", productID, true).Error; err != nil {
return nil, nil, nil, ErrProductNotFound
@@ -49,6 +63,8 @@ func (s *OrderService) prepare(productID uint64, clientIP string) (channel.Chann
Amount: p.Price, // 权威金额
Status: model.OrderPending,
ClientIP: clientIP,
BizSystem: biz.System,
BizRef: biz.Ref,
}
if err := s.db.Create(order).Error; err != nil {
return nil, nil, nil, fmt.Errorf("创建订单失败: %w", err)
@@ -60,9 +76,21 @@ func (s *OrderService) notifyURL(channel string) string {
return s.baseURL + "/api/v1/notify/" + channel
}
// Create 网页支付下单,返回收银台跳转 URL
func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP)
// returnURL 付款后同步跳转地址:业务方传了自定义地址就用它(拼上 out_trade_no),否则用 pay 默认结果页
func (s *OrderService) returnURL(custom, outTradeNo string) string {
if custom == "" {
return s.baseURL + "/result?out_trade_no=" + outTradeNo
}
sep := "?"
if strings.Contains(custom, "?") {
sep = "&"
}
return custom + sep + "out_trade_no=" + outTradeNo
}
// Create 网页支付下单,返回收银台跳转 URL。isMobile=true 时支付宝走手机网站支付(拉起 App)。
func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP string, isMobile bool, biz BizParams) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP, biz)
if err != nil {
return "", nil, err
}
@@ -71,7 +99,8 @@ func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP st
Subject: order.Subject,
Amount: order.Amount,
NotifyURL: s.notifyURL(m.Channel),
ReturnURL: s.baseURL + "/result?out_trade_no=" + order.OutTradeNo,
ReturnURL: s.returnURL(biz.ReturnURL, order.OutTradeNo),
IsMobile: isMobile,
})
if err != nil {
return "", nil, err
@@ -81,7 +110,7 @@ func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP st
// CreateQR 扫码(当面付)下单,返回二维码码串供前端渲染。
func (s *OrderService) CreateQR(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP)
ch, m, order, err := s.prepare(productID, clientIP, BizParams{})
if err != nil {
return "", nil, err
}
@@ -123,6 +152,38 @@ func (s *OrderService) HandleAlipayNotify(ctx context.Context, r *http.Request)
return err
}
// HandleWechatNotify 处理微信异步回调:解密验签 → 按 out_trade_no 定位订单/商户 → 核对金额 → 幂等更新。
// 微信回调正文加密且不含明文商户路由信息,故先用任一启用的微信商户凭证解密(同主体共用),
// 再按解密出的 out_trade_no 找到订单实际归属的商户入账。返回 nil 表示已正确处理。
func (s *OrderService) HandleWechatNotify(ctx context.Context, r *http.Request) error {
ch, _, err := s.reg.FirstWechat()
if err != nil {
s.logNotify("wechat", "", false, "no_merchant", "")
return err
}
res, err := ch.VerifyNotify(ctx, r)
if err != nil {
s.logNotify("wechat", "", false, "verify_failed", "")
return err
}
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", res.OutTradeNo).Error; err != nil {
s.logNotify("wechat", res.OutTradeNo, true, "not_found", res.Raw)
return fmt.Errorf("订单不存在: %s", res.OutTradeNo)
}
var m model.Merchant
if err := s.db.First(&m, o.MerchantID).Error; err != nil {
s.logNotify("wechat", res.OutTradeNo, true, "merchant_not_found", res.Raw)
return fmt.Errorf("订单 %s 的商户不存在: %w", res.OutTradeNo, err)
}
result, err := s.applyPaid(&m, res)
s.logNotify("wechat", res.OutTradeNo, true, result, res.Raw)
return err
}
// applyPaid 在一个事务里完成「金额核对 + 幂等置为已支付」。返回处理结果标记。
func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (string, error) {
if !res.Paid {
@@ -164,6 +225,10 @@ func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (
log.Printf("[notify] 订单 %s 已支付 trade_no=%s amount=%s", o.OutTradeNo, res.TradeNo, res.Amount)
return nil
})
// 新入账成功且属业务对接单:异步回调业务系统 webhook(失败由后台重试兜底,不阻塞支付回调响应)。
if err == nil && resultTag == "processed" {
go s.notifyBizByOutTradeNo(res.OutTradeNo)
}
return resultTag, err
}
@@ -227,3 +292,103 @@ func (s *OrderService) StartQuerySync(interval, maxAge time.Duration) {
}
}()
}
// notifyBizByOutTradeNo 向业务系统推送「支付成功」webhook(签名)。成功则置 BizNotified,失败留待重试。
func (s *OrderService) notifyBizByOutTradeNo(outTradeNo string) {
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", outTradeNo).Error; err != nil {
return
}
if o.BizSystem == "" || o.BizNotified || o.Status != model.OrderPaid {
return
}
cfg, ok := config.C.BizByName(o.BizSystem)
if !ok {
log.Printf("[biz_notify] 订单 %s 业务系统 %s 未配置回调,跳过", o.OutTradeNo, o.BizSystem)
return
}
var p model.Product
_ = s.db.First(&p, o.ProductID).Error // 取 biz_code;失败则为空
paidAt := ""
if o.PaidAt != nil {
paidAt = o.PaidAt.Format(time.RFC3339)
}
body, _ := json.Marshal(map[string]any{
"out_trade_no": o.OutTradeNo,
"biz_system": o.BizSystem,
"biz_ref": o.BizRef,
"product_biz_code": p.BizCode,
"amount": o.Amount,
"trade_no": o.TradeNo,
"channel": o.Channel,
"paid_at": paidAt,
})
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sign := util.HMACSign(cfg.Secret, o.BizSystem, ts, nonce, string(body))
req, err := http.NewRequest(http.MethodPost, cfg.CallbackURL, bytes.NewReader(body))
if err != nil {
log.Printf("[biz_notify] 订单 %s 构造回调请求失败: %v", o.OutTradeNo, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-System", o.BizSystem)
req.Header.Set("X-Pay-Timestamp", ts)
req.Header.Set("X-Pay-Nonce", nonce)
req.Header.Set("X-Pay-Sign", sign)
entry := &model.BizNotifyLog{OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, URL: cfg.CallbackURL, Payload: string(body)}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
success := false
if err != nil {
entry.RespBody = err.Error()
} else {
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
entry.RespCode = resp.StatusCode
entry.RespBody = string(rb)
// 约定:业务方返回 HTTP 200 且响应含 SUCCESS 视为受理成功。
if resp.StatusCode == http.StatusOK && strings.Contains(strings.ToUpper(string(rb)), "SUCCESS") {
success = true
}
}
entry.OK = success
_ = s.db.Create(entry).Error
if success {
s.db.Model(&model.Order{}).Where("out_trade_no = ?", o.OutTradeNo).Update("biz_notified", true)
log.Printf("[biz_notify] 订单 %s 已成功回调 %s", o.OutTradeNo, o.BizSystem)
} else {
log.Printf("[biz_notify] 订单 %s 回调 %s 失败(code=%d),等待重试", o.OutTradeNo, o.BizSystem, entry.RespCode)
}
}
// NotifyBizPending 兜底重试:把已支付但未成功回调业务方的订单再推一次。
func (s *OrderService) NotifyBizPending() {
var orders []model.Order
cutoff := time.Now().Add(-24 * time.Hour)
if err := s.db.Where("status = ? AND biz_system <> '' AND biz_notified = ? AND created_at > ?",
model.OrderPaid, false, cutoff).Limit(50).Find(&orders).Error; err != nil {
log.Printf("[biz_notify] 查询待回调订单失败: %v", err)
return
}
for i := range orders {
s.notifyBizByOutTradeNo(orders[i].OutTradeNo)
}
}
// StartBizNotifyRetry 启动业务回调重试循环(兜底 webhook 丢失/业务方短暂不可用)。
func (s *OrderService) StartBizNotifyRetry(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
s.NotifyBizPending()
}
}()
}
+5
View File
@@ -23,6 +23,11 @@ func AmountToCents(s string) (int64, error) {
return int64(math.Round(f * 100)), nil
}
// CentsToAmount 把分(int64)转回 "0.01" 元字符串(微信接口用分,本服务统一用元字符串)。
func CentsToAmount(cents int64) string {
return strconv.FormatFloat(float64(cents)/100, 'f', 2, 64)
}
// AmountEqual 判断两个元金额字符串是否等值(按分比较,避免浮点/格式差异)。
func AmountEqual(a, b string) bool {
ca, err1 := AmountToCents(a)
+22
View File
@@ -0,0 +1,22 @@
package util
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
// HMACSign 用共享密钥对若干片段做 HMAC-SHA256 签名(片段以 \n 连接),返回 base64。
// 业务对接双向共用:pay 校验业务方下单请求签名 + 给回调 payload 签名。
func HMACSign(secret string, parts ...string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strings.Join(parts, "\n")))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// HMACVerify 常量时间比对签名,防时序侧信道。
func HMACVerify(secret, sig string, parts ...string) bool {
expected := HMACSign(secret, parts...)
return hmac.Equal([]byte(expected), []byte(sig))
}
+85
View File
@@ -22,6 +22,7 @@ func main() {
db := initDB()
autoMigrate(db)
seed(db)
seedWechat(db)
reg := channel.NewRegistry(db)
@@ -39,6 +40,9 @@ func main() {
log.Printf("查单兜底已启动:每 %ds 一次", config.C.QuerySync.IntervalSec)
}
// 业务回调重试兜底(webhook 丢失/业务方短暂不可用时补推)。
orderSvc.StartBizNotifyRetry(60 * time.Second)
addr := ":" + config.C.Server.Port
log.Printf("支付服务启动 %s (mode=%s, base_url=%s)", addr, config.C.Server.Mode, config.C.Server.BaseURL)
if err := r.Run(addr); err != nil {
@@ -75,6 +79,7 @@ func autoMigrate(db *gorm.DB) {
&model.Product{},
&model.Order{},
&model.NotifyLog{},
&model.BizNotifyLog{},
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}
@@ -121,4 +126,84 @@ func seed(db *gorm.DB) {
}
log.Printf("[seed] 已为商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
}
seedPlans(db, m.ID)
}
// seedPlans 幂等 upsert 业务套餐(带 biz_code,供 jiu 等业务系统按 biz_code 查 product)。
// 价格权威在此表;权益(时长/档位/设备/功能)由业务方按 biz_code 映射。
func seedPlans(db *gorm.DB, merchantID uint64) {
plans := []model.Product{
{Name: "联调测试套餐", Description: "1 分钱端到端联调用(标准权益)", Price: "0.01", BizCode: "test_liandiao", Sort: 0},
{Name: "限时优惠 · 1元首月体验", Description: "标准权益 · 30 天 · 每门店限一次(限次由 jiu 侧校验)", Price: "1.00", BizCode: "promo_first_month", Sort: 1},
{Name: "月付 · 标准", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "299.00", BizCode: "monthly_standard", Sort: 2},
{Name: "月付 · 高级", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "599.00", BizCode: "monthly_pro", Sort: 3},
{Name: "年付 · 标准", Description: "单店/单仓库 · 2 客户端 · 千张图片分享", Price: "2999.00", BizCode: "annual_standard", Sort: 4},
{Name: "年付 · 高级", Description: "单店/多仓库 · 5 客户端 · 万张照片 · 免费 AI 周/月度商业分析", Price: "5999.00", BizCode: "annual_pro", Sort: 5},
}
for _, p := range plans {
var existing model.Product
err := db.Where("biz_code = ?", p.BizCode).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
p.MerchantID = merchantID
p.Active = true
if err := db.Create(&p).Error; err != nil {
log.Fatalf("[seed] 创建套餐 %s 失败: %v", p.BizCode, err)
}
continue
} else if err != nil {
log.Fatalf("[seed] 查询套餐 %s 失败: %v", p.BizCode, err)
}
// 已存在:更新价格/文案/归属(价格权威可从此改)。
db.Model(&existing).Updates(map[string]any{
"merchant_id": merchantID, "name": p.Name, "description": p.Description,
"price": p.Price, "active": true, "sort": p.Sort,
})
}
log.Printf("[seed] 业务套餐就绪:%d 个(含联调 0.01)", len(plans))
}
// seedWechat 据 config 的 wechat upsert 一个微信商户(V3 Native),并为其补两个测试套餐。
// 微信无沙箱:wechat.enabled=true 即视为正式(Production=true),只能用真实商户号做 1 分钱实测。
func seedWechat(db *gorm.DB) {
wc := config.C.Wechat
if !wc.Enabled {
log.Println("[seed] wechat.enabled=false,跳过微信商户初始化")
return
}
var m model.Merchant
err := db.Where("code = ?", wc.MerchantCode).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
m = model.Merchant{
Code: wc.MerchantCode,
Name: wc.MerchantName,
Channel: "wechat",
}
} else if err != nil {
log.Fatalf("[seed] 查询微信商户失败: %v", err)
}
m.Production = true // 微信无沙箱,一律正式网关
m.Enabled = true
m.MchID = wc.MchID
m.WxAppID = wc.AppID
m.CertSerial = wc.CertSerial
m.WxPrivateKey = wc.PrivateKey
m.APIv3Key = wc.APIv3Key
if err := db.Save(&m).Error; err != nil {
log.Fatalf("[seed] 保存微信商户失败: %v", err)
}
log.Printf("[seed] 微信商户就绪: code=%s mch_id=%s", m.Code, m.MchID)
var cnt int64
db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt)
if cnt == 0 {
samples := []model.Product{
{MerchantID: m.ID, Name: "微信测试套餐 A", Description: "微信联调用", Price: "0.01", Active: true, Sort: 1},
{MerchantID: m.ID, Name: "微信测试套餐 B", Description: "微信联调用", Price: "0.02", Active: true, Sort: 2},
}
if err := db.Create(&samples).Error; err != nil {
log.Fatalf("[seed] 创建微信测试套餐失败: %v", err)
}
log.Printf("[seed] 已为微信商户 %s 创建 %d 个测试套餐", m.Code, len(samples))
}
}
-91
View File
@@ -1,91 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收款</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--radius:14px}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:radial-gradient(1000px 500px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;padding:24px}
.box{width:100%;max-width:420px}
h1{font-size:22px;margin:0 0 4px;text-align:center}
.sub{color:var(--muted);font-size:13px;text-align:center;margin-bottom:22px}
.item{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:12px 0;cursor:pointer;transition:.15s}
.item:hover{border-color:var(--accent);background:var(--card-2)}
.item.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
.item .name{font-weight:600}
.item .desc{color:var(--fg-soft);font-size:13px;margin-top:2px}
.item .price{color:var(--green);font-size:20px;font-weight:700;white-space:nowrap}
.item .price small{font-size:12px;color:var(--muted);font-weight:400}
button{width:100%;margin-top:18px;padding:14px;border:0;border-radius:12px;background:var(--accent);color:#04122b;font-size:16px;font-weight:700;cursor:pointer;transition:.15s}
button:disabled{opacity:.5;cursor:not-allowed}
.msg{margin-top:14px;text-align:center;font-size:13px;color:var(--muted);min-height:18px}
.msg.err{color:#f85149}
.hint{margin-top:16px;font-size:12.5px;color:var(--muted);text-align:center;line-height:1.7}
.hint code{background:var(--card-2);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--fg-soft)}
</style>
</head>
<body>
<div class="box">
<h1>选择套餐</h1>
<div class="sub">支付宝支付 · 沙箱联调</div>
<div id="list"></div>
<button id="pay" disabled>请选择套餐</button>
<div class="msg" id="msg"></div>
<div class="hint">
跳转到支付宝收银台后,用<strong>右边「登录支付宝账户付款」</strong><br>
买家账号 <code>vtlvff1163@sandbox.com</code> · 支付密码 <code>111111</code>
</div>
</div>
<script>
let selected = null;
const listEl = document.getElementById('list');
const payBtn = document.getElementById('pay');
const msgEl = document.getElementById('msg');
function setMsg(t, err){ msgEl.textContent = t || ''; msgEl.classList.toggle('err', !!err); }
async function load(){
try{
const r = await fetch('/api/v1/products');
const j = await r.json();
const items = j.data || [];
if(!items.length){ listEl.innerHTML = '<div class="sub">暂无可购套餐</div>'; return; }
listEl.innerHTML = '';
items.forEach(p => {
const el = document.createElement('div');
el.className = 'item';
el.innerHTML = `<div><div class="name">${p.name}</div><div class="desc">${p.description||''}</div></div>
<div class="price">¥${p.price}<small> 元</small></div>`;
el.onclick = () => {
document.querySelectorAll('.item').forEach(x=>x.classList.remove('sel'));
el.classList.add('sel');
selected = p;
payBtn.disabled = false;
payBtn.textContent = `支付 ¥${p.price}`;
};
listEl.appendChild(el);
});
}catch(e){ setMsg('加载套餐失败:'+e, true); }
}
payBtn.onclick = async () => {
if(!selected) return;
payBtn.disabled = true; setMsg('正在创建订单…');
try{
const r = await fetch('/api/v1/orders', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({product_id: selected.id})
});
const j = await r.json();
if(!r.ok){ setMsg(j.message || '下单失败', true); payBtn.disabled=false; return; }
setMsg('正在跳转支付宝收银台…');
window.location.href = j.data.pay_url;
}catch(e){ setMsg('下单异常:'+e, true); payBtn.disabled=false; }
};
load();
</script>
</body>
</html>