feat(v2): OrderStore 建单/建尝试/幂等标付/取消/列表(条件UPDATE+RowsAffected)

This commit is contained in:
wangjia
2026-07-10 08:21:02 +08:00
parent 01887dd571
commit 6a53d7e008
2 changed files with 147 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
// 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"
"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
}
if err := tx.Model(&model.Attempt{}).
Where("channel = ? AND provider_ref = ?", channel, providerRef).
Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at}).Error; err != nil {
return err
}
flipped = true
return nil
})
if err != nil {
return false, fmt.Errorf("store.MarkAttemptPaid: %w", err)
}
return flipped, 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
}
+63
View File
@@ -0,0 +1,63 @@
package store_test
import (
"testing"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func seedOrder(t *testing.T, s *store.OrderStore, no string) {
t.Helper()
if err := s.CreateOrder(&model.OrderV2{
OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", ProductID: 3,
AmountMinor: 29990000, Currency: "USDT", Status: model.OrderPendingV2,
}); err != nil {
t.Fatalf("create order: %v", err)
}
if err := s.CreateAttempt(&model.Attempt{
OutTradeNo: no, Channel: "crypto", AccountID: "usdt-a1", ProviderRef: "P-" + no,
RenderType: "crypto_address", AmountMinor: 29990000, Currency: "USDT",
Status: model.AttemptPending, ExpiresAt: ptr(time.Now().Add(15 * time.Minute)),
}); err != nil {
t.Fatalf("create attempt: %v", err)
}
}
func ptr(t time.Time) *time.Time { return &t }
func TestMarkPaidIdempotentAndCancel(t *testing.T) {
s := store.NewOrderStore(model.OpenTestDB(t))
seedOrder(t, s, "PAY-1")
ok, err := s.MarkAttemptPaid("PAY-1", "crypto", "P-PAY-1", time.Now())
if err != nil || !ok {
t.Fatalf("mark#1 ok=%v err=%v", ok, err)
}
// 幂等:再标 → false
ok2, _ := s.MarkAttemptPaid("PAY-1", "crypto", "P-PAY-1", time.Now())
if ok2 {
t.Fatalf("第二次 MarkAttemptPaid 应 false")
}
// paid 后不能取消
if okc, _ := s.CancelOrder("PAY-1"); okc {
t.Fatalf("paid 单不应可取消")
}
// 另一单可取消,且列表可见
seedOrder(t, s, "PAY-2")
if okc, err := s.CancelOrder("PAY-2"); err != nil || !okc {
t.Fatalf("cancel PAY-2 ok=%v err=%v", okc, err)
}
list, _ := s.ListOrders("pangolin", "u-1", 20)
var sawCanceled bool
for _, o := range list {
if o.OutTradeNo == "PAY-2" && o.Status == model.OrderCanceledV2 {
sawCanceled = true
}
}
if !sawCanceled {
t.Fatalf("canceled 单应出现在列表")
}
}