初始提交:岩美 pay 收款服务(支付宝当面付 + 多商户多渠道架构)

- Go/Gin/GORM + 纯 Go SQLite(无 cgo)
- Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位
- 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底
- 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook
- 密钥走环境变量/Bitwarden,不入库

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-25 07:21:48 +08:00
commit 6da6964451
29 changed files with 2205 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
package channel
import (
"context"
"fmt"
"net/http"
"github.com/smartwalle/alipay/v3"
"github.com/wangjia/pay/internal/model"
)
type alipayChannel struct {
client *alipay.Client
}
func newAlipay(m *model.Merchant) (Channel, error) {
if m.AppID == "" || m.AppPrivateKey == "" || m.AlipayPublicKey == "" {
return nil, fmt.Errorf("商户 %s 的支付宝凭证不完整(app_id/app_private_key/alipay_public_key", m.Code)
}
client, err := alipay.New(m.AppID, m.AppPrivateKey, m.Production)
if err != nil {
return nil, fmt.Errorf("初始化支付宝客户端失败: %w", err)
}
// 公钥模式(沙箱常用)。若改用证书模式,这里换成 LoadAppCertPublicKey 等。
if err := client.LoadAliPayPublicKey(m.AlipayPublicKey); err != nil {
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
}
return &alipayChannel{client: client}, nil
}
func (a *alipayChannel) Name() string { return "alipay" }
func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) {
var p = alipay.TradePagePay{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
p.TotalAmount = req.Amount
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
p.NotifyURL = req.NotifyURL
p.ReturnURL = req.ReturnURL
u, err := a.client.TradePagePay(p)
if err != nil {
return "", fmt.Errorf("支付宝下单失败: %w", err)
}
return u.String(), nil
}
func (a *alipayChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) {
var p = alipay.TradePreCreate{}
p.OutTradeNo = req.OutTradeNo
p.Subject = req.Subject
p.TotalAmount = req.Amount
p.NotifyURL = req.NotifyURL
rsp, err := a.client.TradePreCreate(ctx, p)
if err != nil {
return "", fmt.Errorf("支付宝预下单调用失败: %w", err)
}
if rsp.IsFailure() {
return "", fmt.Errorf("支付宝预下单失败: %s / %s", rsp.Msg, rsp.SubMsg)
}
return rsp.QRCode, nil
}
func (a *alipayChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("解析回调表单失败: %w", err)
}
// DecodeNotification 内部用已加载的支付宝公钥验签,验签不过会返回错误。
noti, err := a.client.DecodeNotification(ctx, r.Form)
if err != nil {
return nil, fmt.Errorf("回调验签失败: %w", err)
}
paid := noti.TradeStatus == alipay.TradeStatusSuccess || noti.TradeStatus == alipay.TradeStatusFinished
return &NotifyResult{
OutTradeNo: noti.OutTradeNo,
TradeNo: noti.TradeNo,
Amount: noti.TotalAmount,
BuyerLogonID: noti.BuyerLogonId,
Paid: paid,
Raw: r.Form.Encode(),
}, nil
}
func (a *alipayChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) {
var p = alipay.TradeQuery{OutTradeNo: outTradeNo}
rsp, err := a.client.TradeQuery(ctx, p)
if err != nil {
return nil, fmt.Errorf("支付宝查单调用失败: %w", err)
}
if rsp.IsFailure() {
// 交易不存在(TRADE_NOT_EXIST)等:视为未找到,不报错,交由上层决定。
return &QueryResult{Found: false}, nil
}
paid := rsp.TradeStatus == alipay.TradeStatusSuccess || rsp.TradeStatus == alipay.TradeStatusFinished
return &QueryResult{
Found: true,
OutTradeNo: rsp.OutTradeNo,
TradeNo: rsp.TradeNo,
Amount: rsp.TotalAmount,
Paid: paid,
}, nil
}
+64
View File
@@ -0,0 +1,64 @@
// Package channel 把不同支付渠道(支付宝/微信…)收口到统一接口。
// 上层业务只面向 Channel,新增渠道 = 加一个实现 + 一行注册,主流程不动。
package channel
import (
"context"
"fmt"
"net/http"
"github.com/wangjia/pay/internal/model"
)
// CreateReq 统一下单参数。
type CreateReq struct {
OutTradeNo string
Subject string
Amount string // 元,两位小数
ReturnURL string
NotifyURL string
}
// NotifyResult 异步通知验签解析后的统一结果。
type NotifyResult struct {
OutTradeNo string
TradeNo string
Amount string
BuyerLogonID string
Paid bool // 是否为支付成功状态
Raw string
}
// QueryResult 主动查单的统一结果。
type QueryResult struct {
Found bool
OutTradeNo string
TradeNo string
Amount string
Paid bool
}
// Channel 支付渠道统一接口。
type Channel interface {
Name() string
// PagePay 统一下单,返回收银台跳转 URL。
PagePay(ctx context.Context, req CreateReq) (payURL string, err error)
// PreCreate 扫码(当面付)预下单,返回二维码码串,由前端渲染成二维码供客户扫码付款。
PreCreate(ctx context.Context, req CreateReq) (qrCode string, err error)
// VerifyNotify 验签并解析异步通知(内部完成 ParseForm 与签名校验)。
VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error)
// Query 主动查单。
Query(ctx context.Context, outTradeNo string) (*QueryResult, error)
}
// Build 按商户配置构造渠道实例。
func Build(m *model.Merchant) (Channel, error) {
switch m.Channel {
case "alipay":
return newAlipay(m)
case "wechat":
return newWechat(m)
default:
return nil, fmt.Errorf("未知支付渠道: %q", m.Channel)
}
}
+69
View File
@@ -0,0 +1,69 @@
package channel
import (
"fmt"
"sync"
"gorm.io/gorm"
"github.com/wangjia/pay/internal/model"
)
// Registry 按商户缓存已构造的渠道实例,避免每次下单都重新加载密钥。
type Registry struct {
db *gorm.DB
mu sync.RWMutex
cache map[uint64]Channel
}
func NewRegistry(db *gorm.DB) *Registry {
return &Registry{db: db, cache: make(map[uint64]Channel)}
}
// ByMerchantID 取(或构造)指定商户的渠道。
func (r *Registry) ByMerchantID(id uint64) (Channel, *model.Merchant, error) {
var m model.Merchant
if err := r.db.First(&m, "id = ? AND enabled = ?", id, true).Error; err != nil {
return nil, nil, fmt.Errorf("商户不存在或已停用: %w", err)
}
ch, err := r.get(&m)
return ch, &m, err
}
// AlipayByAppID 异步回调时用 app_id 反查商户并取其渠道(用于验签)。
func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error) {
if appID == "" {
return nil, nil, fmt.Errorf("回调缺少 app_id")
}
var m model.Merchant
if err := r.db.First(&m, "channel = ? AND app_id = ? AND enabled = ?", "alipay", appID, true).Error; err != nil {
return nil, nil, fmt.Errorf("找不到 app_id=%s 对应的支付宝商户: %w", appID, 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 {
r.mu.RUnlock()
return ch, nil
}
r.mu.RUnlock()
ch, err := Build(m)
if err != nil {
return nil, err
}
r.mu.Lock()
r.cache[m.ID] = ch
r.mu.Unlock()
return ch, nil
}
// Invalidate 商户凭证变更后清缓存(预留给管理接口)。
func (r *Registry) Invalidate(id uint64) {
r.mu.Lock()
delete(r.cache, id)
r.mu.Unlock()
}
+32
View File
@@ -0,0 +1,32 @@
package channel
import (
"context"
"errors"
"net/http"
"github.com/wangjia/pay/internal/model"
)
// ErrNotImplemented 渠道尚未实现。
var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现")
// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。
type wechatChannel struct{ m *model.Merchant }
func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil }
func (w *wechatChannel) Name() string { return "wechat" }
func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
}
func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) {
return "", ErrNotImplemented
}
func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) {
return nil, ErrNotImplemented
}
func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) {
return nil, ErrNotImplemented
}
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/internal/service"
"github.com/wangjia/pay/internal/util"
)
type OrderHandler struct {
svc *service.OrderService
}
func NewOrderHandler(svc *service.OrderService) *OrderHandler {
return &OrderHandler{svc: svc}
}
type createOrderRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
}
// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。
func (h *OrderHandler) Create(c *gin.Context) {
var req createOrderRequest
if err := c.ShouldBindJSON(&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 err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
return
}
util.RespondSuccess(c, gin.H{
"pay_url": payURL,
"out_trade_no": order.OutTradeNo,
"amount": order.Amount,
"subject": order.Subject,
})
}
// CreateQR POST /api/v1/orders/qr —— 扫码下单,返回二维码码串(前端渲染成二维码)。
func (h *OrderHandler) CreateQR(c *gin.Context) {
var req createOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
return
}
qr, order, err := h.svc.CreateQR(c.Request.Context(), req.ProductID, c.ClientIP())
if err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
return
}
util.RespondSuccess(c, gin.H{
"qr_code": qr,
"out_trade_no": order.OutTradeNo,
"amount": order.Amount,
"subject": order.Subject,
})
}
// AlipayNotify POST /api/v1/notify/alipay —— 异步回调。
// 成功必须返回纯文本 "success",否则支付宝会按策略重发。
func (h *OrderHandler) AlipayNotify(c *gin.Context) {
if err := h.svc.HandleAlipayNotify(c.Request.Context(), c.Request); err != nil {
c.String(http.StatusOK, "failure") // 回 failure 让支付宝重试(也可记录后人工处理)
return
}
c.String(http.StatusOK, "success")
}
// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。
func (h *OrderHandler) GetStatus(c *gin.Context) {
out := c.Param("out_trade_no")
o, err := h.svc.GetByOutTradeNo(out)
if err != nil {
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
return
}
util.RespondSuccess(c, gin.H{
"out_trade_no": o.OutTradeNo,
"subject": o.Subject,
"amount": o.Amount,
"status": o.Status,
"trade_no": o.TradeNo,
"paid_at": o.PaidAt,
})
}
+65
View File
@@ -0,0 +1,65 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"gorm.io/gorm"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
)
type PageHandler struct {
db *gorm.DB
webDir string
}
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")
}
// QRCode GET /qrcode?text=xxx —— 把任意字符串渲染成二维码 PNG(用于展示支付宝扫码码串)。
func (h *PageHandler) QRCode(c *gin.Context) {
text := c.Query("text")
if text == "" {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 text 参数")
return
}
png, err := qrcode.Encode(text, qrcode.Medium, 256)
if err != nil {
util.RespondError(c, http.StatusInternalServerError, "qr_failed", err.Error())
return
}
c.Data(http.StatusOK, "image/png", png)
}
// ListProducts GET /api/v1/products —— 上架套餐列表(金额来自服务端)。
func (h *PageHandler) ListProducts(c *gin.Context) {
var products []model.Product
if err := h.db.Where("active = ?", true).Order("sort asc, id asc").Find(&products).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "list_failed", err.Error())
return
}
out := make([]gin.H, 0, len(products))
for _, p := range products {
out = append(out, gin.H{
"id": p.ID,
"name": p.Name,
"description": p.Description,
"price": p.Price,
})
}
util.RespondSuccess(c, out)
}
+10
View File
@@ -0,0 +1,10 @@
package model
import "time"
// Base 公共字段(与 jiu 约定一致)
type Base struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+24
View File
@@ -0,0 +1,24 @@
package model
// Merchant 一个「业务 × 渠道」的收款凭证。
// 多业务、多支付宝账户、以后加微信,都是往这张表加行 —— 主流程不变。
type Merchant struct {
Base
Code string `gorm:"uniqueIndex;size:64" json:"code"` // 业务标识:yanmei / jiu ...
Name string `gorm:"size:128" json:"name"` // 展示名
Channel string `gorm:"index;size:16" json:"channel"` // alipay | wechat
Production bool `json:"production"` // false=沙箱
Enabled bool `gorm:"default:true" json:"enabled"`
// —— 支付宝 ——(app_id 用于异步回调反查商户)
AppID string `gorm:"index;size:64" json:"app_id"`
AppPrivateKey string `gorm:"type:text" json:"-"` // 应用私钥,绝不下发
AlipayPublicKey string `gorm:"type:text" json:"-"` // 支付宝公钥(验签用)
// —— 微信 ——(预留,渠道尚未实现)
MchID string `gorm:"size:64" json:"mch_id,omitempty"`
WxAppID string `gorm:"size:64" json:"wx_app_id,omitempty"`
APIv3Key string `gorm:"type:text" json:"-"`
CertSerial string `gorm:"size:128" json:"-"`
WxPrivateKey string `gorm:"type:text" json:"-"`
}
+11
View File
@@ -0,0 +1,11 @@
package model
// NotifyLog 异步回调审计日志。每条通知的原始报文 + 验签/处理结果都留一份,便于排查对账纠纷。
type NotifyLog struct {
Base
Channel string `gorm:"size:16" json:"channel"`
OutTradeNo string `gorm:"index;size:64" json:"out_trade_no"`
Verified bool `json:"verified"` // 验签是否通过
Result string `gorm:"size:32" json:"result"` // processed | duplicate | amount_mismatch | verify_failed | not_found | ignored
Raw string `gorm:"type:text" json:"raw"` // 原始表单报文
}
+28
View File
@@ -0,0 +1,28 @@
package model
import "time"
type OrderStatus string
const (
OrderPending OrderStatus = "pending" // 待支付
OrderPaid OrderStatus = "paid" // 已支付
OrderClosed OrderStatus = "closed" // 已关闭/超时
OrderRefunded OrderStatus = "refunded" // 已退款
)
// Order 一笔收款订单。OutTradeNo 是我们生成的商户订单号,贯穿下单/回调/查单。
type Order struct {
Base
OutTradeNo string `gorm:"uniqueIndex;size:64;not null" json:"out_trade_no"`
MerchantID uint64 `gorm:"index;not null" json:"merchant_id"`
Channel string `gorm:"size:16" json:"channel"`
ProductID uint64 `json:"product_id"`
Subject string `gorm:"size:128" json:"subject"`
Amount string `gorm:"size:20;not null" json:"amount"` // 权威金额,回调/查单核对用
Status OrderStatus `gorm:"index;size:16;not null" json:"status"`
TradeNo string `gorm:"index;size:64" json:"trade_no"` // 支付宝交易号
BuyerLogonID string `gorm:"size:128" json:"buyer_logon_id"`
PaidAt *time.Time `json:"paid_at"`
ClientIP string `gorm:"size:64" json:"client_ip"`
}
+13
View File
@@ -0,0 +1,13 @@
package model
// Product 固定套餐/商品。价格以本表为准(服务端权威价),绝不信任前端传值。
// 金额用 string 存(如 "0.01"),与支付宝 total_amount 口径一致,避免浮点误差。
type Product struct {
Base
MerchantID uint64 `gorm:"index;not null" json:"merchant_id"`
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"size:255" json:"description"`
Price string `gorm:"size:20;not null" json:"price"` // 元,两位小数
Active bool `gorm:"default:true" json:"active"`
Sort int `json:"sort"`
}
+36
View File
@@ -0,0 +1,36 @@
package router
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/channel"
"github.com/wangjia/pay/internal/handler"
"github.com/wangjia/pay/internal/service"
)
// Setup 装配路由。返回 OrderService 供 main 启动查单兜底任务。
func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderService {
orderSvc := service.NewOrderService(db, reg, config.C.Server.BaseURL)
orderH := handler.NewOrderHandler(orderSvc)
pageH := handler.NewPageHandler(db, "web")
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)
v1 := r.Group("/api/v1")
{
v1.GET("/products", pageH.ListProducts)
v1.POST("/orders", orderH.Create)
v1.POST("/orders/qr", orderH.CreateQR)
v1.GET("/orders/:out_trade_no", orderH.GetStatus)
v1.POST("/notify/alipay", orderH.AlipayNotify)
}
return orderSvc
}
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
"gorm.io/gorm"
"github.com/wangjia/pay/internal/channel"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
)
var (
ErrProductNotFound = errors.New("套餐不存在或已下架")
ErrAmountMismatch = errors.New("回调金额与订单金额不符")
)
type OrderService struct {
db *gorm.DB
reg *channel.Registry
baseURL string
}
func NewOrderService(db *gorm.DB, reg *channel.Registry, baseURL string) *OrderService {
return &OrderService{db: db, reg: reg, baseURL: baseURL}
}
// prepare 校验套餐、取渠道、落库一张待支付订单(金额一律取服务端套餐价,不信任前端)。
func (s *OrderService) prepare(productID uint64, clientIP string) (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
}
ch, m, err := s.reg.ByMerchantID(p.MerchantID)
if err != nil {
return nil, nil, nil, err
}
order := &model.Order{
OutTradeNo: util.NewOutTradeNo(m.Code),
MerchantID: m.ID,
Channel: m.Channel,
ProductID: p.ID,
Subject: p.Name,
Amount: p.Price, // 权威金额
Status: model.OrderPending,
ClientIP: clientIP,
}
if err := s.db.Create(order).Error; err != nil {
return nil, nil, nil, fmt.Errorf("创建订单失败: %w", err)
}
return ch, m, order, nil
}
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)
if err != nil {
return "", nil, err
}
payURL, err := ch.PagePay(ctx, channel.CreateReq{
OutTradeNo: order.OutTradeNo,
Subject: order.Subject,
Amount: order.Amount,
NotifyURL: s.notifyURL(m.Channel),
ReturnURL: s.baseURL + "/result?out_trade_no=" + order.OutTradeNo,
})
if err != nil {
return "", nil, err
}
return payURL, order, nil
}
// CreateQR 扫码(当面付)下单,返回二维码码串供前端渲染。
func (s *OrderService) CreateQR(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) {
ch, m, order, err := s.prepare(productID, clientIP)
if err != nil {
return "", nil, err
}
qr, err := ch.PreCreate(ctx, channel.CreateReq{
OutTradeNo: order.OutTradeNo,
Subject: order.Subject,
Amount: order.Amount,
NotifyURL: s.notifyURL(m.Channel),
})
if err != nil {
return "", nil, err
}
return qr, order, nil
}
// HandleAlipayNotify 处理支付宝异步回调:反查商户 → 验签 → 核对金额 → 幂等更新。
// 返回 nil 表示已正确处理(调用方应给支付宝回 "success")。
func (s *OrderService) HandleAlipayNotify(ctx context.Context, r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("解析回调失败: %w", err)
}
appID := r.PostFormValue("app_id")
outTradeNo := r.PostFormValue("out_trade_no")
ch, m, err := s.reg.AlipayByAppID(appID)
if err != nil {
s.logNotify("alipay", outTradeNo, false, "not_found", r.Form.Encode())
return err
}
res, err := ch.VerifyNotify(ctx, r)
if err != nil {
s.logNotify("alipay", outTradeNo, false, "verify_failed", r.Form.Encode())
return err
}
result, err := s.applyPaid(m, res)
s.logNotify("alipay", res.OutTradeNo, true, result, res.Raw)
return err
}
// applyPaid 在一个事务里完成「金额核对 + 幂等置为已支付」。返回处理结果标记。
func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (string, error) {
if !res.Paid {
return "ignored", nil // 非成功状态(如 WAIT_BUYER_PAY),确认收到即可
}
var resultTag string
err := s.db.Transaction(func(tx *gorm.DB) error {
var o model.Order
if err := tx.First(&o, "out_trade_no = ? AND merchant_id = ?", res.OutTradeNo, m.ID).Error; err != nil {
resultTag = "not_found"
return fmt.Errorf("订单不存在: %s", res.OutTradeNo)
}
if o.Status == model.OrderPaid {
resultTag = "duplicate" // 幂等:已处理过,直接成功返回
return nil
}
if !util.AmountEqual(o.Amount, res.Amount) {
resultTag = "amount_mismatch"
return ErrAmountMismatch
}
now := time.Now()
upd := tx.Model(&model.Order{}).
Where("out_trade_no = ? AND status = ?", o.OutTradeNo, model.OrderPending).
Updates(map[string]any{
"status": model.OrderPaid,
"trade_no": res.TradeNo,
"buyer_logon_id": res.BuyerLogonID,
"paid_at": &now,
})
if upd.Error != nil {
return upd.Error
}
if upd.RowsAffected == 0 {
resultTag = "duplicate" // 并发下被另一路(如查单)先置位
return nil
}
resultTag = "processed"
log.Printf("[notify] 订单 %s 已支付 trade_no=%s amount=%s", o.OutTradeNo, res.TradeNo, res.Amount)
return nil
})
return resultTag, err
}
func (s *OrderService) logNotify(ch, outTradeNo string, verified bool, result, raw string) {
_ = s.db.Create(&model.NotifyLog{
Channel: ch,
OutTradeNo: outTradeNo,
Verified: verified,
Result: result,
Raw: raw,
}).Error
}
// GetByOutTradeNo 供前端结果页轮询。
func (s *OrderService) GetByOutTradeNo(outTradeNo string) (*model.Order, error) {
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", outTradeNo).Error; err != nil {
return nil, err
}
return &o, nil
}
// SyncPending 兜底:把近期待支付订单拿去主动查单,命中已支付则补记(防回调丢失)。
func (s *OrderService) SyncPending(ctx context.Context, maxAge time.Duration) {
var orders []model.Order
cutoff := time.Now().Add(-maxAge)
if err := s.db.Where("status = ? AND created_at > ?", model.OrderPending, cutoff).
Limit(100).Find(&orders).Error; err != nil {
log.Printf("[query_sync] 查询待支付订单失败: %v", err)
return
}
for i := range orders {
o := &orders[i]
ch, m, err := s.reg.ByMerchantID(o.MerchantID)
if err != nil {
continue
}
qr, err := ch.Query(ctx, o.OutTradeNo)
if err != nil || qr == nil || !qr.Found || !qr.Paid {
continue
}
result, _ := s.applyPaid(m, &channel.NotifyResult{
OutTradeNo: qr.OutTradeNo,
TradeNo: qr.TradeNo,
Amount: qr.Amount,
Paid: true,
})
if result == "processed" {
log.Printf("[query_sync] 订单 %s 经主动查单补记为已支付", o.OutTradeNo)
}
}
}
// StartQuerySync 启动后台查单兜底循环。
func (s *OrderService) StartQuerySync(interval, maxAge time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
s.SyncPending(context.Background(), maxAge)
}
}()
}
+21
View File
@@ -0,0 +1,21 @@
package util
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// NewOutTradeNo 生成全局唯一的商户订单号:<code>-<时间>-<随机>,控制在 64 字符内。
// 例:yanmei-20260624153012-a1b2c3d4
func NewOutTradeNo(merchantCode string) string {
code := merchantCode
if len(code) > 16 {
code = code[:16]
}
ts := time.Now().Format("20060102150405")
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
return fmt.Sprintf("%s-%s-%s", code, ts, suffix)
}
+31
View File
@@ -0,0 +1,31 @@
package util
import (
"fmt"
"math"
"strconv"
"strings"
)
// AmountToCents 把 "0.01" / "12.30" 元金额转为分(int64),便于精确比较。
func AmountToCents(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("空金额")
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("金额格式错误 %q: %w", s, err)
}
if f < 0 {
return 0, fmt.Errorf("金额不能为负: %q", s)
}
return int64(math.Round(f * 100)), nil
}
// AmountEqual 判断两个元金额字符串是否等值(按分比较,避免浮点/格式差异)。
func AmountEqual(a, b string) bool {
ca, err1 := AmountToCents(a)
cb, err2 := AmountToCents(b)
return err1 == nil && err2 == nil && ca == cb
}
+17
View File
@@ -0,0 +1,17 @@
package util
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RespondError 结构化错误:{"code":..,"message":..}
func RespondError(c *gin.Context, status int, code, msg string) {
c.JSON(status, gin.H{"code": code, "message": msg})
}
// RespondSuccess 成功:{"data":..}
func RespondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"data": data})
}