9117de7dcf
RefundApplyTask 的候选查询按 out_trade_no ASC/id ASC 取前 200,succeeded 退款历史 只增不减、partially_refunded 长期驻留,超过 limit 后最新的崩溃窗口永远排在候选外 扫不到。两路查询都加 since 回溯窗:RefundStore.ListDistinctOutTradeNosByStatusSince (completed_at>=since)、OrderStore.ListOrdersByStatusSince(updated_at>=since),旧的 无窗方法原样保留。RefundApplyTask 新增 lookback/now 参数,config.ReconcileConfig 新增 RefundApplyLookbackMin(默认 48h)。顺带按 T6 复审补 OrderTTLMin 字段注释(须 ≫ crypto orderTTL 15min),清 sync_test.go 死行 `_ = time.Second`。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/wangjia/pay/internal/model"
|
|
)
|
|
|
|
func seedPaid(t *testing.T, os *OrderStore, outNo string, amount int64) {
|
|
t.Helper()
|
|
if err := os.CreateOrder(&model.OrderV2{
|
|
OutTradeNo: outNo, BizSystem: "pangolin", BizCode: "pro_year",
|
|
AmountMinor: amount, Currency: "CNY", Status: model.OrderPaidV2,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.CreateAttempt(&model.Attempt{
|
|
OutTradeNo: outNo, Channel: "alipay", ProviderRef: outNo,
|
|
AmountMinor: amount, Currency: "CNY", Status: model.AttemptPaid,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestRefundStoreLifecycle(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
os := NewOrderStore(db)
|
|
rs := NewRefundStore(db)
|
|
seedPaid(t, os, "PAY-1", 19900)
|
|
|
|
// PaidAttempt 定位原交易
|
|
att, err := os.PaidAttempt("PAY-1")
|
|
if err != nil || att.Channel != "alipay" || att.ProviderRef != "PAY-1" {
|
|
t.Fatalf("PaidAttempt = %+v, %v", att, err)
|
|
}
|
|
|
|
// 建两笔部分退款(processing)
|
|
for _, id := range []string{"rf-1", "rf-2"} {
|
|
if err := rs.CreateRefund(&model.Refund{
|
|
RefundID: id, OutTradeNo: "PAY-1", AttemptProviderRef: "PAY-1",
|
|
AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// 非失败之和 = 10000(占额度)
|
|
if sum, _ := rs.RefundSum("PAY-1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded); sum != 10000 {
|
|
t.Fatalf("reserved sum = %d want 10000", sum)
|
|
}
|
|
|
|
// rf-1 翻 succeeded(条件正确)
|
|
ok, err := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "ali-refund-1", time.Now())
|
|
if err != nil || !ok {
|
|
t.Fatalf("mark rf-1 = %v, %v", ok, err)
|
|
}
|
|
// 再翻一次(from 已不符)→ no-op
|
|
if ok, _ := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "x", time.Now()); ok {
|
|
t.Fatal("重复翻转应 no-op")
|
|
}
|
|
r1, _ := rs.GetRefund("rf-1")
|
|
if r1.Status != model.RefundSucceeded || r1.ProviderRefundRef != "ali-refund-1" || r1.CompletedAt == nil {
|
|
t.Fatalf("rf-1 = %+v", r1)
|
|
}
|
|
|
|
// 部分退:succeeded 之和 5000 < 19900 → partially_refunded
|
|
succ, _ := rs.RefundSum("PAY-1", model.RefundSucceeded)
|
|
if flipped, _ := os.ApplyRefundToOrder("PAY-1", succ >= 19900); !flipped {
|
|
t.Fatal("ApplyRefundToOrder 应翻转")
|
|
}
|
|
if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 {
|
|
t.Fatalf("order status = %s want partially_refunded", o.Status)
|
|
}
|
|
|
|
// rf-2 也成功 → 总退 10000 仍 < 19900,保持 partially_refunded(非 fully)
|
|
_, _ = rs.MarkRefundStatus("rf-2", model.RefundProcessing, model.RefundSucceeded, "ali-refund-2", time.Now())
|
|
succ, _ = rs.RefundSum("PAY-1", model.RefundSucceeded)
|
|
_, _ = os.ApplyRefundToOrder("PAY-1", succ >= 19900)
|
|
if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 {
|
|
t.Fatalf("order status = %s want partially_refunded", o.Status)
|
|
}
|
|
}
|
|
|
|
func TestApplyRefundToOrderFully(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
os := NewOrderStore(db)
|
|
seedPaid(t, os, "PAY-2", 10000)
|
|
if _, err := os.ApplyRefundToOrder("PAY-2", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o, _ := os.GetOrder("PAY-2"); o.Status != model.OrderRefundedV2 {
|
|
t.Fatalf("status = %s want refunded", o.Status)
|
|
}
|
|
}
|
|
|
|
// TestListDistinctOutTradeNosByStatus 供退款修复扫描定位「有 succeeded 退款」的候选
|
|
// 订单(自愈依据):同订单多笔 succeeded 退款只应出现一次(distinct)。
|
|
func TestListDistinctOutTradeNosByStatus(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
rs := NewRefundStore(db)
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "s1", OutTradeNo: "PAY-S1", AmountMinor: 100, Currency: "CNY", Status: model.RefundSucceeded})
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "s2", OutTradeNo: "PAY-S1", AmountMinor: 200, Currency: "CNY", Status: model.RefundSucceeded}) // 同单第二笔
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "s3", OutTradeNo: "PAY-S2", AmountMinor: 100, Currency: "CNY", Status: model.RefundSucceeded})
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "p1", OutTradeNo: "PAY-S3", AmountMinor: 100, Currency: "CNY", Status: model.RefundProcessing}) // 非 succeeded,不应命中
|
|
|
|
got, err := rs.ListDistinctOutTradeNosByStatus(model.RefundSucceeded, 50)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("应 distinct 出 2 个 out_trade_no, got %d: %+v", len(got), got)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, no := range got {
|
|
seen[no] = true
|
|
}
|
|
if !seen["PAY-S1"] || !seen["PAY-S2"] {
|
|
t.Fatalf("命中集合不对: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestListDistinctOutTradeNosByStatusSinceExcludesStale 钉住回溯窗:completed_at
|
|
// 早于 since 的 succeeded 退款不应入候选(防 LIMIT 尾部饥饿——见 RefundApplyTask)。
|
|
func TestListDistinctOutTradeNosByStatusSinceExcludesStale(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
rs := NewRefundStore(db)
|
|
now := time.Now()
|
|
stale := now.Add(-72 * time.Hour)
|
|
fresh := now.Add(-time.Hour)
|
|
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "s-stale", OutTradeNo: "PAY-STALE", AmountMinor: 100, Currency: "CNY",
|
|
Status: model.RefundSucceeded, CompletedAt: &stale})
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "s-fresh", OutTradeNo: "PAY-FRESH", AmountMinor: 100, Currency: "CNY",
|
|
Status: model.RefundSucceeded, CompletedAt: &fresh})
|
|
|
|
got, err := rs.ListDistinctOutTradeNosByStatusSince(model.RefundSucceeded, now.Add(-48*time.Hour), 50)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0] != "PAY-FRESH" {
|
|
t.Fatalf("只应命中窗内的 PAY-FRESH, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestListStuckRefunds 供「卡滞 processing/manual_pending 退款告警」:只挑 updated_at
|
|
// 早于阈值的 processing/manual_pending 行,requested/succeeded/failed 不命中。
|
|
func TestListStuckRefunds(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
rs := NewRefundStore(db)
|
|
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
|
mk := func(id, no string, st model.RefundStatus, updatedAgo time.Duration) {
|
|
if err := rs.CreateRefund(&model.Refund{RefundID: id, OutTradeNo: no, AmountMinor: 100, Currency: "CNY", Status: st}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&model.Refund{}).Where("refund_id = ?", id).
|
|
Update("updated_at", now.Add(-updatedAgo)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mk("stuck-proc", "PAY-T1", model.RefundProcessing, 45*time.Minute) // 超阈值 → 命中
|
|
mk("fresh-proc", "PAY-T2", model.RefundProcessing, 5*time.Minute) // 未超 → 不命中
|
|
mk("stuck-manual", "PAY-T3", model.RefundManualPending, 2*time.Hour) // 超阈值 → 命中
|
|
mk("done", "PAY-T4", model.RefundSucceeded, 2*time.Hour) // 已终态 → 不命中
|
|
|
|
got, err := rs.ListStuckRefunds([]model.RefundStatus{model.RefundProcessing, model.RefundManualPending}, now.Add(-30*time.Minute), 50)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("应命中 2 笔卡滞, got %d: %+v", len(got), got)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, r := range got {
|
|
seen[r.RefundID] = true
|
|
}
|
|
if !seen["stuck-proc"] || !seen["stuck-manual"] {
|
|
t.Fatalf("命中集合不对: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestListManualPending(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
rs := NewRefundStore(db)
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "rf-m1", OutTradeNo: "C-1", AmountMinor: 1, Currency: "USDT", Status: model.RefundManualPending})
|
|
_ = rs.CreateRefund(&model.Refund{RefundID: "rf-p1", OutTradeNo: "C-2", AmountMinor: 1, Currency: "USDT", Status: model.RefundProcessing})
|
|
list, err := rs.ListManualPending(50)
|
|
if err != nil || len(list) != 1 || list[0].RefundID != "rf-m1" {
|
|
t.Fatalf("manual pending = %+v, %v", list, err)
|
|
}
|
|
}
|
|
|
|
// TestCreateRefundGuardedSequential 顺序场景:60 通过、再 50 超退拒(60+50=110>100)、
|
|
// 再 40 通过(60+40=100 打满),验证守卫在非并发路径下的基本判定与求和口径(非 failed)。
|
|
func TestCreateRefundGuardedSequential(t *testing.T) {
|
|
db := model.OpenTestDB(t)
|
|
os := NewOrderStore(db)
|
|
rs := NewRefundStore(db)
|
|
seedPaid(t, os, "PAY-G1", 10000) // 100.00
|
|
|
|
ok, err := rs.CreateRefundGuarded(&model.Refund{
|
|
RefundID: "g1-a", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1",
|
|
AmountMinor: 6000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
|
|
}, 10000)
|
|
if err != nil || !ok {
|
|
t.Fatalf("g1-a: ok=%v err=%v want true,nil", ok, err)
|
|
}
|
|
|
|
ok, err = rs.CreateRefundGuarded(&model.Refund{
|
|
RefundID: "g1-b", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1",
|
|
AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
|
|
}, 10000)
|
|
if err != nil || ok {
|
|
t.Fatalf("g1-b: ok=%v err=%v want false,nil(超退应拒)", ok, err)
|
|
}
|
|
|
|
ok, err = rs.CreateRefundGuarded(&model.Refund{
|
|
RefundID: "g1-c", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1",
|
|
AmountMinor: 4000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
|
|
}, 10000)
|
|
if err != nil || !ok {
|
|
t.Fatalf("g1-c: ok=%v err=%v want true,nil", ok, err)
|
|
}
|
|
|
|
sum, err := rs.RefundSum("PAY-G1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded)
|
|
if err != nil || sum != 10000 {
|
|
t.Fatalf("reserved sum = %d, err=%v want 10000", sum, err)
|
|
}
|
|
// 被拒的 g1-b 不应落库
|
|
if _, err := rs.GetRefund("g1-b"); !errors.Is(err, ErrRefundNotFound) {
|
|
t.Fatalf("g1-b 应未落库,GetRefund err=%v", err)
|
|
}
|
|
}
|
|
|
|
// openFileGuardedDB 开一个 t.TempDir 下的文件型 sqlite(而非 model.OpenTestDB 的
|
|
// in-memory cache=shared),确保并发 goroutine 走真正独立的连接/文件锁路径来
|
|
// 验证 CreateRefundGuarded 的串行化 —— DSN 同样带 _txlock=immediate。
|
|
func openFileGuardedDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
dsn := fmt.Sprintf("file:%s/guard.db?_txlock=immediate&_pragma=busy_timeout(5000)", t.TempDir())
|
|
db, err := gorm.Open(sqlite.Open(dsn),
|
|
&gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true})
|
|
if err != nil {
|
|
t.Fatalf("open file guard db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{},
|
|
&model.WebhookDelivery{}, &model.Product{}, &model.ProductPrice{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
sqlDB, _ := db.DB()
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
return db
|
|
}
|
|
|
|
// TestCreateRefundGuardedConcurrentExactlyOneWins 两个 goroutine 同时对同一笔
|
|
// 100.00 已付订单各申请 60.00 退款(合计 120 > 100,必超退)。事务化守卫(锁订单行 +
|
|
// 事务内求和 + 校验 + 插入)必须让恰好一个成功、另一个被拒 —— 若把守卫换回「先
|
|
// RefundSum 再 CreateRefund」的裸读写两段式(Task 3 计划里的朴素调用法),两个
|
|
// goroutine 都可能读到 reserved=0、都通过校验、都插入成功,变成两个 true(超退)。
|
|
// 本测试即用来钉住这个回归:TDD 上手动验证过 —— 把 CreateRefundGuarded 换成裸
|
|
// RefundSum(...)+CreateRefund(...) 两步、不用事务/锁,本测试会因为「两个 true」而
|
|
// FAIL(count==2),证明测试确实在防这一类竞态,而不是摆设。
|
|
func TestCreateRefundGuardedConcurrentExactlyOneWins(t *testing.T) {
|
|
db := openFileGuardedDB(t)
|
|
os := NewOrderStore(db)
|
|
rs := NewRefundStore(db)
|
|
seedPaid(t, os, "PAY-G2", 10000) // 100.00
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]bool, 2)
|
|
errs := make([]error, 2)
|
|
ids := []string{"g2-a", "g2-b"}
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
ok, err := rs.CreateRefundGuarded(&model.Refund{
|
|
RefundID: ids[i], OutTradeNo: "PAY-G2", AttemptProviderRef: "PAY-G2",
|
|
AmountMinor: 6000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business",
|
|
}, 10000)
|
|
results[i] = ok
|
|
errs[i] = err
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
trueCount := 0
|
|
for i, ok := range results {
|
|
// 允许 SQLite 忙锁重试耗尽等基础设施性错误(busy_timeout 已设为 5s 通常不会
|
|
// 触发),但不允许"两个都成功"的超退;若出现非预期错误直接报失败方便定位。
|
|
if errs[i] != nil {
|
|
t.Fatalf("goroutine %d unexpected error: %v", i, errs[i])
|
|
}
|
|
if ok {
|
|
trueCount++
|
|
}
|
|
}
|
|
if trueCount != 1 {
|
|
t.Fatalf("trueCount = %d, want 1 (results=%v)", trueCount, results)
|
|
}
|
|
|
|
sum, err := rs.RefundSum("PAY-G2", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded)
|
|
if err != nil || sum != 6000 {
|
|
t.Fatalf("reserved sum = %d, err=%v want 6000(仅一笔落库)", sum, err)
|
|
}
|
|
}
|