feat: 微信V3 + 支付宝手机站/UA自适应 + 业务对接(签名下单/webhook) + 通用多业务

- 微信支付 V3 Native 渠道 (wechat.go):Native下单/回调AES-GCM解密验签/查单
- 支付宝:手机网站支付 wap.pay + 按 UA 自适应(PC page.pay扫码 / 手机拉App);qr_pay_mode=2 完整扫码收银台
- 业务对接:下单接口扩展(biz_system/biz_ref/return_url)+ HMAC 签名鉴权;支付成功 webhook 主动推送业务方 + 60s 重试 + BizNotifyLog
- 通用多业务:config.biz 改 map,加业务只改配置(BIZ_<SYS>_SECRET/_CALLBACK_URL)
- seedPlans:四档真实套餐 + promo_first_month(¥1) + test_liandiao(0.01),均挂 biz_code;/products 暴露 biz_code
- 删除沙箱 pay.html;对接设计文档入 docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
This commit is contained in:
2026-07-03 22:44:34 +08:00
parent 6da6964451
commit fd5f56f7de
22 changed files with 861 additions and 122 deletions
+20
View File
@@ -32,6 +32,23 @@ func newAlipay(m *model.Merchant) (Channel, error) {
func (a *alipayChannel) Name() string { return "alipay" }
func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) {
// 手机浏览器:手机网站支付 wap.pay,H5 收银台可直接拉起支付宝 App 付款。
if req.IsMobile {
var p = alipay.TradeWapPay{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
p.TotalAmount = req.Amount
p.ProductCode = "QUICK_WAP_WAY"
p.NotifyURL = req.NotifyURL
p.ReturnURL = req.ReturnURL
u, err := a.client.TradeWapPay(p)
if err != nil {
return "", fmt.Errorf("支付宝手机网站下单失败: %w", err)
}
return u.String(), nil
}
// PC 浏览器:电脑网站支付 page.pay。
var p = alipay.TradePagePay{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
@@ -39,6 +56,9 @@ func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
p.NotifyURL = req.NotifyURL
p.ReturnURL = req.ReturnURL
// qr_pay_mode=2 跳转模式:跳到支付宝完整扫码收银台(含收款方/金额/订单详情),
// 而非默认「登录付款」页;区别于 4(光秃秃的嵌入式二维码)。
p.QRPayMode = "2"
u, err := a.client.TradePagePay(p)
if err != nil {
return "", fmt.Errorf("支付宝下单失败: %w", err)
+1
View File
@@ -17,6 +17,7 @@ type CreateReq struct {
Amount string // 元,两位小数
ReturnURL string
NotifyURL string
IsMobile bool // 客户端是否手机浏览器:支付宝据此走手机网站支付(wap.pay 拉起 App) 而非电脑网站支付(page.pay 扫码)
}
// NotifyResult 异步通知验签解析后的统一结果。
+13
View File
@@ -43,6 +43,19 @@ func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error)
return ch, &m, err
}
// FirstWechat 取一个启用中的微信商户及其渠道,用于回调解密验签。
// 微信 V3 回调正文加密,需先用商户凭证(平台证书 + APIv3 密钥)解密才能拿到 out_trade_no
// 同一公司主体共用一个 mch_id/apiv3_key/证书,故取任一启用的微信商户即可解密,
// 解密后再按 out_trade_no 定位到订单真正归属的商户入账。
func (r *Registry) FirstWechat() (Channel, *model.Merchant, error) {
var m model.Merchant
if err := r.db.First(&m, "channel = ? AND enabled = ?", "wechat", true).Error; err != nil {
return nil, nil, fmt.Errorf("没有启用的微信商户: %w", err)
}
ch, err := r.get(&m)
return ch, &m, err
}
func (r *Registry) get(m *model.Merchant) (Channel, error) {
r.mu.RLock()
if ch, ok := r.cache[m.ID]; ok {
+132 -12
View File
@@ -3,30 +3,150 @@ package channel
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
)
// ErrNotImplemented 渠道尚未实现。
var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现")
// wechatChannel 微信支付 V3 · Native(扫码)实现。
// PC 网站场景微信只提供 Native 扫码(无支付宝那种网页跳转收银台),所以:
// - PreCreate → Native 下单,返回 code_url(前端经 /qrcode 渲染成二维码)
// - PagePay → 不适用,返回错误引导改用扫码
// - VerifyNotify / Query → 回调解密验签 / 主动查单
type wechatChannel struct {
mchID string
appID string
client *core.Client
native *native.NativeApiService
handler *notify.Handler
}
// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。
type wechatChannel struct{ m *model.Merchant }
func newWechat(m *model.Merchant) (Channel, error) {
if m.MchID == "" || m.WxAppID == "" || m.WxPrivateKey == "" || m.CertSerial == "" || m.APIv3Key == "" {
return nil, fmt.Errorf("商户 %s 的微信凭证不完整(mch_id/wx_app_id/cert_serial/wx_private_key/apiv3_key", m.Code)
}
func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil }
mchPrivateKey, err := utils.LoadPrivateKey(m.WxPrivateKey)
if err != nil {
return nil, fmt.Errorf("加载微信商户私钥失败: %w", err)
}
// 初始化时自动下载并周期性更新微信支付平台证书(需公网可达微信 API)。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(
m.MchID, m.CertSerial, mchPrivateKey, m.APIv3Key,
))
if err != nil {
return nil, fmt.Errorf("初始化微信支付客户端失败: %w", err)
}
// 回调处理器:用平台证书验签 + APIv3 密钥解密。
certVisitor := downloader.MgrInstance().GetCertificateVisitor(m.MchID)
handler, err := notify.NewRSANotifyHandler(m.APIv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor))
if err != nil {
return nil, fmt.Errorf("初始化微信回调处理器失败: %w", err)
}
return &wechatChannel{
mchID: m.MchID,
appID: m.WxAppID,
client: client,
native: &native.NativeApiService{Client: client},
handler: handler,
}, nil
}
func (w *wechatChannel) Name() string { return "wechat" }
// PagePay 微信无网页跳转收银台(PC 网站走 Native 扫码),改用 PreCreate。
func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
return "", errors.New("微信支付无网页跳转收银台,请用扫码下单(CreateQR / PreCreate")
}
func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
// PreCreate Native 下单,返回 code_url 供渲染二维码。
func (w *wechatChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) {
cents, err := util.AmountToCents(req.Amount)
if err != nil {
return "", fmt.Errorf("金额换算失败: %w", err)
}
resp, _, err := w.native.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.appID),
Mchid: core.String(w.mchID),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OutTradeNo),
NotifyUrl: core.String(req.NotifyURL),
Amount: &native.Amount{
Total: core.Int64(cents),
Currency: core.String("CNY"),
},
})
if err != nil {
return "", fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", errors.New("微信 Native 下单未返回 code_url")
}
return *resp.CodeUrl, nil
}
func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) {
return nil, ErrNotImplemented
// VerifyNotify 解密验签微信异步回调,解析成统一结果。
func (w *wechatChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
tx := new(payments.Transaction)
if _, err := w.handler.ParseNotifyRequest(ctx, r, tx); err != nil {
return nil, fmt.Errorf("微信回调验签/解密失败: %w", err)
}
return txToNotifyResult(tx), nil
}
func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) {
return nil, ErrNotImplemented
// Query 主动查单(out_trade_no 维度)。
func (w *wechatChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) {
tx, _, err := w.native.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(outTradeNo),
Mchid: core.String(w.mchID),
})
if err != nil {
// 交易不存在等:视为未找到,交由上层决定(与支付宝实现一致)。
return &QueryResult{Found: false}, nil
}
res := txToNotifyResult(tx)
return &QueryResult{
Found: true,
OutTradeNo: res.OutTradeNo,
TradeNo: res.TradeNo,
Amount: res.Amount,
Paid: res.Paid,
}, nil
}
// txToNotifyResult 把微信交易对象归一化成本服务的统一结构(金额分→元字符串)。
func txToNotifyResult(tx *payments.Transaction) *NotifyResult {
res := &NotifyResult{}
if tx.OutTradeNo != nil {
res.OutTradeNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
res.TradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
res.Amount = util.CentsToAmount(*tx.Amount.Total)
}
if tx.Payer != nil && tx.Payer.Openid != nil {
res.BuyerLogonID = *tx.Payer.Openid
}
// 交易状态:SUCCESS 视为已支付(其余 NOTPAY/CLOSED/REFUND 等均非成功)。
res.Paid = tx.TradeState != nil && *tx.TradeState == "SUCCESS"
return res
}
+81 -3
View File
@@ -1,15 +1,33 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/service"
"github.com/wangjia/pay/internal/util"
)
// isMobileUA 粗略判断是否手机浏览器(决定支付宝走 wap.pay 拉起 App 还是 page.pay 扫码)。
func isMobileUA(ua string) bool {
ua = strings.ToLower(ua)
for _, kw := range []string{"android", "iphone", "ipod", "mobile", "harmony", "windows phone"} {
if strings.Contains(ua, kw) {
return true
}
}
return false
}
type OrderHandler struct {
svc *service.OrderService
}
@@ -19,17 +37,67 @@ func NewOrderHandler(svc *service.OrderService) *OrderHandler {
}
type createOrderRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
ProductID uint64 `json:"product_id"`
BizSystem string `json:"biz_system,omitempty"` // 业务对接来源(如 jiu);带则需签名鉴权
BizRef string `json:"biz_ref,omitempty"` // 业务引用(jiu purchase_id
ReturnURL string `json:"return_url,omitempty"` // 自定义付款后跳回地址
}
// verifyBizSign 校验业务方下单请求的 HMAC 签名(防外部乱下单/伪造业务单)。
func verifyBizSign(c *gin.Context, system string, rawBody []byte) error {
cfg, ok := config.C.BizByName(system)
if !ok {
return fmt.Errorf("未知或未配置的业务系统: %s", system)
}
if c.GetHeader("X-Pay-System") != system {
return errors.New("X-Pay-System 与 biz_system 不一致")
}
ts := c.GetHeader("X-Pay-Timestamp")
nonce := c.GetHeader("X-Pay-Nonce")
sign := c.GetHeader("X-Pay-Sign")
if ts == "" || nonce == "" || sign == "" {
return errors.New("缺少签名头 X-Pay-Timestamp/Nonce/Sign")
}
tsi, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("时间戳格式错误")
}
if d := time.Now().Unix() - tsi; d > 300 || d < -300 {
return errors.New("请求已过期(时间戳超 5 分钟窗口)")
}
if !util.HMACVerify(cfg.Secret, sign, system, ts, nonce, string(rawBody)) {
return errors.New("签名校验失败")
}
return nil
}
// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。
// 独立收款(浏览器 /paytest):只传 product_id,无需签名。
// 业务对接(jiu 后端):带 biz_system/biz_ref + HMAC 签名头,服务端校验后受理。
func (h *OrderHandler) Create(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "读取请求失败")
return
}
var req createOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
return
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP())
if req.ProductID == 0 {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 product_id")
return
}
if req.BizSystem != "" {
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP(),
isMobileUA(c.Request.UserAgent()),
service.BizParams{System: req.BizSystem, Ref: req.BizRef, ReturnURL: req.ReturnURL})
if err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
@@ -80,6 +148,16 @@ func (h *OrderHandler) AlipayNotify(c *gin.Context) {
c.String(http.StatusOK, "success")
}
// WechatNotify POST /api/v1/notify/wechat —— 微信异步回调。
// 成功回 200 + {"code":"SUCCESS"};失败回非 200 + {"code":"FAIL"} 让微信按策略重发。
func (h *OrderHandler) WechatNotify(c *gin.Context) {
if err := h.svc.HandleWechatNotify(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "OK"})
}
// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。
func (h *OrderHandler) GetStatus(c *gin.Context) {
out := c.Param("out_trade_no")
+1 -5
View File
@@ -20,11 +20,6 @@ func NewPageHandler(db *gorm.DB, webDir string) *PageHandler {
return &PageHandler{db: db, webDir: webDir}
}
// PayPage GET / —— 收款页。
func (h *PageHandler) PayPage(c *gin.Context) {
c.File(h.webDir + "/pay.html")
}
// ResultPage GET /result —— 支付结果页(return_url 落地)。
func (h *PageHandler) ResultPage(c *gin.Context) {
c.File(h.webDir + "/result.html")
@@ -59,6 +54,7 @@ func (h *PageHandler) ListProducts(c *gin.Context) {
"name": p.Name,
"description": p.Description,
"price": p.Price,
"biz_code": p.BizCode, // 业务方按 biz_code 查 product_id
})
}
util.RespondSuccess(c, out)
+13
View File
@@ -0,0 +1,13 @@
package model
// BizNotifyLog 业务系统回调(pay → 业务方 webhook)日志,供排障 / 重放追踪。
type BizNotifyLog struct {
Base
OutTradeNo string `gorm:"index;size:64" json:"out_trade_no"`
BizSystem string `gorm:"size:32" json:"biz_system"`
URL string `gorm:"size:255" json:"url"`
Payload string `gorm:"type:text" json:"payload"`
RespCode int `json:"resp_code"`
RespBody string `gorm:"type:text" json:"resp_body"`
OK bool `json:"ok"`
}
+5
View File
@@ -25,4 +25,9 @@ type Order struct {
BuyerLogonID string `gorm:"size:128" json:"buyer_logon_id"`
PaidAt *time.Time `json:"paid_at"`
ClientIP string `gorm:"size:64" json:"client_ip"`
// —— 业务对接(如 jiu 授权续费)——
BizSystem string `gorm:"index;size:32" json:"biz_system,omitempty"` // 业务来源,如 jiu;决定入账后回调哪个 webhook。空=独立收款(/paytest 等)
BizRef string `gorm:"size:128" json:"biz_ref,omitempty"` // 业务引用(jiu 的 purchase_id),webhook 原样回传
BizNotified bool `gorm:"default:false" json:"biz_notified"` // 业务 webhook 是否已成功回调(幂等/重试用)
}
+3
View File
@@ -10,4 +10,7 @@ type Product struct {
Price string `gorm:"size:20;not null" json:"price"` // 元,两位小数
Active bool `gorm:"default:true" json:"active"`
Sort int `json:"sort"`
// BizCode 稳定套餐码(如 annual_standard),业务 webhook 回传,供业务方按码映射权益(时长/档位),
// 避免业务方硬编码数字 product_id。空=无业务语义(纯测试套餐)。
BizCode string `gorm:"size:64;index" json:"biz_code,omitempty"`
}
+1 -1
View File
@@ -19,7 +19,6 @@ func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderServ
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
// 页面
r.GET("/", pageH.PayPage)
r.GET("/result", pageH.ResultPage)
r.GET("/qrcode", pageH.QRCode)
@@ -30,6 +29,7 @@ func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderServ
v1.POST("/orders/qr", orderH.CreateQR)
v1.GET("/orders/:out_trade_no", orderH.GetStatus)
v1.POST("/notify/alipay", orderH.AlipayNotify)
v1.POST("/notify/wechat", orderH.WechatNotify)
}
return orderSvc
+171 -6
View File
@@ -1,15 +1,22 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/channel"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
@@ -30,8 +37,15 @@ func NewOrderService(db *gorm.DB, reg *channel.Registry, baseURL string) *OrderS
return &OrderService{db: db, reg: reg, baseURL: baseURL}
}
// BizParams 业务对接下单参数(独立收款场景全空)。
type BizParams struct {
System string // 业务来源,如 jiu
Ref string // 业务引用,如 jiu 的 purchase_id
ReturnURL string // 自定义付款后跳回地址(空则用 pay 默认结果页)
}
// prepare 校验套餐、取渠道、落库一张待支付订单(金额一律取服务端套餐价,不信任前端)。
func (s *OrderService) prepare(productID uint64, clientIP string) (channel.Channel, *model.Merchant, *model.Order, error) {
func (s *OrderService) prepare(productID uint64, clientIP string, biz BizParams) (channel.Channel, *model.Merchant, *model.Order, error) {
var p model.Product
if err := s.db.First(&p, "id = ? AND active = ?", productID, true).Error; err != nil {
return nil, nil, nil, ErrProductNotFound
@@ -49,6 +63,8 @@ func (s *OrderService) prepare(productID uint64, clientIP string) (channel.Chann
Amount: p.Price, // 权威金额
Status: model.OrderPending,
ClientIP: clientIP,
BizSystem: biz.System,
BizRef: biz.Ref,
}
if err := s.db.Create(order).Error; err != nil {
return nil, nil, nil, fmt.Errorf("创建订单失败: %w", err)
@@ -60,9 +76,21 @@ func (s *OrderService) notifyURL(channel string) string {
return s.baseURL + "/api/v1/notify/" + channel
}
// Create 网页支付下单,返回收银台跳转 URL
func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP)
// returnURL 付款后同步跳转地址:业务方传了自定义地址就用它(拼上 out_trade_no),否则用 pay 默认结果页
func (s *OrderService) returnURL(custom, outTradeNo string) string {
if custom == "" {
return s.baseURL + "/result?out_trade_no=" + outTradeNo
}
sep := "?"
if strings.Contains(custom, "?") {
sep = "&"
}
return custom + sep + "out_trade_no=" + outTradeNo
}
// Create 网页支付下单,返回收银台跳转 URL。isMobile=true 时支付宝走手机网站支付(拉起 App)。
func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP string, isMobile bool, biz BizParams) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP, biz)
if err != nil {
return "", nil, err
}
@@ -71,7 +99,8 @@ func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP st
Subject: order.Subject,
Amount: order.Amount,
NotifyURL: s.notifyURL(m.Channel),
ReturnURL: s.baseURL + "/result?out_trade_no=" + order.OutTradeNo,
ReturnURL: s.returnURL(biz.ReturnURL, order.OutTradeNo),
IsMobile: isMobile,
})
if err != nil {
return "", nil, err
@@ -81,7 +110,7 @@ func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP st
// CreateQR 扫码(当面付)下单,返回二维码码串供前端渲染。
func (s *OrderService) CreateQR(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP)
ch, m, order, err := s.prepare(productID, clientIP, BizParams{})
if err != nil {
return "", nil, err
}
@@ -123,6 +152,38 @@ func (s *OrderService) HandleAlipayNotify(ctx context.Context, r *http.Request)
return err
}
// HandleWechatNotify 处理微信异步回调:解密验签 → 按 out_trade_no 定位订单/商户 → 核对金额 → 幂等更新。
// 微信回调正文加密且不含明文商户路由信息,故先用任一启用的微信商户凭证解密(同主体共用),
// 再按解密出的 out_trade_no 找到订单实际归属的商户入账。返回 nil 表示已正确处理。
func (s *OrderService) HandleWechatNotify(ctx context.Context, r *http.Request) error {
ch, _, err := s.reg.FirstWechat()
if err != nil {
s.logNotify("wechat", "", false, "no_merchant", "")
return err
}
res, err := ch.VerifyNotify(ctx, r)
if err != nil {
s.logNotify("wechat", "", false, "verify_failed", "")
return err
}
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", res.OutTradeNo).Error; err != nil {
s.logNotify("wechat", res.OutTradeNo, true, "not_found", res.Raw)
return fmt.Errorf("订单不存在: %s", res.OutTradeNo)
}
var m model.Merchant
if err := s.db.First(&m, o.MerchantID).Error; err != nil {
s.logNotify("wechat", res.OutTradeNo, true, "merchant_not_found", res.Raw)
return fmt.Errorf("订单 %s 的商户不存在: %w", res.OutTradeNo, err)
}
result, err := s.applyPaid(&m, res)
s.logNotify("wechat", res.OutTradeNo, true, result, res.Raw)
return err
}
// applyPaid 在一个事务里完成「金额核对 + 幂等置为已支付」。返回处理结果标记。
func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (string, error) {
if !res.Paid {
@@ -164,6 +225,10 @@ func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (
log.Printf("[notify] 订单 %s 已支付 trade_no=%s amount=%s", o.OutTradeNo, res.TradeNo, res.Amount)
return nil
})
// 新入账成功且属业务对接单:异步回调业务系统 webhook(失败由后台重试兜底,不阻塞支付回调响应)。
if err == nil && resultTag == "processed" {
go s.notifyBizByOutTradeNo(res.OutTradeNo)
}
return resultTag, err
}
@@ -227,3 +292,103 @@ func (s *OrderService) StartQuerySync(interval, maxAge time.Duration) {
}
}()
}
// notifyBizByOutTradeNo 向业务系统推送「支付成功」webhook(签名)。成功则置 BizNotified,失败留待重试。
func (s *OrderService) notifyBizByOutTradeNo(outTradeNo string) {
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", outTradeNo).Error; err != nil {
return
}
if o.BizSystem == "" || o.BizNotified || o.Status != model.OrderPaid {
return
}
cfg, ok := config.C.BizByName(o.BizSystem)
if !ok {
log.Printf("[biz_notify] 订单 %s 业务系统 %s 未配置回调,跳过", o.OutTradeNo, o.BizSystem)
return
}
var p model.Product
_ = s.db.First(&p, o.ProductID).Error // 取 biz_code;失败则为空
paidAt := ""
if o.PaidAt != nil {
paidAt = o.PaidAt.Format(time.RFC3339)
}
body, _ := json.Marshal(map[string]any{
"out_trade_no": o.OutTradeNo,
"biz_system": o.BizSystem,
"biz_ref": o.BizRef,
"product_biz_code": p.BizCode,
"amount": o.Amount,
"trade_no": o.TradeNo,
"channel": o.Channel,
"paid_at": paidAt,
})
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sign := util.HMACSign(cfg.Secret, o.BizSystem, ts, nonce, string(body))
req, err := http.NewRequest(http.MethodPost, cfg.CallbackURL, bytes.NewReader(body))
if err != nil {
log.Printf("[biz_notify] 订单 %s 构造回调请求失败: %v", o.OutTradeNo, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pay-System", o.BizSystem)
req.Header.Set("X-Pay-Timestamp", ts)
req.Header.Set("X-Pay-Nonce", nonce)
req.Header.Set("X-Pay-Sign", sign)
entry := &model.BizNotifyLog{OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, URL: cfg.CallbackURL, Payload: string(body)}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
success := false
if err != nil {
entry.RespBody = err.Error()
} else {
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
entry.RespCode = resp.StatusCode
entry.RespBody = string(rb)
// 约定:业务方返回 HTTP 200 且响应含 SUCCESS 视为受理成功。
if resp.StatusCode == http.StatusOK && strings.Contains(strings.ToUpper(string(rb)), "SUCCESS") {
success = true
}
}
entry.OK = success
_ = s.db.Create(entry).Error
if success {
s.db.Model(&model.Order{}).Where("out_trade_no = ?", o.OutTradeNo).Update("biz_notified", true)
log.Printf("[biz_notify] 订单 %s 已成功回调 %s", o.OutTradeNo, o.BizSystem)
} else {
log.Printf("[biz_notify] 订单 %s 回调 %s 失败(code=%d),等待重试", o.OutTradeNo, o.BizSystem, entry.RespCode)
}
}
// NotifyBizPending 兜底重试:把已支付但未成功回调业务方的订单再推一次。
func (s *OrderService) NotifyBizPending() {
var orders []model.Order
cutoff := time.Now().Add(-24 * time.Hour)
if err := s.db.Where("status = ? AND biz_system <> '' AND biz_notified = ? AND created_at > ?",
model.OrderPaid, false, cutoff).Limit(50).Find(&orders).Error; err != nil {
log.Printf("[biz_notify] 查询待回调订单失败: %v", err)
return
}
for i := range orders {
s.notifyBizByOutTradeNo(orders[i].OutTradeNo)
}
}
// StartBizNotifyRetry 启动业务回调重试循环(兜底 webhook 丢失/业务方短暂不可用)。
func (s *OrderService) StartBizNotifyRetry(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
s.NotifyBizPending()
}
}()
}
+5
View File
@@ -23,6 +23,11 @@ func AmountToCents(s string) (int64, error) {
return int64(math.Round(f * 100)), nil
}
// CentsToAmount 把分(int64)转回 "0.01" 元字符串(微信接口用分,本服务统一用元字符串)。
func CentsToAmount(cents int64) string {
return strconv.FormatFloat(float64(cents)/100, 'f', 2, 64)
}
// AmountEqual 判断两个元金额字符串是否等值(按分比较,避免浮点/格式差异)。
func AmountEqual(a, b string) bool {
ca, err1 := AmountToCents(a)
+22
View File
@@ -0,0 +1,22 @@
package util
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
// HMACSign 用共享密钥对若干片段做 HMAC-SHA256 签名(片段以 \n 连接),返回 base64。
// 业务对接双向共用:pay 校验业务方下单请求签名 + 给回调 payload 签名。
func HMACSign(secret string, parts ...string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strings.Join(parts, "\n")))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// HMACVerify 常量时间比对签名,防时序侧信道。
func HMACVerify(secret, sig string, parts ...string) bool {
expected := HMACSign(secret, parts...)
return hmac.Equal([]byte(expected), []byte(sig))
}