初始提交:岩美 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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"` // 原始表单报文
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user