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,真值由 internal/accounts.Registry 运行时从环境变量
|
||||||
// `<CredentialEnvPrefix>_<KEY>` 读取。
|
// `<CredentialEnvPrefix>_<KEY>` 读取。
|
||||||
type AccountConfig struct {
|
type AccountConfig struct {
|
||||||
AccountID string `mapstructure:"account_id"`
|
AccountID string `mapstructure:"account_id"`
|
||||||
Channel string `mapstructure:"channel"`
|
Channel string `mapstructure:"channel"`
|
||||||
Weight int `mapstructure:"weight"`
|
Weight int `mapstructure:"weight"`
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
// DailyLimit 单位为该账户所属渠道结算币种的最小单位(minor units)——渠道币种由 Capabilities().SettleCurrencies[0] 决定;
|
||||||
|
// 跨币种账户的限额刻度互不可比,运营配置时按各自币种填。
|
||||||
DailyLimit int64 `mapstructure:"daily_limit"`
|
DailyLimit int64 `mapstructure:"daily_limit"`
|
||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region"`
|
||||||
Subject string `mapstructure:"subject"`
|
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", "不支持的支付方式")
|
util.RespondError(c, http.StatusBadRequest, "unknown_method", "不支持的支付方式")
|
||||||
case errors.Is(err, gateway.ErrNoAccount):
|
case errors.Is(err, gateway.ErrNoAccount):
|
||||||
util.RespondError(c, http.StatusServiceUnavailable, "no_account", "该支付方式暂不可用")
|
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:
|
default:
|
||||||
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
|
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
|
||||||
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
|
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package handler_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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)
|
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