From af15f4736ed471dd0d3b43128963a13524595494 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 08:35:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20MarkAttemptPaid=20=E6=8C=89=E5=8D=95?= =?UTF-8?q?scope+=E6=A0=A1=E9=AA=8Cattempt=E5=91=BD=E4=B8=AD=20+=20OpenTes?= =?UTF-8?q?tDB=20=E5=94=AF=E4=B8=80=E5=BA=93=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - attempt UPDATE 加 out_trade_no 过滤并检查 RowsAffected,防止 order 已翻 paid 但 providerRef 错配导致没有对应 attempt 被标记的账本背离(未命中 时返回 error 触发事务回滚,order 保持 pending) - 补测试覆盖 providerRef 错配场景:断言返回 error 且回滚后订单仍 pending - OpenTestDB 改为每次调用生成唯一内存库名(atomic 计数器),避免同包多 测试共享 file::memory:?cache=shared 导致数据串扰 Co-Authored-By: Claude Opus 4.8 --- internal/model/testdb.go | 11 ++++++++++- internal/store/order.go | 12 ++++++++---- internal/store/order_test.go | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) 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) + } +}