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
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -8,11 +10,18 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var testDBCounter int64
|
||||||
|
|
||||||
// OpenTestDB opens an in-memory SQLite DB with the v2 schema migrated.
|
// 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).
|
// 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 {
|
func OpenTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
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})
|
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open test db: %v", err)
|
t.Fatalf("open test db: %v", err)
|
||||||
|
|||||||
@@ -47,10 +47,14 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at
|
|||||||
if res.RowsAffected == 0 {
|
if res.RowsAffected == 0 {
|
||||||
return nil // 非 pending → 幂等 no-op
|
return nil // 非 pending → 幂等 no-op
|
||||||
}
|
}
|
||||||
if err := tx.Model(&model.Attempt{}).
|
ares := tx.Model(&model.Attempt{}).
|
||||||
Where("channel = ? AND provider_ref = ?", channel, providerRef).
|
Where("out_trade_no = ? AND channel = ? AND provider_ref = ?", outTradeNo, channel, providerRef).
|
||||||
Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at}).Error; err != nil {
|
Updates(map[string]any{"status": model.AttemptPaid, "paid_at": at})
|
||||||
return err
|
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
|
flipped = true
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -61,3 +61,25 @@ func TestMarkPaidIdempotentAndCancel(t *testing.T) {
|
|||||||
t.Fatalf("canceled 单应出现在列表")
|
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