diff --git a/config/config.go b/config/config.go index c06430e..0079b29 100644 --- a/config/config.go +++ b/config/config.go @@ -27,10 +27,12 @@ type Config struct { // 只存 CredentialEnvPrefix,真值由 internal/accounts.Registry 运行时从环境变量 // `_` 读取。 type AccountConfig struct { - AccountID string `mapstructure:"account_id"` - Channel string `mapstructure:"channel"` - Weight int `mapstructure:"weight"` - Enabled bool `mapstructure:"enabled"` + AccountID string `mapstructure:"account_id"` + Channel string `mapstructure:"channel"` + Weight int `mapstructure:"weight"` + Enabled bool `mapstructure:"enabled"` + // DailyLimit 单位为该账户所属渠道结算币种的最小单位(minor units)——渠道币种由 Capabilities().SettleCurrencies[0] 决定; + // 跨币种账户的限额刻度互不可比,运营配置时按各自币种填。 DailyLimit int64 `mapstructure:"daily_limit"` Region string `mapstructure:"region"` Subject string `mapstructure:"subject"` diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index 2a8f09b..bf1d443 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -154,6 +154,10 @@ func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, e util.RespondError(c, http.StatusBadRequest, "unknown_method", "不支持的支付方式") case errors.Is(err, gateway.ErrNoAccount): util.RespondError(c, http.StatusServiceUnavailable, "no_account", "该支付方式暂不可用") + case errors.Is(err, gateway.ErrCurrencyMismatch): + util.RespondError(c, http.StatusConflict, "currency_mismatch", "该支付方式结算币种与订单不符,请换一种支付方式") + case errors.Is(err, gateway.ErrNoSettleCurrency): + util.RespondError(c, http.StatusServiceUnavailable, "no_settle_currency", "该支付方式配置不完整,暂不可用") default: log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err) util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试") diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index d23576f..7b8eec2 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -2,10 +2,13 @@ package handler_test import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" @@ -163,3 +166,79 @@ func TestV2CallbackMalformedBodyIs400Transient(t *testing.T) { t.Fatalf("malformed body callback code=%d, want 400", w.Code) } } + +// TestV2RetryCurrencyMismatch409 覆盖 review 发现:重试到结算币种不同的渠道应返回 409, +// 不再落入默认 500,以避免客户端误以为是瞬时失败而无限重试。 +func TestV2RetryCurrencyMismatch409(t *testing.T) { + t.Helper() + gin.SetMode(gin.TestMode) + orders := store.NewOrderStore(model.OpenTestDB(t)) + preg := provider.NewRegistry() + preg.Register(fake.New()) // method="fake", settles in "USDT" + + // 构造一个只支持 EUR 的 provider + eurProvider := &eurFakeProvider{} + preg.Register(eurProvider) // method="fake_eur", settles in "EUR" + + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + {AccountID: "fake-a2", Channel: "fake_eur", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, preg, picker, oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + + // 下单(USDT 渠道) + w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"}) + if w.Code != http.StatusOK { + t.Fatalf("create code=%d body=%v", w.Code, out) + } + orderNo := out["data"].(map[string]any)["order_no"].(string) + + // 重试到 EUR 渠道 → 币种不符 → 409 currency_mismatch + wRetry, outRetry := do(t, r, http.MethodPost, "/api/v2/orders/"+orderNo+"/retry", map[string]any{"method": "fake_eur"}) + if wRetry.Code != http.StatusConflict { + t.Fatalf("retry code=%d, want 409; body=%v", wRetry.Code, outRetry) + } + if outRetry["code"] != "currency_mismatch" { + t.Fatalf("retry code=%v, want currency_mismatch", outRetry["code"]) + } +} + +// eurFakeProvider 是一个测试用的 provider,仅支持 EUR。 +type eurFakeProvider struct{} + +func (p *eurFakeProvider) Method() string { return "fake_eur" } + +func (p *eurFakeProvider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderCryptoAddress}, + SupportsRefund: false, + SettleCurrencies: []string{"EUR"}, + Regions: []string{"global"}, + } +} + +func (p *eurFakeProvider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + exp := time.Now().Add(15 * time.Minute) + ref := "EURK-" + req.OutTradeNo + return &provider.Session{ + ProviderRef: ref, + RenderType: provider.RenderCryptoAddress, + Payload: map[string]any{ + "address": "EFake" + req.Account.AccountID + req.OutTradeNo, + "amount_minor": req.AmountMinor, + "currency": req.Currency, + }, + ExpiresAt: &exp, + }, nil +} + +func (p *eurFakeProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + return nil, errors.New("not implemented") +} + +func (p *eurFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + return nil, errors.New("not implemented") +}