fix(v2): settle 定位尝试的瞬时读库错误归 SettleFailed(可重试),不再误标 not_found

AttemptByProviderRef 返回非哨兵错误(如 DB 抖动)时原先与"查无此单"终态
一并标 SettleNotFound;调用方按结果值决定 ack,会把已付订单永久丢弃。
非哨兵分支改判 SettleFailed(可重试,渠道会重投),errors.Is(ErrAttemptNotFound)
分支维持 SettleNotFound 不变。新增 TestSettleTransientReadErrorIsFailed 通过
关闭底层连接强制产出瞬时读库错误,验证结果与语义。
This commit is contained in:
wangjia
2026-07-10 11:20:53 +08:00
parent 68d162d6c4
commit 0877b01874
2 changed files with 41 additions and 1 deletions
+2 -1
View File
@@ -43,7 +43,8 @@ func (g *Gateway) Settle(ctx context.Context, ev *provider.PaidEvent) (SettleRes
if errors.Is(err, store.ErrAttemptNotFound) {
return SettleNotFound, nil
}
return SettleNotFound, err
// 读库瞬时失败是可重试态,不能与"查无此单"终态混淆。
return SettleFailed, err
}
// 金额/币种核对:币种须一致,实付须 ≥ 应收(允许 crypto 多付,拒少付)。
if ev.PaidCurrency != att.Currency || ev.PaidAmountMinor < att.AmountMinor {
+39
View File
@@ -5,9 +5,13 @@ import (
"encoding/json"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/provider/fake"
"github.com/wangjia/pay/internal/store"
)
func attemptRef(t *testing.T, orders interface {
@@ -103,6 +107,41 @@ func TestSettleEnqueueFailureKeepsOrderPending(t *testing.T) {
}
}
// 瞬时读库失败(非 store.ErrAttemptNotFound 哨兵)必须归 SettleFailed(可重试),
// 不能与"查无此单"的终态 SettleNotFound 混淆——否则调用方按结果值决定 ack,
// 会把已付订单永久丢弃。构造方式:先建好订单/尝试,再直接关掉底层连接,
// 让 AttemptByProviderRef 打到一个已关闭的 DB 上,产出非哨兵错误。
func TestSettleTransientReadErrorIsFailed(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(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},
})
spy := &spyEnqueuer{}
g := gateway.New(orders, preg, areg, stubResolver{}, spy, "global")
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
ref := attemptRef(t, orders)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB(): %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
got, err := g.Settle(ctx, ev)
if got != gateway.SettleFailed || err == nil {
t.Fatalf("瞬时读库失败应 SettleFailed+err, got %v, %v", got, err)
}
}
func TestHandleCallbackAndSync(t *testing.T) {
g, fp, _, orders := newGateway(t)
ctx := context.Background()