feat:统一汇率转换方向
This commit is contained in:
1 parent
c0a0d9b7ac
commit
44424dfb67
4 files changed
+92
-49
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user