Skip to content

Commit 0c059e8

Browse files
authored
Merge pull request #50 from GMWalletApp/dev
2 parents 143bc84 + 485059f commit 0c059e8

182 files changed

Lines changed: 509 additions & 241 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/bootstrap/bootstrap.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func InitApp() {
2222
log.Init()
2323
dao.Init()
2424
// Wire settings-table lookups into the config package so
25-
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides.
25+
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
2626
config.SettingsGetString = func(key string) string {
2727
return data.GetSettingString(key, "")
2828
}
@@ -36,9 +36,6 @@ func InitApp() {
3636
}
3737
}
3838
}
39-
// config.Init() computes RateApiUrl before SettingsGetString is
40-
// installed, so refresh the cache once DB-backed settings are available.
41-
config.RateApiUrl = config.GetRateApiUrl()
4239
// Seed admin account and JWT secret so the management console is
4340
// immediately usable on a fresh install. Both are idempotent.
4441
_, isNew, err := data.EnsureDefaultAdmin()

src/config/config.go

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ var (
3434
TgBotToken string
3535
TgProxy string
3636
TgManage int64
37-
UsdtRate float64
38-
RateApiUrl string
3937
BuildVersion = "0.0.0-dev"
4038
BuildCommit = "none"
4139
BuildDate = "unknown"
@@ -76,8 +74,6 @@ func Init() {
7674
TgBotToken = viper.GetString("tg_bot_token")
7775
TgProxy = viper.GetString("tg_proxy")
7876
TgManage = viper.GetInt64("tg_manage")
79-
80-
RateApiUrl = GetRateApiUrl()
8177
}
8278

8379
func mustMkdir(path string) {
@@ -275,13 +271,14 @@ func GetRateForCoin(coin string, base string) float64 {
275271
if usdtRate > 0 {
276272
return 1 / usdtRate
277273
}
274+
return 0
278275
}
279276
}
277+
return getRateForCoinFromAPI(coin, base)
278+
}
280279

281-
baseURL := RateApiUrl
282-
if baseURL == "" {
283-
baseURL = GetRateApiUrl()
284-
}
280+
func getRateForCoinFromAPI(coin string, base string) float64 {
281+
baseURL := GetRateApiUrl()
285282
if baseURL == "" {
286283
log.Printf("rate api url is empty")
287284
return 0.0
@@ -313,19 +310,18 @@ func GetRateForCoin(coin string, base string) float64 {
313310
}
314311

315312
func GetUsdtRate() float64 {
316-
// Prefer the DB-backed override (admin-configurable). Fall back to
317-
// the legacy .env var, then the hardcoded 6.4 default.
313+
// Only the admin setting can force the USDT/CNY rate. When the
314+
// setting is unset, zero, or negative, fall back to the rate API.
318315
if forced := settingsForcedUsdtRate(); forced > 0 {
319316
return forced
320317
}
321-
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
322-
if forcedUsdtRate > 0 {
323-
return forcedUsdtRate
324-
}
325-
if UsdtRate <= 0 {
326-
return 6.4
318+
319+
apiRate := getRateForCoinFromAPI("usdt", "cny")
320+
if apiRate > 0 {
321+
return 1 / apiRate
327322
}
328-
return UsdtRate
323+
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
324+
return 0
329325
}
330326

331327
func GetOrderExpirationTime() int {

src/config/config_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,52 @@
11
package config
22

33
import (
4+
"io"
5+
"math"
6+
"net/http"
47
"os"
58
"path/filepath"
9+
"strings"
610
"testing"
11+
"time"
12+
13+
"github.com/GMWalletApp/epusdt/util/http_client"
14+
"github.com/go-resty/resty/v2"
15+
"github.com/spf13/viper"
716
)
817

18+
func installSettingsGetter(t *testing.T, values map[string]string) {
19+
t.Helper()
20+
21+
oldGetter := SettingsGetString
22+
SettingsGetString = func(key string) string {
23+
return values[key]
24+
}
25+
t.Cleanup(func() {
26+
SettingsGetString = oldGetter
27+
})
28+
}
29+
30+
type roundTripFunc func(*http.Request) (*http.Response, error)
31+
32+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
33+
return f(req)
34+
}
35+
36+
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
37+
t.Helper()
38+
39+
oldFactory := http_client.ClientFactory
40+
http_client.ClientFactory = func() *resty.Client {
41+
client := resty.NewWithClient(&http.Client{Transport: handler})
42+
client.SetTimeout(10 * time.Second)
43+
return client
44+
}
45+
t.Cleanup(func() {
46+
http_client.ClientFactory = oldFactory
47+
})
48+
}
49+
950
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
1051
t.Helper()
1152

@@ -135,3 +176,127 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
135176
t.Fatalf("config path = %s, want %s", got, flagPath)
136177
}
137178
}
179+
180+
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
181+
viper.Reset()
182+
t.Cleanup(viper.Reset)
183+
t.Setenv("API_RATE_URL", "")
184+
185+
apiCalled := false
186+
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
187+
apiCalled = true
188+
return &http.Response{
189+
StatusCode: http.StatusInternalServerError,
190+
Status: http.StatusText(http.StatusInternalServerError),
191+
Header: make(http.Header),
192+
Body: io.NopCloser(strings.NewReader("")),
193+
Request: r,
194+
}, nil
195+
})
196+
197+
installSettingsGetter(t, map[string]string{
198+
"rate.forced_usdt_rate": "7.25",
199+
"rate.api_url": "https://rate.example.test",
200+
})
201+
202+
got := GetUsdtRate()
203+
if got != 7.25 {
204+
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
205+
}
206+
if apiCalled {
207+
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
208+
}
209+
}
210+
211+
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
212+
viper.Reset()
213+
t.Cleanup(viper.Reset)
214+
t.Setenv("API_RATE_URL", "")
215+
216+
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
217+
if r.URL.Path != "/cny.json" {
218+
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
219+
}
220+
return &http.Response{
221+
StatusCode: http.StatusOK,
222+
Status: "200 OK",
223+
Header: http.Header{"Content-Type": []string{"application/json"}},
224+
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
225+
Request: r,
226+
}, nil
227+
})
228+
229+
installSettingsGetter(t, map[string]string{
230+
"rate.forced_usdt_rate": "-1",
231+
"rate.api_url": "https://rate.example.test",
232+
})
233+
234+
got := GetUsdtRate()
235+
want := 1 / 0.14635
236+
if math.Abs(got-want) > 1e-9 {
237+
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
238+
}
239+
240+
rate := GetRateForCoin("usdt", "cny")
241+
if math.Abs(rate-0.14635) > 1e-9 {
242+
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
243+
}
244+
}
245+
246+
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
247+
viper.Reset()
248+
t.Cleanup(viper.Reset)
249+
t.Setenv("API_RATE_URL", "")
250+
251+
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
252+
return &http.Response{
253+
StatusCode: http.StatusBadGateway,
254+
Status: "502 Bad Gateway",
255+
Header: make(http.Header),
256+
Body: io.NopCloser(strings.NewReader("")),
257+
Request: r,
258+
}, nil
259+
})
260+
261+
installSettingsGetter(t, map[string]string{
262+
"rate.forced_usdt_rate": "0",
263+
"rate.api_url": "https://rate.example.test",
264+
})
265+
266+
if got := GetUsdtRate(); got != 0 {
267+
t.Fatalf("GetUsdtRate() = %v, want 0", got)
268+
}
269+
if got := GetRateForCoin("usdt", "cny"); got != 0 {
270+
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
271+
}
272+
}
273+
274+
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
275+
viper.Reset()
276+
t.Cleanup(viper.Reset)
277+
t.Setenv("API_RATE_URL", "")
278+
279+
callCount := 0
280+
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
281+
callCount++
282+
return &http.Response{
283+
StatusCode: http.StatusBadGateway,
284+
Status: "502 Bad Gateway",
285+
Header: make(http.Header),
286+
Body: io.NopCloser(strings.NewReader("")),
287+
Request: r,
288+
}, nil
289+
})
290+
291+
installSettingsGetter(t, map[string]string{
292+
"rate.forced_usdt_rate": "0",
293+
"rate.api_url": "https://rate.example.test",
294+
})
295+
296+
if got := GetRateForCoin("usdt", "cny"); got != 0 {
297+
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
298+
}
299+
if callCount != 1 {
300+
t.Fatalf("rate api call count = %d, want 1", callCount)
301+
}
302+
}

src/config/settings_bridge.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ package config
55
// create a cycle via `model/dao -> config`.
66
//
77
// If unset (e.g. during early startup or tests) the getters return the
8-
// zero string / 0 and callers fall through to the .env / default path.
8+
// zero string / 0 and callers apply their own fallback behavior.
99

1010
// SettingsGetString is installed by bootstrap.Init with a closure that
1111
// reads from the settings table. Runtime-only.

src/controller/admin/settings_controller.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414
// Supported groups and keys:
1515
//
1616
// - group=rate:
17-
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api)
18-
// rate.api_url (string) — external rate API URL
17+
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
18+
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
1919
// rate.adjust_percent (float) — rate adjustment percentage
2020
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
2121
//
@@ -73,7 +73,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
7373
// @Description Batch insert/update settings. Returns per-key status.
7474
// @Description Supported groups: brand, rate, system, epay.
7575
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
76-
// @Description rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
76+
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
7777
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
7878
// @Description system group keys: system.order_expiration_time.
7979
// @Tags Admin Settings

src/internal/testutil/testdb.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ func SetupTestDatabases(t testing.TB) func() {
2020
t.Helper()
2121

2222
viper.Reset()
23-
viper.Set("forced_usdt_rate", 1.0)
2423
viper.Set("app_uri", "https://example.com")
2524
viper.Set("order_expiration_time", 10)
2625
viper.Set("order_notice_max_retry", 2)
@@ -31,7 +30,6 @@ func SetupTestDatabases(t testing.TB) func() {
3130
config.HTTPAccessLog = false
3231
config.SQLDebug = false
3332
config.LogLevel = "error"
34-
config.UsdtRate = 0
3533
appLog.Sugar = zap.NewNop().Sugar()
3634

3735
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
@@ -52,6 +50,16 @@ func SetupTestDatabases(t testing.TB) func() {
5250

5351
dao.Mdb = mainDB
5452
dao.RuntimeDB = runtimeDB
53+
config.SettingsGetString = func(key string) string {
54+
if dao.Mdb == nil {
55+
return ""
56+
}
57+
var row mdb.Setting
58+
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
59+
return ""
60+
}
61+
return row.Value
62+
}
5563

5664
// Seed all standard chains as enabled so IsChainEnabled checks pass.
5765
for _, network := range []string{
@@ -74,12 +82,21 @@ func SetupTestDatabases(t testing.TB) func() {
7482
Pid: "1001", SecretKey: "test-token",
7583
Status: mdb.ApiKeyStatusEnable,
7684
})
85+
if err := dao.Mdb.Create(&mdb.Setting{
86+
Group: "rate",
87+
Key: "rate.forced_usdt_rate",
88+
Value: "1.0",
89+
Type: "string",
90+
}).Error; err != nil {
91+
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
92+
}
7793

7894
return func() {
7995
closeDB(t, runtimeDB)
8096
closeDB(t, mainDB)
8197
dao.Mdb = nil
8298
dao.RuntimeDB = nil
99+
config.SettingsGetString = nil
83100
viper.Reset()
84101
}
85102
}

0 commit comments

Comments
 (0)