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:
wangjia
2026-07-10 14:27:39 +08:00
parent 585133532c
commit 98b95dde19
3 changed files with 89 additions and 4 deletions
+6 -4
View File
@@ -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"`
+4
View File
@@ -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+"失败,请稍后重试")
+79
View File
@@ -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")
}