test(pay): 补 settle 并发/跨包全链 e2e/reconcile 装配/alipay 验签健壮性/money 零小数位

- gateway: TestSettleConcurrentSameOrder 真并发钉住 MarkAttemptPaid 行锁不变量
  (文件型 sqlite,规避 in-memory cache=shared 的 SQLITE_LOCKED_SHAREDCACHE)。
- gateway: e2e_fullchain_test.go 补下单→回调→settle→webhook 实际 HTTP 投递→
  delivered 整链(httptest server + 真实 store.WebhookStore/webhook.Notifier)。
- reconcile: main.go 装配抽到 reconcile.Assemble(+Runner.TaskNames 访问器),
  补 assembly_test.go 钉住 7 个后台任务全部注册 + crypto 缺渠道/interval=0 分支。
- alipay: 补验签健壮性(未知多余字段/字段乱序/中文unicode/空值字段),意外定位
  vendor Encoder 对空值字段的真实语义与官方文档描述不同,已记录在测试注释。
- money: 补 TestZeroDecimalCurrencyJPYNotSupported,记录 JPY/KRW 当前未注册进
  exponents(接入会先踩 ErrUnknownCurrency,不是乘除法坑)。
This commit is contained in:
wangjia
2026-07-11 08:50:45 +08:00
parent 1f87b0fcbf
commit 0ae7769abe
8 changed files with 696 additions and 16 deletions
+119
View File
@@ -3,8 +3,14 @@ package gateway_test
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
@@ -12,6 +18,7 @@ import (
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/provider/fake"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/webhook"
)
func attemptRef(t *testing.T, orders interface {
@@ -169,6 +176,118 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
}
}
// openFileGatewaySettleDB 开一个 t.TempDir 下的文件型 sqlite(而非 model.OpenTestDB 的
// in-memory cache=shared)专供并发 Settle 测试:cache=shared 的 in-memory sqlite 下,两个
// goroutine 各自事务并发写不同表会报 "database table is locked"(SQLITE_LOCKED_SHAREDCACHE,
// 共享缓存表级锁——不是 busy_timeout 能重试的 SQLITE_BUSY),同 store/refund_test.go
// openFileGuardedDB 的说明。文件型连接各走独立锁路径,规避此问题;DSN 同样带
// _txlock=immediate 保留"事务一开始即抢写锁"的守卫语义。
func openFileGatewaySettleDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s/settle.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 settle db: %v", err)
}
if err := db.AutoMigrate(&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{},
&model.WebhookDelivery{}, &model.Product{}, &model.ProductPrice{}, &model.OrphanPayment{},
&model.Subscription{}, &model.Chargeback{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := model.UpgradeWebhookDeliveryIndex(db); err != nil {
t.Fatalf("upgrade webhook_deliveries uq_delivery index: %v", err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
return db
}
// TestSettleConcurrentSameOrder 钉住 Settle 依赖的行锁不变量(MarkAttemptPaid 用条件
// UPDATE ... WHERE status=pending 天然把并发翻转串行化,同 store/refund_test.go
// TestCreateRefundGuardedConcurrentExactlyOneWins 的真并发模式):两个 goroutine 并发对
// 同一订单投递同一份成功回调,必须恰好一次真正结算 —— 订单 paid 一次(不重复)、
// webhook outbox 对该单恰好一行(不双投)、一个 goroutine 得 Settled、另一个得 Duplicate。
//
// 用真实 store.WebhookStore + webhook.Notifier(非 spyEnqueuer)驱动:本测试要断言的是
// "落库的 outbox 行数",spyEnqueuer 的内存 slice/map 本身也不是并发安全的,不适合这里。
// model.OpenTestDB 的 DSN 已带 _txlock=immediate + busy_timeout,并发事务在读阶段即串行化。
func TestSettleConcurrentSameOrder(t *testing.T) {
db := openFileGatewaySettleDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
ws := store.NewWebhookStore(db)
notifier := webhook.NewNotifier(ws,
func(string) (config.BizSystemConfig, bool) { return config.BizSystemConfig{}, false },
func(string) (bool, error) { return true, nil },
)
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, notifier, "global", subs, chargebacks)
ctx := context.Background()
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-conc"})
if err != nil {
t.Fatalf("create: %v", err)
}
ref := attemptRef(t, orders)
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
var wg sync.WaitGroup
results := make([]gateway.SettleResult, 2)
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i], errs[i] = g.Settle(ctx, ev)
}(i)
}
wg.Wait()
for i, gerr := range errs {
if gerr != nil {
t.Fatalf("goroutine %d unexpected error: %v", i, gerr)
}
}
processed, duplicate := 0, 0
for _, r := range results {
switch r {
case gateway.SettleProcessed:
processed++
case gateway.SettleDuplicate:
duplicate++
default:
t.Fatalf("unexpected result %v in %v", r, results)
}
}
if processed != 1 || duplicate != 1 {
t.Fatalf("results = %v, want exactly one Processed + one Duplicate", results)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPaidV2 {
t.Fatalf("order = %+v, %v, want paid exactly once", o, err)
}
var cnt int64
if err := db.Model(&model.WebhookDelivery{}).
Where("out_trade_no = ? AND event_type = ?", res.OrderNo, "payment.succeeded").
Count(&cnt).Error; err != nil {
t.Fatalf("count outbox: %v", err)
}
if cnt != 1 {
t.Fatalf("outbox rows for order = %d, want exactly 1(不双投)", cnt)
}
}
func TestHandleCallbackAndSync(t *testing.T) {
g, fp, _, orders := newGateway(t)
ctx := context.Background()