feat(rate): support multi-currency forced rate list

This commit is contained in:
line-6000
2026-05-22 23:13:21 +08:00
parent dec1b97b9e
commit 8a48611625
285 changed files with 2145 additions and 354 deletions
+20 -23
View File
@@ -8,7 +8,6 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -17,12 +16,6 @@ import (
"github.com/tidwall/gjson"
)
// parseFloat is the minimal helper used by the settings bridge; kept
// here so settings_bridge.go stays import-free.
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
var (
HTTPAccessLog bool
SQLDebug bool
@@ -353,21 +346,25 @@ func GetRateForCoin(coin string, base string) float64 {
if coin == base {
return 1
}
if coin == "usdt" {
switch base {
case "usd":
return 1
case "cny":
usdtRate := GetUsdtRate()
if usdtRate > 0 {
return 1 / usdtRate
}
return 0
}
if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
return forcedRate
}
if coin == "usdt" && base == "usd" {
return 1
}
return getRateForCoinFromAPI(coin, base)
}
func getForcedRateForCoin(coin string, base string) float64 {
raw := strings.TrimSpace(settingsForcedRateList())
if raw != "" {
if rate := gjson.Get(raw, base+"."+coin).Float(); rate > 0 {
return rate
}
}
return 0
}
func getRateForCoinFromAPI(coin string, base string) float64 {
baseURL := GetRateApiUrl()
if baseURL == "" {
@@ -401,17 +398,17 @@ func getRateForCoinFromAPI(coin string, base string) float64 {
}
func GetUsdtRate() float64 {
// Only the admin setting can force the USDT/CNY rate. When the
// setting is unset, zero, or negative, fall back to the rate API.
if forced := settingsForcedUsdtRate(); forced > 0 {
return forced
// rate.forced_rate_list stores token amount per one base currency unit.
// GetUsdtRate returns the inverse display value: CNY per USDT.
if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
return 1 / forcedRate
}
apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 {
return 1 / apiRate
}
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
return 0
}
+39 -7
View File
@@ -231,7 +231,7 @@ func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
}
}
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
@@ -249,16 +249,19 @@ func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "7.25",
"rate.forced_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
if got != 7.25 {
if math.Abs(got-7.25) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
}
if rate := GetRateForCoin("trx", "usd"); rate != 123.45 {
t.Fatalf("GetRateForCoin(trx, usd) = %v, want 123.45", rate)
}
if apiCalled {
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
}
}
@@ -281,7 +284,7 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "-1",
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test",
})
@@ -297,6 +300,35 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
}
}
func TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/cny.json" {
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_rate_list": `{"usd":{"trx":123.45}}`,
"rate.api_url": "https://rate.example.test",
})
rate := GetRateForCoin("usdt", "cny")
if math.Abs(rate-0.14635) > 1e-9 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
}
}
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
@@ -313,7 +345,7 @@ func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test",
})
@@ -343,7 +375,7 @@ func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test",
})
+3 -11
View File
@@ -18,17 +18,9 @@ func settingsRateApiUrl() string {
return SettingsGetString("rate.api_url")
}
func settingsForcedUsdtRate() float64 {
func settingsForcedRateList() string {
if SettingsGetString == nil {
return 0
return ""
}
raw := SettingsGetString("rate.forced_usdt_rate")
if raw == "" {
return 0
}
f, err := parseFloat(raw)
if err != nil {
return 0
}
return f
return SettingsGetString("rate.forced_rate_list")
}