// 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 } 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 } 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 }