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