diff --git a/internal/model/testdb.go b/internal/model/testdb.go index 8e2d96c..61d8ad0 100644 --- a/internal/model/testdb.go +++ b/internal/model/testdb.go @@ -1,6 +1,8 @@ package model import ( + "fmt" + "sync/atomic" "testing" "github.com/glebarez/sqlite" @@ -8,11 +10,18 @@ import ( "gorm.io/gorm/logger" ) +var testDBCounter int64 + // OpenTestDB opens an in-memory SQLite DB with the v2 schema migrated. // Shared by all package tests (pay has no prior DB test harness — this is it). +// Each call gets a uniquely named in-memory DB (cache=shared is still needed +// so GORM's connection pool sees the same migrated schema across connections), +// so concurrent/parallel tests in the same package don't share state. func OpenTestDB(t *testing.T) *gorm.DB { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), + n := atomic.AddInt64(&testDBCounter, 1) + dsn := fmt.Sprintf("file:testdb_%d?mode=memory&cache=shared", n) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) if err != nil { t.Fatalf("open test db: %v", err) diff --git a/internal/store/order.go b/internal/store/order.go index 8d65d22..58d783f 100644 --- a/internal/store/order.go +++ b/internal/store/order.go @@ -47,10 +47,14 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at 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 + 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 diff --git a/internal/store/order_test.go b/internal/store/order_test.go index 40c469b..204ca15 100644 --- a/internal/store/order_test.go +++ b/internal/store/order_test.go @@ -61,3 +61,25 @@ func TestMarkPaidIdempotentAndCancel(t *testing.T) { t.Fatalf("canceled 单应出现在列表") } } + +func TestMarkAttemptPaidProviderRefMismatchRollsBack(t *testing.T) { + db := model.OpenTestDB(t) + s := store.NewOrderStore(db) + seedOrder(t, s, "PAY-X") + + ok, err := s.MarkAttemptPaid("PAY-X", "crypto", "P-WRONG", time.Now()) + if err == nil { + t.Fatalf("providerRef 错配应返回 error, got ok=%v err=%v", ok, err) + } + if ok { + t.Fatalf("providerRef 错配不应返回 ok=true") + } + + var o model.OrderV2 + if err := db.Where("out_trade_no = ?", "PAY-X").First(&o).Error; err != nil { + t.Fatalf("query order: %v", err) + } + if o.Status != model.OrderPendingV2 { + t.Fatalf("事务应回滚, order status 应仍为 pending, got %v", o.Status) + } +}