diff --git a/internal/store/order_query.go b/internal/store/order_query.go new file mode 100644 index 0000000..691c7dd --- /dev/null +++ b/internal/store/order_query.go @@ -0,0 +1,64 @@ +package store + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +var ( + ErrOrderNotFound = errors.New("store: order not found") + ErrAttemptNotFound = errors.New("store: attempt not found") +) + +// GetOrder returns an order by out_trade_no. +func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) { + var o model.OrderV2 + if err := s.db.Where("out_trade_no = ?", outTradeNo).First(&o).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrOrderNotFound + } + return nil, fmt.Errorf("store.GetOrder: %w", err) + } + return &o, nil +} + +// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement +// can recover out_trade_no + channel from a callback/query that only carries the ref. +func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) { + var a model.Attempt + if err := s.db.Where("provider_ref = ?", providerRef).First(&a).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAttemptNotFound + } + return nil, fmt.Errorf("store.AttemptByProviderRef: %w", err) + } + return &a, nil +} + +// ListAttemptsByStatus lists attempts in a status (for query-sync fallback). +func (s *OrderStore) ListAttemptsByStatus(status model.AttemptStatus, limit int) ([]model.Attempt, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + var out []model.Attempt + if err := s.db.Where("status = ?", status).Order("id ASC").Limit(limit).Find(&out).Error; err != nil { + return nil, fmt.Errorf("store.ListAttemptsByStatus: %w", err) + } + return out, nil +} + +// ExpirePendingAttempts marks all pending attempts of an order as expired +// (used before a retry spawns a fresh attempt). Order status is untouched. +func (s *OrderStore) ExpirePendingAttempts(outTradeNo string) (int64, error) { + res := s.db.Model(&model.Attempt{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, model.AttemptPending). + Update("status", model.AttemptExpired) + if res.Error != nil { + return 0, fmt.Errorf("store.ExpirePendingAttempts: %w", res.Error) + } + return res.RowsAffected, nil +} diff --git a/internal/store/order_query_test.go b/internal/store/order_query_test.go new file mode 100644 index 0000000..a3f235c --- /dev/null +++ b/internal/store/order_query_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "errors" + "testing" + "time" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func TestGetOrderAndAttemptByRef(t *testing.T) { + s := store.NewOrderStore(model.OpenTestDB(t)) + seedOrder(t, s, "PAY-Q1") // 复用 order_test.go 的 seedOrder(建 order + attempt provider_ref="P-PAY-Q1") + + o, err := s.GetOrder("PAY-Q1") + if err != nil || o.OutTradeNo != "PAY-Q1" { + t.Fatalf("GetOrder = %+v, %v", o, err) + } + if _, err := s.GetOrder("NOPE"); !errors.Is(err, store.ErrOrderNotFound) { + t.Fatalf("缺单应 ErrOrderNotFound, got %v", err) + } + + att, err := s.AttemptByProviderRef("P-PAY-Q1") + if err != nil || att.OutTradeNo != "PAY-Q1" || att.Channel != "crypto" { + t.Fatalf("AttemptByProviderRef = %+v, %v", att, err) + } + if _, err := s.AttemptByProviderRef("P-UNKNOWN"); !errors.Is(err, store.ErrAttemptNotFound) { + t.Fatalf("缺尝试应 ErrAttemptNotFound, got %v", err) + } +} + +func TestListPendingAndExpire(t *testing.T) { + s := store.NewOrderStore(model.OpenTestDB(t)) + seedOrder(t, s, "PAY-Q2") + + pending, err := s.ListAttemptsByStatus(model.AttemptPending, 10) + if err != nil || len(pending) != 1 { + t.Fatalf("ListAttemptsByStatus pending = %d, %v", len(pending), err) + } + + n, err := s.ExpirePendingAttempts("PAY-Q2") + if err != nil || n != 1 { + t.Fatalf("ExpirePendingAttempts = %d, %v", n, err) + } + pending2, _ := s.ListAttemptsByStatus(model.AttemptPending, 10) + if len(pending2) != 0 { + t.Fatalf("弃过期后应无 pending, got %d", len(pending2)) + } + _ = time.Now +}