6da6964451
- Go/Gin/GORM + 纯 Go SQLite(无 cgo) - Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位 - 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底 - 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook - 密钥走环境变量/Bitwarden,不入库 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
6.8 KiB
Go
230 lines
6.8 KiB
Go
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)
|
|
}
|
|
}()
|
|
}
|