From fd5f56f7de5356474b90e1790e4eb300ae9e37d9 Mon Sep 17 00:00:00 2001 From: wangjia Date: Fri, 3 Jul 2026 22:44:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1V3=20+=20=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=AE=9D=E6=89=8B=E6=9C=BA=E7=AB=99/UA=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=20+=20=E4=B8=9A=E5=8A=A1=E5=AF=B9=E6=8E=A5(?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E4=B8=8B=E5=8D=95/webhook)=20+=20=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=A4=9A=E4=B8=9A=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 微信支付 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__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 Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn --- README.md | 10 +- config/config.go | 51 +++++++ config/config.yaml | 25 ++++ docs/index.html | 6 + docs/pay接入jiu授权续费对接方案.html | 214 +++++++++++++++++++++++++++ go.mod | 1 + go.sum | 5 + internal/channel/alipay.go | 20 +++ internal/channel/channel.go | 1 + internal/channel/registry.go | 13 ++ internal/channel/wechat.go | 144 ++++++++++++++++-- internal/handler/order.go | 84 ++++++++++- internal/handler/page.go | 6 +- internal/model/biz_notify_log.go | 13 ++ internal/model/order.go | 5 + internal/model/product.go | 3 + internal/router/router.go | 2 +- internal/service/order.go | 177 +++++++++++++++++++++- internal/util/money.go | 5 + internal/util/sign.go | 22 +++ main.go | 85 +++++++++++ web/pay.html | 91 ------------ 22 files changed, 861 insertions(+), 122 deletions(-) create mode 100644 docs/pay接入jiu授权续费对接方案.html create mode 100644 internal/model/biz_notify_log.go create mode 100644 internal/util/sign.go delete mode 100644 web/pay.html diff --git a/README.md b/README.md index 8333d44..7c93022 100644 --- a/README.md +++ b/README.md @@ -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 解密验签) | ## 沙箱联调步骤 diff --git a/config/config.go b/config/config.go index bcbd716..baef165 100644 --- a/config/config.go +++ b/config/config.go @@ -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. 下声明即可接入,无需改代码。 + 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__SECRET / BIZ__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__SECRET / BIZ__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 + } } diff --git a/config/config.yaml b/config/config.yaml index e4cfa88..4e24272 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -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. 下加一段即可接入,无需改代码。 +# 契约见 pay-contract 仓(OpenAPI + README)。业务方带签名下单 + 入账后 pay 回调其 webhook。 +# 每个业务的 secret/callback 按约定走环境变量:BIZ__SECRET / BIZ__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 diff --git a/docs/index.html b/docs/index.html index b1a5476..0944120 100644 --- a/docs/index.html +++ b/docs/index.html @@ -40,6 +40,12 @@
v1.0 · 2026-06-24 · onboarding · 含备案依赖路线图
+ +
pay × jiu · 授权续费对接方案
+
让 jiu 门店在应用内购买/续费授权:付款走 pay 收款中枢,付成功后 pay 签名 webhook 回调 jiu、jiu 直接给该门店续期(方案 B,无感)。含总体架构与时序、pay/jiu 两侧数据模型改动(Order 加 biz 字段·Product 加 biz_code·Purchase·BizNotifyLog)、接口契约(下单扩展/webhook/购买/接收器)、HMAC 签名+防重放+幂等安全设计、套餐映射、失败重试对账、分阶段实施计划、开放问题清单。
+
v1.0 · 2026-07-03 · 方案 B(付款直接续期)· server-to-server + 签名 webhook · 待评审
+
+

📋 实现计划

见仓库根 README.md(运行与联调步骤)

diff --git a/docs/pay接入jiu授权续费对接方案.html b/docs/pay接入jiu授权续费对接方案.html new file mode 100644 index 0000000..50647fd --- /dev/null +++ b/docs/pay接入jiu授权续费对接方案.html @@ -0,0 +1,214 @@ + + + + + +pay × jiu · 授权续费对接方案 + + + +
+ ← 返回文档索引 +
Integration Design · pay × jiu
+

pay 接入 jiu 授权续费 · 对接方案

+

让 jiu(岩美酒库)门店在应用内购买/续费授权:付款走 pay 收款中枢,付成功后 pay 回调 jiu,jiu 直接给该门店续期。

+

v1.0 · 2026-07-03 · 方案 B(付款直接续期门店)· server-to-server + 签名 webhook · 待评审

+ +

1. 背景与目标

+
+

pay 已是独立收款中枢(支付宝电脑网站支付 page.pay + 手机网站支付 wap.pay UA 自适应,已上线验证到账)。jiu 已有完整授权体系(License / LicenseCode / 兑换续期)。目标:把两者接起来——jiu 门店在应用内选套餐付款,付成功后无感自动续期,不再靠线下发兑换码。

+
+ +

2. 现状盘点

+

pay 现有对外接口

+ + + + + +
方法/路径说明
POST /api/v1/orders下单,返回 pay_url(UA 自适应 PC 扫码 / 手机拉 App)。当前只收 product_id,无业务标识、无鉴权
GET /api/v1/orders/:out_trade_no查订单状态
POST /api/v1/notify/{alipay,wechat}支付宝/微信异步回调(入账,已实现)
+

jiu 现有授权模型

+ + + + +
模型关键字段语义
LicenseShopID · Type(trial/monthly/annual/lifetime) · Tier · ExpiresAt · MaxDevices · IsActive门店当前授权状态
LicenseCodeCode · DurationDays · Tier · Type · Status(unused/redeemed/void) · RedeemedShopID兑换券:兑换时把 DurationDays 叠加到门店到期时间;一码一次
+

现有续期路径:平台生成码 → 用户获得 → 门店兑换。「购买」这步目前是线下/手动——本方案就是把它自动化。

+ +

3. 总体架构(方案 B)

+
用户(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 ────────────────────────────►│ │ + ④'客户端刷新 → 已续期 ✓ │ │ │
+

为什么是 B:购买发生在已登录门店,jiu 天然知道是哪个 ShopID,付成功直接续期,用户无感,体验最好。

+ +

4. 数据模型改动

+

pay Order 加业务字段 新增

+ + + + + +
字段类型说明
BizSystemstring(index)业务来源,如 jiu;决定回调哪个 webhook / 用哪个密钥
BizRefstring业务引用,jiu 传(如 shop_id 或 jiu purchase_id);webhook 原样回传
BizReturnURLstring(可选)付完跳回业务自己的页面;空则用 pay 默认结果页
+

pay Product 加稳定套餐码 新增

+

BizCode(如 annual_standard):webhook 回传给 jiu,jiu 按稳定码而非数字 product_id 映射权益,避免硬编码耦合。价格权威仍在 pay 套餐表。

+

pay BizNotifyLog 业务回调日志 新增

+

记录每次向业务系统回调的 URL / payload / 响应 / 重试次数,用于排障与重放。

+

jiu Purchase 购买记录 新增

+ + + + + +
字段说明
ShopID · ProductBizCode · Amount谁买、买什么、多少钱
OutTradeNopay 订单号(对账 + 幂等键)
Status(pending/paid/failed) · PaidAt状态机
+ +

5. 接口契约

+ +

5.1 pay 下单接口扩展 POST /api/v1/orders

+

请求头带签名(见 §6)。Body 扩展:

+
{
+  "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))
+

pay 校验签名 → 按 product_id 取服务端权威价格建单、存 biz 字段 → 返回 pay_url

+ +

5.2 payjiu 业务 webhook(入账后主动推)

+

pay 在 applyPaid 成功后,按 order 的 biz_system 找到回调配置(URL+secret),POST:

+
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 按退避重试
+ +

5.3 jiu 购买接口 POST /api/v1/license/purchase

+

鉴权=jiu 自己的登录态(当前用户/门店)。选套餐 → 建 Purchase(pending) → 调 pay §5.1 下单 → 返回 pay_url 给客户端。

+ +

5.4 jiu webhook 接收器 POST /api/v1/pay/callback

+
1
验签:用共享密钥校验 X-Pay-Sign + 时间戳窗口,确认真是 pay 发来的
+
2
幂等:按 out_trade_no 查 Purchase,已处理则直接回 SUCCESS
+
3
映射product_biz_code → (duration_days, tier, type)
+
4
续期:给该 ShopID 延长授权(复用现有 license 服务:生成 LicenseCode 即兑换,或直接叠加 License.ExpiresAt
+
5
回 ack:HTTP 200 + {"code":"SUCCESS"};标记 Purchase=paid
+ +

6. 安全设计

+
+
    +
  • 共享密钥:jiu↔pay 各方向一对 HMAC 密钥,存 Bitwarden(如条目 pay-jiu webhook secret),部署经 rbw 灌环境变量,不落配置/仓库
  • +
  • 签名:HMAC-SHA256 覆盖 system+timestamp+nonce+body,防篡改。
  • +
  • 防重放:timestamp 5 分钟窗口 + nonce 去重(拒绝过期/重复请求)。
  • +
  • 幂等:两端都按 out_trade_no 唯一处理——pay 可能重发 webhook、jiu 只续一次。
  • +
  • 价格权威:pay 按 product_id 取服务端价格,不信任传入金额(延续既有红线)。
  • +
  • 传输:全程 HTTPS(jiu.51yanmei.com / pay.51yanmei.com 均已 HTTPS)。
  • +
+
+ +

7. 套餐映射

+

pay 套餐表持有价格(99/268/888/2280),每个套餐挂稳定 biz_code;jiu 侧一张映射表把 biz_code 翻成时长/档位:

+ + + + + + +
pay productbiz_code价格jiu 续期(直接叠加 ExpiresAt)
月付·标准monthly_standard¥299+30 天, tier=standard, type=monthly
月付·高级monthly_pro¥599+30 天, tier=pro, type=monthly
年付·标准annual_standard¥2999+365 天, tier=standard, type=annual
年付·高级annual_pro¥5999+365 天, tier=pro, type=annual
+

权益差异(2026-07-03 已定,都是单店):

+ + + + +
客户端仓库图片分享AI 商业分析
标准(299/2999)2单仓库千张
高级(599/5999)5多仓库万张免费 AI 周/月度商业数据分析
+

pay 侧只透传 biz_code,不关心权益细节;权益→License(tier/max_devices/features) 的落地在 jiu 侧,见 jiu 仓 docs/pay支付对接开发指南.html

+ +

8. 失败 / 重试 / 对账

+
    +
  • webhook 失败重试:pay 对未收到 SUCCESS ack 的回调按退避重试若干次(如 1m/5m/30m/2h…),记 BizNotifyLog。
  • +
  • 兜底查单:jiu 侧对 pending 超时的 Purchase 主动查 pay GET /orders/:out_trade_no 补偿(防 webhook 全丢)。
  • +
  • 对账:pay orders 与 jiu purchases 按 out_trade_no 对齐,定期核对金额/状态。
  • +
  • 用户已付但续期失败:webhook 重试 + 查单兜底应能自愈;仍失败则告警人工处理(钱已到,不可让用户吃亏)。
  • +
+ +

9. 分阶段实施计划

+
+

阶段 1 · pay 侧:Order 加 biz 字段 + Product 加 biz_code;下单接口收 biz + 签名鉴权;BizNotifyLog。

+

阶段 2 · pay 侧:入账后业务 webhook 推送 + 退避重试;回调配置(每个 biz_system 的 URL+secret)。

+

阶段 3 · jiu 侧:Purchase 模型 + 购买接口(调 pay 下单)。

+

阶段 4 · jiu 侧:webhook 接收器(验签+幂等+映射+续期,复用 license 服务)+ 查单兜底。

+

阶段 5:jiu 客户端购买/续费 UI;真实 1 分钱端到端联调;对账脚本。

+
+ +

10. 决策(2026-07-03 已定)

+
+ + + + + + + +
#问题决策
1套餐定义月 ¥299/¥599、年 ¥2999/¥5999(2 档 × 月/年,见 §7)。标准/高级权益差异待定
2续期实现直接叠加 License.ExpiresAt(不走生成兑换码)
3biz_refjiu 的 purchase_id(利对账/多态)
4付款后跳回 jiu 结果页(return_url)
5客户端形态Web 入口 + App 入口都要;App 用内置 webview 打开 pay_url。付完 return_url 跳回;webview 内拉起支付宝 App 需处理 scheme 唤起/回跳
+
+
+ 权益已定(见 §7 表):标准=2 客户端/单仓库/千图;高级=5 客户端/多仓库/万图/AI 分析。 +
App webview 注意:应用内 webview 里跳支付宝需 scheme 白名单 / 拦截 alipays:// 唤起 App,付完靠 return_url 回跳 jiu 页面(比 PC 多一层客户端处理,Phase 5 联调)。 +
+ +

相关:支付宝收款页时序与架构 · 上线状态与部署 Runbook

+
+ + diff --git a/go.mod b/go.mod index cb80116..cb9a790 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 1dcee84..3ce3e8f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/channel/alipay.go b/internal/channel/alipay.go index 7f3bdee..a8f1892 100644 --- a/internal/channel/alipay.go +++ b/internal/channel/alipay.go @@ -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) diff --git a/internal/channel/channel.go b/internal/channel/channel.go index a9256a6..4f915c6 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -17,6 +17,7 @@ type CreateReq struct { Amount string // 元,两位小数 ReturnURL string NotifyURL string + IsMobile bool // 客户端是否手机浏览器:支付宝据此走手机网站支付(wap.pay 拉起 App) 而非电脑网站支付(page.pay 扫码) } // NotifyResult 异步通知验签解析后的统一结果。 diff --git a/internal/channel/registry.go b/internal/channel/registry.go index 511a425..4eca329 100644 --- a/internal/channel/registry.go +++ b/internal/channel/registry.go @@ -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 { diff --git a/internal/channel/wechat.go b/internal/channel/wechat.go index 7057fd9..a5dcc20 100644 --- a/internal/channel/wechat.go +++ b/internal/channel/wechat.go @@ -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 } diff --git a/internal/handler/order.go b/internal/handler/order.go index babe130..ba6004a 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -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") diff --git a/internal/handler/page.go b/internal/handler/page.go index 47afd74..dd090cc 100644 --- a/internal/handler/page.go +++ b/internal/handler/page.go @@ -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) diff --git a/internal/model/biz_notify_log.go b/internal/model/biz_notify_log.go new file mode 100644 index 0000000..ae3a394 --- /dev/null +++ b/internal/model/biz_notify_log.go @@ -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"` +} diff --git a/internal/model/order.go b/internal/model/order.go index 88fff50..255ef7a 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -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 是否已成功回调(幂等/重试用) } diff --git a/internal/model/product.go b/internal/model/product.go index 44ac5cd..9d4c1dd 100644 --- a/internal/model/product.go +++ b/internal/model/product.go @@ -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"` } diff --git a/internal/router/router.go b/internal/router/router.go index 7011bda..c916172 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 diff --git a/internal/service/order.go b/internal/service/order.go index 037a82a..8b9163e 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -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() + } + }() +} diff --git a/internal/util/money.go b/internal/util/money.go index c1e8b16..5a5e643 100644 --- a/internal/util/money.go +++ b/internal/util/money.go @@ -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) diff --git a/internal/util/sign.go b/internal/util/sign.go new file mode 100644 index 0000000..0433fce --- /dev/null +++ b/internal/util/sign.go @@ -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)) +} diff --git a/main.go b/main.go index c0066b7..52462b0 100644 --- a/main.go +++ b/main.go @@ -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)) + } } diff --git a/web/pay.html b/web/pay.html deleted file mode 100644 index a0050c6..0000000 --- a/web/pay.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - -收款 - - - -
-

选择套餐

-
支付宝支付 · 沙箱联调
-
- -
-
- 跳转到支付宝收银台后,用右边「登录支付宝账户付款」
- 买家账号 vtlvff1163@sandbox.com · 支付密码 111111 -
-
- - -