diff --git a/internal/store/order.go b/internal/store/order.go new file mode 100644 index 0000000..8d65d22 --- /dev/null +++ b/internal/store/order.go @@ -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 +} diff --git a/internal/store/order_test.go b/internal/store/order_test.go new file mode 100644 index 0000000..40c469b --- /dev/null +++ b/internal/store/order_test.go @@ -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 单应出现在列表") + } +}