Files
pay/internal/store/order.go
T

119 lines
3.7 KiB
Go

// Package store holds v2 data-access for orders/attempts. Idempotency uses
// conditional UPDATE + RowsAffected (SQLite single-writer; no FOR UPDATE),
// matching pay's existing applyPaid pattern.
package store
import (
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
type OrderStore struct{ db *gorm.DB }
func NewOrderStore(db *gorm.DB) *OrderStore { return &OrderStore{db: db} }
func (s *OrderStore) CreateOrder(o *model.OrderV2) error {
if err := s.db.Create(o).Error; err != nil {
return fmt.Errorf("store.CreateOrder: %w", err)
}
return nil
}
func (s *OrderStore) CreateAttempt(a *model.Attempt) error {
if a.ProviderRef == "" {
return fmt.Errorf("store.CreateAttempt: empty ProviderRef")
}
if err := s.db.Create(a).Error; err != nil {
return fmt.Errorf("store.CreateAttempt: %w", err)
}
return nil
}
// MarkAttemptPaid flips a pending order (and its attempt) to paid inside a tx.
// Returns false if the order was not pending (already handled / canceled / expired).
func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at time.Time) (bool, error) {
var flipped bool
err := s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
Updates(map[string]any{"status": model.OrderPaidV2, "paid_at": at})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil // 非 pending → 幂等 no-op
}
ares := tx.Model(&model.Attempt{}).
Where("out_trade_no = ? AND channel = ? AND provider_ref = ?", outTradeNo, channel, providerRef).
Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at})
if ares.Error != nil {
return ares.Error
}
if ares.RowsAffected == 0 {
return fmt.Errorf("store.MarkAttemptPaid: order %s flipped paid but no matching attempt (%s/%s)", outTradeNo, channel, providerRef)
}
flipped = true
return nil
})
if err != nil {
return false, fmt.Errorf("store.MarkAttemptPaid: %w", err)
}
return flipped, nil
}
// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。
// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。
func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) {
var created bool
err := s.db.Transaction(func(tx *gorm.DB) error {
ores := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true,
}).Create(order)
if ores.Error != nil {
return ores.Error
}
if ores.RowsAffected == 0 {
return nil // 已建过 → 幂等 no-op
}
ares := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true,
}).Create(att)
if ares.Error != nil {
return ares.Error
}
created = ares.RowsAffected > 0
return nil
})
if err != nil {
return false, fmt.Errorf("store.CreateRenewalPaid: %w", err)
}
return created, nil
}
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
Update("status", model.OrderCanceledV2)
if res.Error != nil {
return false, fmt.Errorf("store.CancelOrder: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *OrderStore) ListOrders(bizSystem, bizRef string, limit int) ([]model.OrderV2, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var out []model.OrderV2
if err := s.db.Where("biz_system = ? AND biz_ref = ?", bizSystem, bizRef).
Order("id DESC").Limit(limit).Find(&out).Error; err != nil {
return nil, fmt.Errorf("store.ListOrders: %w", err)
}
return out, nil
}