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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user