feat:统一汇率转换方向

This commit is contained in:
qian committed 2026-07-23 23:06:40 +08:00
1 parent c0a0d9b7ac
commit 44424dfb67
4 files changed
+92 -49

No files matched your search

+5 -6
View File
@@ -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
}
+62 -8
View File
@@ -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())
}
}
+1 -3
View File
@@ -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",
@@ -1,19 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFormValues } from './useSettings'
const values = useFormValues()
function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
if (direction === 'cny_to_usd') {
const usdToCny = Number(values['payment_exchange_rate_usd_to_cny'])
if (usdToCny > 0) {
values['payment_exchange_rate_cny_to_usd'] = Math.round((1 / usdToCny) * 10000) / 10000
}
} else {
const cnyToUsd = Number(values['payment_exchange_rate_cny_to_usd'])
if (cnyToUsd > 0) {
values['payment_exchange_rate_usd_to_cny'] = Math.round((1 / cnyToUsd) * 10000) / 10000
}
// 计算 CNY→USD(直接读取存储值)
const computedCnyToUsd = computed(() => {
const cnyToUsd = Number(values['payment_exchange_rate_cny_to_usd'])
return cnyToUsd > 0 ? cnyToUsd : 0
})
// 同步 USD→CNY 到底层存储的 CNY→USD
function updateCnyToUsd(usdToCny: number | undefined) {
if (!usdToCny || usdToCny <= 0) {
values['payment_exchange_rate_cny_to_usd'] = 0
return
}
// 存储倒数(CNY→USD = 1 / USD→CNY)
values['payment_exchange_rate_cny_to_usd'] = 1 / usdToCny
}
</script>
@@ -46,30 +49,19 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
<div class="section">
<div class="section-title">货币兑换</div>
<div class="section-grid">
<AFormItem label="CNY → USD" help="1 人民币兑换多少美元">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<AInputNumber
:model-value="values['payment_exchange_rate_cny_to_usd'] as number"
@change="(v: number | undefined) => values['payment_exchange_rate_cny_to_usd'] = v ?? 0"
:min="0.001" :max="100" :step="0.01" :precision="4"
style="flex: 1"
/>
<AButton type="outline" size="small" @click="syncRate('cny_to_usd')">同步</AButton>
</div>
<AFormItem label="USD → CNY" help="1 美元兑换多少人民币(常用汇率约 7.0~7.3)" class="field-full">
<AInputNumber
:model-value="(1 / (values['payment_exchange_rate_cny_to_usd'] as number || 0.14)) as number"
@change="updateCnyToUsd"
:min="0.01" :max="1000" :step="0.01" :precision="4"
style="width: 100%"
/>
</AFormItem>
<AFormItem label="USD → CNY" help="1 美元兑换多少人民币">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<AInputNumber
:model-value="values['payment_exchange_rate_usd_to_cny'] as number"
@change="(v: number | undefined) => values['payment_exchange_rate_usd_to_cny'] = v ?? 0"
:min="0.001" :max="1000" :step="0.01" :precision="4"
style="flex: 1"
/>
<AButton type="outline" size="small" @click="syncRate('usd_to_cny')">同步</AButton>
</div>
<AFormItem label="CNY → USD(自动计算)" help="取上方汇率的倒数,确保往返闭合" class="field-full">
<AInput :model-value="computedCnyToUsd.toFixed(6)" disabled style="width: 100%" />
</AFormItem>
<div class="field-full" style="font-size: 12px; color: var(--color-text-3); margin-top: -8px">
点击「同步」可根据另一方向的汇率自动计算当前值(取倒数)
💡 系统以 USD→CNY 为输入(更符合习惯),内部自动取倒数存储,确保往返换算闭合
</div>
</div>
</div>