diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go index da5e593..5225e31 100644 --- a/internal/logic/billing/currency.go +++ b/internal/logic/billing/currency.go @@ -3,12 +3,12 @@ package billing import ( "context" - "github.com/shopspring/decimal" lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/shopspring/decimal" ) const defaultCNYToUSD = 0.14 -const defaultUSDToCNY = 7.25 +const defaultUSDToCNY = 7.142857142857143 // 1 / 0.14,确保互为倒数 // ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。 // 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。 @@ -34,12 +34,11 @@ func GetExchangeRateCNYToUSD(ctx context.Context) float64 { return rate } -// GetExchangeRateUSDToCNY 获取 USD→CNY 兑换比例 +// GetExchangeRateUSDToCNY 获取 USD→CNY 兑换比例(取 CNY→USD 的倒数,确保往返闭合) func GetExchangeRateUSDToCNY(ctx context.Context) float64 { - cfg := lcommon.Config() - rate := cfg.GetFloat(ctx, "payment_exchange_rate_usd_to_cny") + rate := GetExchangeRateCNYToUSD(ctx) if rate <= 0 { return defaultUSDToCNY } - return rate + return 1.0 / rate } diff --git a/internal/logic/billing/currency_test.go b/internal/logic/billing/currency_test.go index 8d91180..071fa30 100644 --- a/internal/logic/billing/currency_test.go +++ b/internal/logic/billing/currency_test.go @@ -2,6 +2,9 @@ package billing import ( "testing" + + "github.com/gogf/gf/v2/test/gtest" + "github.com/shopspring/decimal" ) func TestDefaultCNYToUSD(t *testing.T) { @@ -17,7 +20,8 @@ func TestDefaultUSDToCNY(t *testing.T) { } func TestDefaultRates_Reciprocal(t *testing.T) { - // 两个默认汇率不应精确互为倒数(市场汇率有买卖价差),但数量级应一致 + // 修复后:defaultUSDToCNY 不再是独立配置,而是 defaultCNYToUSD 的倒数 + // 验证常量定义互为倒数 product := defaultCNYToUSD * defaultUSDToCNY if product < 0.9 || product > 1.1 { t.Fatalf("default rate product should be ~1.0, got %f", product) @@ -77,13 +81,63 @@ func TestCurrencyConstants_NonZero(t *testing.T) { } } -func TestCurrencyConstants_ReasonableRange(t *testing.T) { - // CNY→USD 应在 0.1~0.2 范围内(合理汇率区间) - if defaultCNYToUSD < 0.05 || defaultCNYToUSD > 0.3 { - t.Errorf("defaultCNYToUSD %f outside reasonable range [0.05, 0.3]", defaultCNYToUSD) +// TestExchangeRateReciprocal 验证 USD→CNY 汇率是 CNY→USD 的倒数(往返闭合) +func TestExchangeRateReciprocal(t *testing.T) { + // 测试默认汇率互为倒数 + reciprocal := 1.0 / defaultCNYToUSD + diff := reciprocal - defaultUSDToCNY + if diff < 0 { + diff = -diff } - // USD→CNY 应在 5~15 范围内 - if defaultUSDToCNY < 5 || defaultUSDToCNY > 15 { - t.Errorf("defaultUSDToCNY %f outside reasonable range [5, 15]", defaultUSDToCNY) + // 允许误差 0.01(因为默认常量可能不完全精确) + if diff > 0.01 { + t.Errorf("默认汇率应互为倒数:1/%.4f = %.4f, 但 defaultUSDToCNY = %.4f, 差值 = %.6f", + defaultCNYToUSD, reciprocal, defaultUSDToCNY, diff) + } +} + +// TestConvertRoundTrip 验证 CNY→USD→CNY 往返转换闭合(误差在可接受范围内) +func TestConvertRoundTrip(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // 测试多个典型金额 + testAmounts := []float64{100.0, 50.5, 1.0, 999.99} + + for _, originalCNY := range testAmounts { + // CNY → USD(使用默认汇率模拟) + usd := decimal.NewFromFloat(originalCNY).Mul(decimal.NewFromFloat(defaultCNYToUSD)) + + // USD → CNY(使用倒数) + cny := usd.Mul(decimal.NewFromFloat(1.0 / defaultCNYToUSD)) + + // 验证往返误差 < 0.01 元(1分钱) + diff := cny.Sub(decimal.NewFromFloat(originalCNY)).Abs() + t.AssertLT(InexactFloat64(diff), 0.01) + } + }) +} + +// TestConvertCNYToUSD_Precision 验证 CNY→USD 转换的精度(使用默认汇率) +func TestConvertCNYToUSD_Precision(t *testing.T) { + // 100 CNY × 0.14 = 14 USD + usd := decimal.NewFromFloat(100.0).Mul(decimal.NewFromFloat(defaultCNYToUSD)) + expected := decimal.NewFromFloat(14.0) + + diff := usd.Sub(expected).Abs() + if InexactFloat64(diff) > 0.000001 { + t.Errorf("100 CNY 应转换为约 14 USD,实际=%s, 误差=%s", usd.String(), diff.String()) + } +} + +// TestConvertUSDToCNY_Precision 验证 USD→CNY 转换的精度(使用倒数) +func TestConvertUSDToCNY_Precision(t *testing.T) { + // 14 USD × (1/0.14) ≈ 100 CNY + usd := decimal.NewFromFloat(14.0) + reciprocalRate := 1.0 / defaultCNYToUSD + cny := usd.Mul(decimal.NewFromFloat(reciprocalRate)) + expected := decimal.NewFromFloat(100.0) + + diff := cny.Sub(expected).Abs() + if InexactFloat64(diff) > 0.01 { + t.Errorf("14 USD 应转换为约 100 CNY,实际=%s, 误差=%s", cny.String(), diff.String()) } } diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 1b93fcf..8c6d11a 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -157,9 +157,7 @@ var Registry = []SettingDef{ {Key: "payment_callback_base_url", Type: SettingTypeString, Default: "", Category: "payment", Label: "支付回调基础URL", Description: "为空则使用请求 Host"}, {Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment", - Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元", Validation: "min:0.001,max:100"}, - {Key: "payment_exchange_rate_usd_to_cny", Type: SettingTypeFloat, Default: "7.25", Category: "payment", - Label: "USD → CNY 兑换比例", Description: "1 美元兑换多少人民币", Validation: "min:0.001,max:1000"}, + Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"}, // ── Performance ── {Key: "global_qps_limit", Type: SettingTypeInt, Default: "10000", Category: "performance", diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 58c34d4..2560092 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -1,19 +1,22 @@ @@ -46,30 +49,19 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {