fix(v2): 币种错误映射 409/503(不再误导 500 重试)+ DailyLimit 量纲注释
- handler: ErrCurrencyMismatch → 409, ErrNoSettleCurrency → 503 - test: TestV2RetryCurrencyMismatch409 验证重试不同币种渠道返回 409 - config: DailyLimit 添加量纲注释(minor units) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
+6
-4
@@ -27,10 +27,12 @@ type Config struct {
|
||||
// 只存 CredentialEnvPrefix,真值由 internal/accounts.Registry 运行时从环境变量
|
||||
// `<CredentialEnvPrefix>_<KEY>` 读取。
|
||||
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"`
|
||||
|
||||
@@ -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+"失败,请稍后重试")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user