From 0877b01874cb45e58c822a6d027e3a4baad31b8a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 11:20:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20settle=20=E5=AE=9A=E4=BD=8D=E5=B0=9D?= =?UTF-8?q?=E8=AF=95=E7=9A=84=E7=9E=AC=E6=97=B6=E8=AF=BB=E5=BA=93=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=BD=92=20SettleFailed(=E5=8F=AF=E9=87=8D=E8=AF=95),?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=A0=87=20not=5Ffound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AttemptByProviderRef 返回非哨兵错误(如 DB 抖动)时原先与"查无此单"终态 一并标 SettleNotFound;调用方按结果值决定 ack,会把已付订单永久丢弃。 非哨兵分支改判 SettleFailed(可重试,渠道会重投),errors.Is(ErrAttemptNotFound) 分支维持 SettleNotFound 不变。新增 TestSettleTransientReadErrorIsFailed 通过 关闭底层连接强制产出瞬时读库错误,验证结果与语义。 --- internal/gateway/settle.go | 3 ++- internal/gateway/settle_test.go | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go index fff7e5b..e85d5ad 100644 --- a/internal/gateway/settle.go +++ b/internal/gateway/settle.go @@ -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 { diff --git a/internal/gateway/settle_test.go b/internal/gateway/settle_test.go index 62d66f0..596a9c7 100644 --- a/internal/gateway/settle_test.go +++ b/internal/gateway/settle_test.go @@ -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()