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:
@@ -1,6 +1,6 @@
|
||||
# pay — 独立支付服务
|
||||
|
||||
多业务 × 多渠道的收款服务。**支付宝先行(PC 网页支付),微信留接口**(无沙箱,待真实商户号)。
|
||||
多业务 × 多渠道的收款服务。**支付宝(电脑网站支付)+ 微信(V3 Native 扫码)**(微信无沙箱,待真实商户号配置后可用)。
|
||||
栈:Go 1.26 · Gin · GORM(SQLite 纯 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 解密验签) |
|
||||
|
||||
## 沙箱联调步骤
|
||||
|
||||
|
||||
@@ -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_URL(SYSTEM 为大写业务名,如 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,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_KEY(APIv3 密钥)
|
||||
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
|
||||
|
||||
@@ -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 回传给 jiu,jiu 按<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>:全程 HTTPS(jiu.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/¥5999(2 档 × 月/年,见 §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>
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@ type CreateReq struct {
|
||||
Amount string // 元,两位小数
|
||||
ReturnURL string
|
||||
NotifyURL string
|
||||
IsMobile bool // 客户端是否手机浏览器:支付宝据此走手机网站支付(wap.pay 拉起 App) 而非电脑网站支付(page.pay 扫码)
|
||||
}
|
||||
|
||||
// NotifyResult 异步通知验签解析后的统一结果。
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 是否已成功回调(幂等/重试用)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user