fix(v2): MarkAttemptPaid 按单scope+校验attempt命中 + OpenTestDB 唯一库名

- 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 08:35:22 +08:00
parent 5aecb18d7c
commit af15f4736e
3 changed files with 40 additions and 5 deletions
+10 -1
View File
@@ -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)
+8 -4
View File
@@ -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
+22
View File
@@ -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)
}
}