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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user