21 Commits

Author SHA1 Message Date
line-6000 08d409d5eb feat: add in-memory RPC runtime stats SSE endpoint 2026-06-10 00:18:24 +08:00
line-6000 54b83f8b16 Update README with security audit and screenshots
Added security audit section and program screenshots to README.
2026-06-03 21:43:17 +08:00
line-6000 3879853836 Add files 2026-06-03 21:28:24 +08:00
line-6000 2824687d9f Merge pull request #85 from GMWalletApp/dev
feat(www): enable RPC node create and edit actions
2026-06-03 21:01:05 +08:00
line-6000 3e85bf77c2 update www 2026-06-03 20:58:57 +08:00
line-6000 cd44c7c818 Merge pull request #82 from GMWalletApp/dev
Dev
2026-05-27 19:41:55 +08:00
line-6000 c69b0d6c0c feat: isolate manual verification RPC usage
- add rpc node purpose support for general/manual_verify/both
- keep scan, WSS and health checks away from manual_verify nodes
- fallback manual hash verification from general RPC to manual_verify RPC
- add public cashier hash submission path and errno responses
- update tests for RPC selection, failover and manual payment flows
2026-05-27 19:36:56 +08:00
line-6000 13c81ee560 feat(order): use crypto-random url-safe trade IDs and update docs examples 2026-05-27 02:59:25 +08:00
line-6000 c38f728e10 update www 2026-05-27 02:29:20 +08:00
line-6000 b70995f8e9 fix: aggregate dashboard trends by database dialect 2026-05-27 02:06:31 +08:00
line-6000 fca75a99ea feat(payment): allow cashier manual tx hash submission 2026-05-26 23:16:10 +08:00
line-6000 8bf2adad89 fix: reject private callback and rate API URLs 2026-05-26 20:54:41 +08:00
Astra3456 0d97237c71 Update supported systems in README 2026-05-26 00:20:53 +10:00
line-6000 c65b07cfe7 Merge pull request #81 from GMWalletApp/dev
docs: update payment API integration guide
2026-05-23 17:24:44 +08:00
line-6000 7fcb7b38fe docs: update payment API integration guide 2026-05-23 17:23:41 +08:00
line-6000 2a00a0332b Merge pull request #80 from GMWalletApp/dev
Dev
2026-05-22 23:14:35 +08:00
line-6000 8a48611625 feat(rate): support multi-currency forced rate list 2026-05-22 23:13:21 +08:00
line-6000 dec1b97b9e fix: retry Solana signatures after transient processing errors
Avoid marking a Solana signature as processed when order matching or
OrderProcessing fails with a retryable error. Keep terminal outcomes
idempotent while allowing later scans to retry transient failures.
2026-05-22 16:37:50 +08:00
line-6000 449d22a41b fix: send OkPay payment success notifications 2026-05-22 16:25:23 +08:00
line-6000 4b4701f5b9 fix: skip stale EVM ERC20 transfers before order creation 2026-05-22 15:13:09 +08:00
line-6000 f4ef71b359 fix: accept bare admin JWT authorization header
Allow the admin JWT middleware to parse either Authorization: Bearer <jwt>
or a bare JWT value, matching the existing comment. Add tests covering both
accepted forms plus missing and empty authorization headers.
2026-05-22 15:03:59 +08:00
351 changed files with 7357 additions and 967 deletions
+32 -1
View File
@@ -51,13 +51,19 @@
--- ---
## 安全审计
Epusdt 已完成第三方安全审计。
[查看安全审计报告](https://github.com/VectorBits/audit/blob/main/epusdt-secure-audit-report-2026-05-14.pdf)
---
## 广泛兼容,即插即用 ## 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景: 无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
| 领域 | 已支持系统 | | 领域 | 已支持系统 |
|------|-----------| |------|-----------|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) | | **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) | | **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) | | **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) | | **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
@@ -104,6 +110,31 @@ Epusdt
--- ---
## 程序截图
<table>
<tr>
<td align="center" valign="top">
<img src="wiki/img/web2.png" alt="Epusdt 管理面板首页" height="260"><br>
<sub>管理面板首页</sub>
</td>
<td align="center" valign="top">
<img src="wiki/img/web1.png" alt="Epusdt 管理面板" height="260"><br>
<sub>管理面板</sub>
</td>
<td align="center" valign="top">
<img src="wiki/img/pay1.jpeg" alt="Epusdt 收银台" height="260"><br>
<sub>收银台</sub>
</td>
<td align="center" valign="top">
<img src="wiki/img/pay2.jpeg" alt="Epusdt 支付页面" height="260"><br>
<sub>支付页面</sub>
</td>
</tr>
</table>
---
## 实现原理 ## 实现原理
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
+19 -22
View File
@@ -8,7 +8,6 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -17,12 +16,6 @@ import (
"github.com/tidwall/gjson" "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 ( var (
HTTPAccessLog bool HTTPAccessLog bool
SQLDebug bool SQLDebug bool
@@ -353,21 +346,25 @@ func GetRateForCoin(coin string, base string) float64 {
if coin == base { if coin == base {
return 1 return 1
} }
if coin == "usdt" { if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
switch base { return forcedRate
case "usd": }
if coin == "usdt" && base == "usd" {
return 1 return 1
case "cny":
usdtRate := GetUsdtRate()
if usdtRate > 0 {
return 1 / usdtRate
}
return 0
}
} }
return getRateForCoinFromAPI(coin, base) 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 { func getRateForCoinFromAPI(coin string, base string) float64 {
baseURL := GetRateApiUrl() baseURL := GetRateApiUrl()
if baseURL == "" { if baseURL == "" {
@@ -401,17 +398,17 @@ func getRateForCoinFromAPI(coin string, base string) float64 {
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
// Only the admin setting can force the USDT/CNY rate. When the // rate.forced_rate_list stores token amount per one base currency unit.
// setting is unset, zero, or negative, fall back to the rate API. // GetUsdtRate returns the inverse display value: CNY per USDT.
if forced := settingsForcedUsdtRate(); forced > 0 { if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
return forced return 1 / forcedRate
} }
apiRate := getRateForCoinFromAPI("usdt", "cny") apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 { if apiRate > 0 {
return 1 / apiRate 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 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() viper.Reset()
t.Cleanup(viper.Reset) t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "") t.Setenv("API_RATE_URL", "")
@@ -249,16 +249,19 @@ func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
}) })
installSettingsGetter(t, map[string]string{ 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", "rate.api_url": "https://rate.example.test",
}) })
got := GetUsdtRate() got := GetUsdtRate()
if got != 7.25 { if math.Abs(got-7.25) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got) 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 { 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{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "-1", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "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) { func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset() viper.Reset()
t.Cleanup(viper.Reset) t.Cleanup(viper.Reset)
@@ -313,7 +345,7 @@ func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
@@ -343,7 +375,7 @@ func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
+3 -11
View File
@@ -18,17 +18,9 @@ func settingsRateApiUrl() string {
return SettingsGetString("rate.api_url") return SettingsGetString("rate.api_url")
} }
func settingsForcedUsdtRate() float64 { func settingsForcedRateList() string {
if SettingsGetString == nil { if SettingsGetString == nil {
return 0 return ""
} }
raw := SettingsGetString("rate.forced_usdt_rate") return SettingsGetString("rate.forced_rate_list")
if raw == "" {
return 0
}
f, err := parseFloat(raw)
if err != nil {
return 0
}
return f
} }
+12 -12
View File
@@ -3,12 +3,12 @@ package admin
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"strconv" "strconv"
"strings" "strings"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -79,7 +79,7 @@ func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error { func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
req := new(CreateApiKeyRequest) req := new(CreateApiKeyRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -150,11 +150,11 @@ func isUniqueViolation(err error) bool {
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error { func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateApiKeyRequest) req := new(UpdateApiKeyRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Name != nil { if req.Name != nil {
@@ -187,11 +187,11 @@ func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error { func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(ChangeApiKeyStatusRequest) req := new(ChangeApiKeyStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -215,7 +215,7 @@ func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error { func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
secret := randomHex(32) secret := randomHex(32)
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil { if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
@@ -239,7 +239,7 @@ func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error { func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteApiKeyByID(id); err != nil { if err := data.DeleteApiKeyByID(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -262,14 +262,14 @@ func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error { func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
row, err := data.GetApiKeyByID(id) row, err := data.GetApiKeyByID(id)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if row.ID == 0 { if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found")) return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
} }
return c.SucJson(ctx, map[string]interface{}{ return c.SucJson(ctx, map[string]interface{}{
"call_count": row.CallCount, "call_count": row.CallCount,
@@ -291,14 +291,14 @@ func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error { func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
row, err := data.GetApiKeyByID(id) row, err := data.GetApiKeyByID(id)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if row.ID == 0 { if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found")) return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
} }
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey}) return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
} }
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
appjwt "github.com/GMWalletApp/epusdt/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -53,7 +54,7 @@ type InitialPasswordResponse struct {
func (c *BaseAdminController) Login(ctx echo.Context) error { func (c *BaseAdminController) Login(ctx echo.Context) error {
req := new(LoginRequest) req := new(LoginRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -63,10 +64,10 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) { if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
return c.FailJson(ctx, errors.New("invalid username or password")) return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
} }
if user.Status != mdb.AdminUserStatusEnable { if user.Status != mdb.AdminUserStatusEnable {
return c.FailJson(ctx, errors.New("user disabled")) return c.FailJson(ctx, constant.AdminUserDisabledErr)
} }
token, err := appjwt.Sign(user.ID, user.Username) token, err := appjwt.Sign(user.ID, user.Username)
if err != nil { if err != nil {
@@ -94,6 +95,9 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error { func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword() password, err := data.ConsumeInitialAdminPassword()
if err != nil { if err != nil {
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
}
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
return c.SucJson(ctx, InitialPasswordResponse{ return c.SucJson(ctx, InitialPasswordResponse{
@@ -144,14 +148,14 @@ func (c *BaseAdminController) Logout(ctx echo.Context) error {
func (c *BaseAdminController) Me(ctx echo.Context) error { func (c *BaseAdminController) Me(ctx echo.Context) error {
uid := currentAdminUserID(ctx) uid := currentAdminUserID(ctx)
if uid == 0 { if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized")) return c.FailJson(ctx, constant.AdminUnauthorizedErr)
} }
user, err := data.GetAdminUserByID(uid) user, err := data.GetAdminUserByID(uid)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if user.ID == 0 { if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found")) return c.FailJson(ctx, constant.AdminUserNotFoundErr)
} }
// Warn the frontend when the operator hasn't changed the seeded // Warn the frontend when the operator hasn't changed the seeded
// initial password. // initial password.
@@ -176,11 +180,11 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error { func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
uid := currentAdminUserID(ctx) uid := currentAdminUserID(ctx)
if uid == 0 { if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized")) return c.FailJson(ctx, constant.AdminUnauthorizedErr)
} }
req := new(ChangePasswordRequest) req := new(ChangePasswordRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -190,7 +194,7 @@ func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) { if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
return c.FailJson(ctx, errors.New("old password incorrect")) return c.FailJson(ctx, constant.AdminOldPasswordErr)
} }
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil { if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
+2 -1
View File
@@ -2,6 +2,7 @@ package admin
import ( import (
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -49,7 +50,7 @@ func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
network := ctx.Param("network") network := ctx.Param("network")
req := new(UpdateChainRequest) req := new(UpdateChainRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Enabled != nil { if req.Enabled != nil {
@@ -6,6 +6,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -65,7 +66,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error { func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
req := new(CreateChainTokenRequest) req := new(CreateChainTokenRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -107,11 +108,11 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error { func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateChainTokenRequest) req := new(UpdateChainTokenRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.ContractAddress != nil { if req.ContractAddress != nil {
@@ -147,11 +148,11 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error { func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(ChangeChainTokenStatusRequest) req := new(ChangeChainTokenStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil { if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -172,7 +173,7 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error { func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteChainTokenByID(id); err != nil { if err := data.DeleteChainTokenByID(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
+218 -2
View File
@@ -1,6 +1,9 @@
package admin package admin
import ( import (
"encoding/json"
"fmt"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -29,6 +32,49 @@ type OrderStatsResponse struct {
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"` ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
} }
// RpcStatsDashboardResponse is the RPC runtime stats payload.
type RpcStatsDashboardResponse struct {
GeneratedAt string `json:"generated_at" example:"2026-06-09T12:00:00+08:00"`
Summary RpcStatsSummaryResponse `json:"summary"`
Chains []RpcStatsChainResponse `json:"chains"`
}
// RpcStatsSummaryResponse is the overall RPC runtime summary.
type RpcStatsSummaryResponse struct {
TotalChains int `json:"total_chains" example:"6"`
EnabledChains int `json:"enabled_chains" example:"5"`
ActiveChains int `json:"active_chains" example:"4"`
OkChains int `json:"ok_chains" example:"3"`
WarningChains int `json:"warning_chains" example:"1"`
DownChains int `json:"down_chains" example:"0"`
UnknownChains int `json:"unknown_chains" example:"1"`
DisabledChains int `json:"disabled_chains" example:"1"`
SuccessCount int64 `json:"success_count" example:"123"`
FailureCount int64 `json:"failure_count" example:"2"`
SuccessRate float64 `json:"success_rate" example:"0.984"`
}
// RpcStatsChainResponse is one chain's in-process RPC runtime stats.
type RpcStatsChainResponse struct {
Network string `json:"network" example:"tron"`
DisplayName string `json:"display_name" example:"TRON"`
Enabled bool `json:"enabled" example:"true"`
HasData bool `json:"has_data" example:"true"`
Status string `json:"status" example:"ok" enums:"ok,warning,down,unknown,disabled"`
SuccessCount int64 `json:"success_count" example:"123"`
FailureCount int64 `json:"failure_count" example:"2"`
SuccessRate float64 `json:"success_rate" example:"0.984"`
LatestBlockHeight int64 `json:"latest_block_height" example:"64532100"`
LastSyncAt string `json:"last_sync_at" example:"2026-06-09T12:00:00+08:00"`
}
type rpcStatsStreamEnvelope struct {
StatusCode int `json:"status_code"`
Message string `json:"message"`
Data RpcStatsDashboardResponse `json:"data"`
RequestID string `json:"request_id"`
}
// RpcHealthCheckResponse is the response for a health check probe. // RpcHealthCheckResponse is the response for a health check probe.
type RpcHealthCheckResponse struct { type RpcHealthCheckResponse struct {
// 健康状态 unknown=未知 ok=正常 down=异常 // 健康状态 unknown=未知 ok=正常 down=异常
@@ -51,14 +97,16 @@ type RotateSecretResponse struct {
type BatchImportResult struct { type BatchImportResult struct {
Address string `json:"address" example:"TTestTronAddress001"` Address string `json:"address" example:"TTestTronAddress001"`
OK bool `json:"ok" example:"true"` OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""` ErrorCode int `json:"error_code,omitempty" example:"10001"`
Error string `json:"error,omitempty" example:"wallet address already exists"`
} }
// SettingsUpsertResult is one row in the settings upsert response. // SettingsUpsertResult is one row in the settings upsert response.
type SettingsUpsertResult struct { type SettingsUpsertResult struct {
Key string `json:"key" example:"rate.api_url"` Key string `json:"key" example:"rate.api_url"`
OK bool `json:"ok" example:"true"` OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""` ErrorCode int `json:"error_code,omitempty" example:"10043"`
Error string `json:"error,omitempty" example:"invalid setting item"`
} }
// Overview returns the top-card numbers for the dashboard landing page. // Overview returns the top-card numbers for the dashboard landing page.
@@ -228,6 +276,174 @@ func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
}) })
} }
// RpcStats returns process-local RPC runtime counters grouped by chain.
// Values reset on process restart and are not persisted to the database.
// @Summary RPC runtime statistics
// @Description Streams in-process RPC success/failure counts, success rate, latest block height and last sync time by chain over one authenticated SSE connection.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce text/event-stream
// @Success 200 {object} response.ApiResponse{data=admin.RpcStatsDashboardResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/rpc-stats [get]
func (c *BaseAdminController) RpcStats(ctx echo.Context) error {
return c.streamRpcStats(ctx)
}
func buildRpcStatsResponse() (RpcStatsDashboardResponse, error) {
chains, err := data.ListChains()
if err != nil {
return RpcStatsDashboardResponse{}, err
}
stats := data.SnapshotRpcRuntimeStats()
now := time.Now()
resp := RpcStatsDashboardResponse{
GeneratedAt: now.Format(time.RFC3339),
Chains: make([]RpcStatsChainResponse, 0, len(chains)),
}
for _, chain := range chains {
network := strings.ToLower(strings.TrimSpace(chain.Network))
stat := stats[network]
successRate := rpcStatsSuccessRate(stat.SuccessCount, stat.FailureCount)
hasData := stat.SuccessCount+stat.FailureCount > 0
status := rpcStatsStatus(chain.Enabled, stat.SuccessCount, stat.FailureCount)
lastSyncAt := ""
if !stat.LastSyncAt.IsZero() {
lastSyncAt = stat.LastSyncAt.Format(time.RFC3339)
}
resp.Summary.TotalChains++
if chain.Enabled {
resp.Summary.EnabledChains++
}
if hasData {
resp.Summary.ActiveChains++
}
switch status {
case "ok":
resp.Summary.OkChains++
case "warning":
resp.Summary.WarningChains++
case "down":
resp.Summary.DownChains++
case "disabled":
resp.Summary.DisabledChains++
default:
resp.Summary.UnknownChains++
}
resp.Summary.SuccessCount += stat.SuccessCount
resp.Summary.FailureCount += stat.FailureCount
resp.Chains = append(resp.Chains, RpcStatsChainResponse{
Network: chain.Network,
DisplayName: rpcStatsDisplayName(chain.Network, chain.DisplayName),
Enabled: chain.Enabled,
HasData: hasData,
Status: status,
SuccessCount: stat.SuccessCount,
FailureCount: stat.FailureCount,
SuccessRate: successRate,
LatestBlockHeight: stat.LatestBlockHeight,
LastSyncAt: lastSyncAt,
})
}
resp.Summary.SuccessRate = rpcStatsSuccessRate(resp.Summary.SuccessCount, resp.Summary.FailureCount)
return resp, nil
}
func (c *BaseAdminController) streamRpcStats(ctx echo.Context) error {
initial, err := buildRpcStatsResponse()
if err != nil {
return c.FailJson(ctx, err)
}
resp := ctx.Response()
resp.Header().Set(echo.HeaderContentType, "text/event-stream; charset=utf-8")
resp.Header().Set("Cache-Control", "no-cache")
resp.Header().Set("Connection", "keep-alive")
resp.Header().Set("X-Accel-Buffering", "no")
flusher, ok := resp.Writer.(http.Flusher)
if !ok {
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
}
resp.WriteHeader(http.StatusOK)
if _, err := fmt.Fprint(resp.Writer, "retry: 5000\n\n"); err != nil {
return err
}
if err := writeRpcStatsSSE(ctx, flusher, initial); err != nil {
return err
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Request().Context().Done():
return nil
case <-ticker.C:
payload, err := buildRpcStatsResponse()
if err != nil {
return err
}
if err := writeRpcStatsSSE(ctx, flusher, payload); err != nil {
return err
}
}
}
}
func writeRpcStatsSSE(ctx echo.Context, flusher http.Flusher, payload RpcStatsDashboardResponse) error {
envelope := rpcStatsStreamEnvelope{
StatusCode: http.StatusOK,
Message: "success",
Data: payload,
RequestID: ctx.Request().Header.Get(echo.HeaderXRequestID),
}
b, err := json.Marshal(envelope)
if err != nil {
return err
}
if _, err := fmt.Fprintf(ctx.Response().Writer, "event: rpc_stats\ndata: %s\n\n", b); err != nil {
return err
}
flusher.Flush()
return nil
}
func rpcStatsDisplayName(network string, displayName string) string {
if name := strings.TrimSpace(displayName); name != "" {
return name
}
return strings.TrimSpace(network)
}
func rpcStatsSuccessRate(successCount, failureCount int64) float64 {
total := successCount + failureCount
if total <= 0 {
return 0
}
return float64(successCount) / float64(total)
}
func rpcStatsStatus(enabled bool, successCount, failureCount int64) string {
if !enabled {
return "disabled"
}
if successCount+failureCount == 0 {
return "unknown"
}
if successCount == 0 {
return "down"
}
if failureCount > 0 {
return "warning"
}
return "ok"
}
// RecentOrders returns the last N orders for the dashboard list card. // RecentOrders returns the last N orders for the dashboard list card.
// @Summary Recent orders // @Summary Recent orders
// @Description Returns the last N orders for the dashboard // @Description Returns the last N orders for the dashboard
+13 -13
View File
@@ -2,7 +2,6 @@ package admin
import ( import (
"encoding/json" "encoding/json"
"errors"
"strconv" "strconv"
"strings" "strings"
@@ -10,6 +9,7 @@ import (
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify" "github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -69,7 +69,7 @@ func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error { func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
req := new(CreateNotificationChannelRequest) req := new(CreateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req.Type = strings.ToLower(strings.TrimSpace(req.Type)) req.Type = strings.ToLower(strings.TrimSpace(req.Type))
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
@@ -120,7 +120,7 @@ func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error { func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
existing, err := data.GetNotificationChannelByID(id) existing, err := data.GetNotificationChannelByID(id)
if err != nil { if err != nil {
@@ -130,7 +130,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
req := new(UpdateNotificationChannelRequest) req := new(UpdateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Name != nil { if req.Name != nil {
@@ -175,7 +175,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error { func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
existing, err := data.GetNotificationChannelByID(id) existing, err := data.GetNotificationChannelByID(id)
if err != nil { if err != nil {
@@ -185,7 +185,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
req := new(ChangeChannelStatusRequest) req := new(ChangeChannelStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil { if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -209,7 +209,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error { func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
existing, err := data.GetNotificationChannelByID(id) existing, err := data.GetNotificationChannelByID(id)
if err != nil { if err != nil {
@@ -230,12 +230,12 @@ func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error
// + chat_id; webhook/email kept loose until those senders exist. // + chat_id; webhook/email kept loose until those senders exist.
func validateChannelConfig(channelType string, cfg map[string]interface{}) error { func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
if cfg == nil { if cfg == nil {
return errors.New("config required") return constant.NotificationConfigErr
} }
if channelType == mdb.NotificationTypeTelegram { if channelType == mdb.NotificationTypeTelegram {
configJSON, _ := json.Marshal(cfg) configJSON, _ := json.Marshal(cfg)
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil { if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
return err return constant.NotificationConfigErr
} }
} }
return nil return nil
@@ -260,10 +260,10 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
case "1", "true", "on", "yes": case "1", "true", "on", "yes":
return true, nil return true, nil
default: default:
return false, errors.New("invalid boolean string") return false, constant.NotificationEventsErr
} }
default: default:
return false, errors.New("invalid event value type") return false, constant.NotificationEventsErr
} }
} }
@@ -299,7 +299,7 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
for _, v := range t { for _, v := range t {
s, ok := v.(string) s, ok := v.(string)
if !ok { if !ok {
return nil, errors.New("events array must contain strings") return nil, constant.NotificationEventsErr
} }
s = trimKey(s) s = trimKey(s)
if s == "" { if s == "" {
@@ -309,6 +309,6 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
} }
return out, nil return out, nil
default: default:
return nil, errors.New("events must be object or string array") return nil, constant.NotificationEventsErr
} }
} }
+16 -60
View File
@@ -2,7 +2,6 @@ package admin
import ( import (
"encoding/csv" "encoding/csv"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -13,15 +12,11 @@ import (
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// MarkPaidRequest is the payload for manually marking an order as paid.
type MarkPaidRequest struct {
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
}
// OrderListResponse wraps the paginated order list. // OrderListResponse wraps the paginated order list.
type OrderListResponse struct { type OrderListResponse struct {
List []mdb.Orders `json:"list"` List []mdb.Orders `json:"list"`
@@ -93,7 +88,7 @@ func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
return c.SucJson(ctx, order) return c.SucJson(ctx, order)
} }
@@ -115,17 +110,17 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
if order.Status != mdb.StatusWaitPay { if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment")) return c.FailJson(ctx, constant.OrderNotWaitPay)
} }
ok, err := data.CloseOrderManually(tradeID) ok, err := data.CloseOrderManually(tradeID)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if !ok { if !ok {
return c.FailJson(ctx, errors.New("close failed (concurrent state change)")) return c.FailJson(ctx, constant.OrderStatusConflict)
} }
// Release the transaction lock so the amount slot becomes reusable. // Release the transaction lock so the amount slot becomes reusable.
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount) _ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
@@ -142,63 +137,29 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param trade_id path string true "Trade ID" // @Param trade_id path string true "Trade ID"
// @Param request body admin.MarkPaidRequest true "Block transaction ID" // @Param request body request.ManualPaymentRequest true "Block transaction ID"
// @Success 200 {object} response.ApiResponse // @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post] // @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error { func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
tradeID := ctx.Param("trade_id") tradeID := ctx.Param("trade_id")
adminUserID := currentAdminUserID(ctx) adminUserID := currentAdminUserID(ctx)
req := new(MarkPaidRequest) req := new(request.ManualPaymentRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId) req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err) log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
order, err := data.GetOrderInfoByTradeId(tradeID) resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
if err != nil { if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid load failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err) log.Sugar.Warnf("[admin-order] mark-paid failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, resp.BlockTransactionId)
err = errors.New("order not found")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
if order.Status != mdb.StatusWaitPay {
err = errors.New("order is not waiting payment")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s status=%d block_transaction_id=%s err=%v", adminUserID, tradeID, order.Status, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
if !isOnChainOrder(order.PayProvider) {
err = errors.New("order is not an on-chain payment order")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s pay_provider=%s block_transaction_id=%s err=%v", adminUserID, tradeID, order.PayProvider, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
verifiedBlockTransactionID, err := service.ValidateManualOrderPayment(order, req.BlockTransactionId)
if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid chain verification failed admin_user_id=%d trade_id=%s network=%s token=%s amount=%.8f block_transaction_id=%s err=%v", adminUserID, tradeID, order.Network, order.Token, order.ActualAmount, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
req.BlockTransactionId = verifiedBlockTransactionID
err = service.OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: req.BlockTransactionId,
})
if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid processing failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, req.BlockTransactionId)
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
@@ -223,17 +184,17 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { if order.ID == 0 {
err = errors.New("order not found") err = constant.OrderNotExists
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.Status != mdb.StatusPaySuccess { if order.Status != mdb.StatusPaySuccess {
err = errors.New("order is not paid (callback not applicable)") err = constant.OrderCallbackNotApplicable
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err) log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if strings.TrimSpace(order.NotifyUrl) == "" { if strings.TrimSpace(order.NotifyUrl) == "" {
err = errors.New("order has empty notify_url") err = constant.OrderNotifyURLEmptyErr
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -243,7 +204,7 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if !ok { if !ok {
err = errors.New("resend callback failed (concurrent state change)") err = constant.OrderResendCallbackErr
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err) log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -251,11 +212,6 @@ func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
func isOnChainOrder(payProvider string) bool {
payProvider = strings.TrimSpace(payProvider)
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
// ExportOrders streams matching orders as CSV. No pagination — the // ExportOrders streams matching orders as CSV. No pagination — the
// caller drives what rows to include via filter params. Capped at // caller drives what rows to include via filter params. Capped at
// 10k rows to keep the response bounded. // 10k rows to keep the response bounded.
@@ -433,7 +389,7 @@ func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if order.ID == 0 { if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found")) return c.FailJson(ctx, constant.OrderNotExists)
} }
subOrders, err := data.GetAllSubOrders(tradeID) subOrders, err := data.GetAllSubOrders(tradeID)
if err != nil { if err != nil {
+80 -11
View File
@@ -1,13 +1,14 @@
package admin package admin
import ( import (
"errors" "net/url"
"strconv" "strconv"
"strings" "strings"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/task" "github.com/GMWalletApp/epusdt/task"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -20,6 +21,8 @@ type CreateRpcNodeRequest struct {
Weight int `json:"weight" example:"1"` Weight int `json:"weight" example:"1"`
ApiKey string `json:"api_key" example:""` ApiKey string `json:"api_key" example:""`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
Purpose string `json:"purpose" enums:"general,manual_verify,both" example:"general"`
} }
// UpdateRpcNodeRequest is the payload for updating an RPC node. // UpdateRpcNodeRequest is the payload for updating an RPC node.
@@ -28,6 +31,7 @@ type UpdateRpcNodeRequest struct {
Weight *int `json:"weight" example:"1"` Weight *int `json:"weight" example:"1"`
ApiKey *string `json:"api_key" example:"your-api-key"` ApiKey *string `json:"api_key" example:"your-api-key"`
Enabled *bool `json:"enabled" example:"true"` Enabled *bool `json:"enabled" example:"true"`
Purpose *string `json:"purpose" enums:"general,manual_verify,both" example:"manual_verify"`
} }
// ListRpcNodes returns rows optionally filtered by network. // ListRpcNodes returns rows optionally filtered by network.
@@ -64,7 +68,7 @@ func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error { func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
req := new(CreateRpcNodeRequest) req := new(CreateRpcNodeRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -77,13 +81,23 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
if req.Enabled != nil { if req.Enabled != nil {
enabled = *req.Enabled enabled = *req.Enabled
} }
purpose, err := rpcNodePurposeFromRequest(req.Purpose)
if err != nil {
return c.FailJson(ctx, err)
}
nodeURL := strings.TrimSpace(req.Url)
nodeType := strings.ToLower(strings.TrimSpace(req.Type))
if err := validateRpcNodeURLForType(nodeURL, nodeType); err != nil {
return c.FailJson(ctx, err)
}
row := &mdb.RpcNode{ row := &mdb.RpcNode{
Network: strings.ToLower(strings.TrimSpace(req.Network)), Network: strings.ToLower(strings.TrimSpace(req.Network)),
Url: strings.TrimSpace(req.Url), Url: nodeURL,
Type: strings.ToLower(strings.TrimSpace(req.Type)), Type: nodeType,
Weight: weight, Weight: weight,
ApiKey: req.ApiKey, ApiKey: req.ApiKey,
Enabled: enabled, Enabled: enabled,
Purpose: purpose,
Status: mdb.RpcNodeStatusUnknown, Status: mdb.RpcNodeStatusUnknown,
LastLatencyMs: -1, LastLatencyMs: -1,
} }
@@ -93,7 +107,7 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
return c.SucJson(ctx, row) return c.SucJson(ctx, row)
} }
// UpdateRpcNode patches url/weight/api_key/enabled. // UpdateRpcNode patches url/weight/api_key/enabled/purpose.
// @Summary Update RPC node // @Summary Update RPC node
// @Description Patch RPC node fields // @Description Patch RPC node fields
// @Tags Admin RPC Nodes // @Tags Admin RPC Nodes
@@ -108,15 +122,26 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error { func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(UpdateRpcNodeRequest) req := new(UpdateRpcNodeRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Url != nil { if req.Url != nil {
fields["url"] = *req.Url row, err := data.GetRpcNodeByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
}
nodeURL := strings.TrimSpace(*req.Url)
if err := validateRpcNodeURLForType(nodeURL, row.Type); err != nil {
return c.FailJson(ctx, err)
}
fields["url"] = nodeURL
} }
if req.Weight != nil { if req.Weight != nil {
fields["weight"] = *req.Weight fields["weight"] = *req.Weight
@@ -127,6 +152,13 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
if req.Enabled != nil { if req.Enabled != nil {
fields["enabled"] = *req.Enabled fields["enabled"] = *req.Enabled
} }
if req.Purpose != nil {
purpose, err := rpcNodePurposeFromRequest(*req.Purpose)
if err != nil {
return c.FailJson(ctx, err)
}
fields["purpose"] = purpose
}
if err := data.UpdateRpcNodeFields(id, fields); err != nil { if err := data.UpdateRpcNodeFields(id, fields); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -146,7 +178,7 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error { func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteRpcNodeByID(id); err != nil { if err := data.DeleteRpcNodeByID(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -170,14 +202,14 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error { func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
row, err := data.GetRpcNodeByID(id) row, err := data.GetRpcNodeByID(id)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if row.ID == 0 { if row.ID == 0 {
return c.FailJson(ctx, errors.New("node not found")) return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
} }
status, latency := task.ProbeNode(row.Url) status, latency := task.ProbeNode(row.Url)
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil { if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
@@ -188,3 +220,40 @@ func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
"last_latency_ms": latency, "last_latency_ms": latency,
}) })
} }
func rpcNodePurposeFromRequest(raw string) (string, error) {
purpose := strings.ToLower(strings.TrimSpace(raw))
if purpose == "" {
return mdb.RpcNodePurposeGeneral, nil
}
switch purpose {
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeManualVerify, mdb.RpcNodePurposeBoth:
return purpose, nil
default:
return "", constant.RpcNodePurposeErr
}
}
func validateRpcNodeURLForType(rawURL string, nodeType string) error {
rawURL = strings.TrimSpace(rawURL)
nodeType = strings.ToLower(strings.TrimSpace(nodeType))
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return constant.RpcNodeURLErr
}
scheme := strings.ToLower(parsed.Scheme)
switch nodeType {
case mdb.RpcNodeTypeHttp:
if scheme == "http" || scheme == "https" {
return nil
}
return constant.RpcNodeHTTPURLErr
case mdb.RpcNodeTypeWs:
if scheme == "ws" || scheme == "wss" {
return nil
}
return constant.RpcNodeWebSocketURLErr
default:
return constant.RpcNodeTypeErr
}
}
+111 -20
View File
@@ -1,6 +1,7 @@
package admin package admin
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -9,6 +10,8 @@ import (
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/security"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -16,8 +19,8 @@ import (
// Supported groups and keys: // Supported groups and keys:
// //
// - group=rate: // - group=rate:
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url // rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0 // rate.api_url (string) — external rate API URL used when no positive forced rate exists
// rate.adjust_percent (float) — rate adjustment percentage // rate.adjust_percent (float) — rate adjustment percentage
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed // rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
// //
@@ -54,7 +57,7 @@ import (
type SettingUpsertItem struct { type SettingUpsertItem struct {
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"` Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
Key string `json:"key" example:"epay.default_network"` Key string `json:"key" example:"epay.default_network"`
Value string `json:"value" example:"tron"` Value interface{} `json:"value"`
Type string `json:"type" enums:"string,int,bool,json" example:"string"` Type string `json:"type" enums:"string,int,bool,json" example:"string"`
} }
@@ -88,11 +91,11 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// independently so a malformed row in the middle doesn't drop earlier // independently so a malformed row in the middle doesn't drop earlier
// ones. Errors are returned per-key so the UI can surface them. // ones. Errors are returned per-key so the UI can surface them.
// @Summary Upsert settings // @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status. // @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
// @Description Supported groups: brand, rate, system, epay, okpay. // @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron"). // @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens. // @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
// @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. // @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2). // @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2).
// @Tags Admin Settings // @Tags Admin Settings
@@ -106,7 +109,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error { func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
req := new(SettingsUpsertRequest) req := new(SettingsUpsertRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -114,25 +117,40 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
type result struct { type result struct {
Key string `json:"key"` Key string `json:"key"`
OK bool `json:"ok"` OK bool `json:"ok"`
ErrorCode int `json:"error_code,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
out := make([]result, 0, len(req.Items)) out := make([]result, 0, len(req.Items))
errorResult := func(key string, err error) result {
code, msg := constant.ResolveErrno(err)
return result{Key: key, OK: false, ErrorCode: code, Error: msg}
}
for _, item := range req.Items { for _, item := range req.Items {
key := strings.TrimSpace(item.Key) key := strings.TrimSpace(item.Key)
if key == "" { if key == "" {
out = append(out, result{Key: item.Key, OK: false, Error: "key required"}) out = append(out, errorResult(item.Key, constant.SettingItemErr))
continue continue
} }
if err := validateSettingItem(item.Group, key, item.Value); err != nil { value, err := normalizeSettingValue(item.Value)
out = append(out, result{Key: key, OK: false, Error: err.Error()}) if err != nil {
out = append(out, errorResult(key, constant.SettingItemErr))
continue
}
value, err = normalizeAndValidateSettingItem(item.Group, key, value)
if err != nil {
out = append(out, errorResult(key, constant.SettingItemErr))
continue continue
} }
if key == mdb.SettingKeyAmountPrecision { if key == mdb.SettingKeyAmountPrecision {
item.Group = mdb.SettingGroupSystem item.Group = mdb.SettingGroupSystem
item.Type = mdb.SettingTypeInt item.Type = mdb.SettingTypeInt
} }
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil { if key == mdb.SettingKeyRateForcedRateList {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) item.Group = mdb.SettingGroupRate
item.Type = mdb.SettingTypeJSON
}
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
out = append(out, errorResult(key, err))
continue continue
} }
out = append(out, result{Key: key, OK: true}) out = append(out, result{Key: key, OK: true})
@@ -159,21 +177,98 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
return c.SucJson(ctx, out) return c.SucJson(ctx, out)
} }
func validateSettingItem(group, key, value string) error { func normalizeSettingValue(value interface{}) (string, error) {
switch v := value.(type) {
case nil:
return "", nil
case string:
return v, nil
default:
b, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("value must be JSON serializable")
}
return string(b), nil
}
}
func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
switch key { switch key {
case mdb.SettingKeyAmountPrecision: case mdb.SettingKeyAmountPrecision:
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem { if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
return fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem) return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
} }
precision, err := strconv.Atoi(strings.TrimSpace(value)) precision, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil { if err != nil {
return fmt.Errorf("%s must be an integer", key) return value, fmt.Errorf("%s must be an integer", key)
} }
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision { if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
return fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision) return value, fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
}
case mdb.SettingKeyRateForcedRateList:
normalized, err := normalizeForcedRateListSetting(group, key, value)
if err != nil {
return value, err
}
return normalized, nil
case mdb.SettingKeyRateApiUrl:
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
}
if err := security.ValidatePublicHTTPURL(value); err != nil {
return value, fmt.Errorf("%s invalid: %w", key, err)
} }
} }
return nil return value, nil
}
func normalizeForcedRateListSetting(group, key, value string) (string, error) {
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
}
if strings.TrimSpace(value) == "" {
return "", nil
}
var rates map[string]map[string]float64
if err := json.Unmarshal([]byte(value), &rates); err != nil {
return value, fmt.Errorf("%s must be valid JSON object", key)
}
normalized := make(map[string]map[string]float64, len(rates))
for base, coins := range rates {
normalizedBase := strings.ToLower(strings.TrimSpace(base))
if normalizedBase == "" {
return value, fmt.Errorf("%s base currency must not be empty", key)
}
if coins == nil {
return value, fmt.Errorf("%s.%s must be an object", key, base)
}
normalizedCoins, ok := normalized[normalizedBase]
if !ok {
normalizedCoins = make(map[string]float64, len(coins))
normalized[normalizedBase] = normalizedCoins
}
for coin, rate := range coins {
normalizedCoin := strings.ToLower(strings.TrimSpace(coin))
if normalizedCoin == "" {
return value, fmt.Errorf("%s coin must not be empty", key)
}
if rate < 0 {
return value, fmt.Errorf("%s.%s.%s must be >= 0", key, base, coin)
}
if _, exists := normalizedCoins[normalizedCoin]; exists {
return value, fmt.Errorf("%s.%s.%s is duplicated after normalization", key, normalizedBase, normalizedCoin)
}
normalizedCoins[normalizedCoin] = rate
}
}
normalizedValue, err := json.Marshal(normalized)
if err != nil {
return value, fmt.Errorf("%s must be JSON serializable", key)
}
return string(normalizedValue), nil
} }
// DeleteSetting removes one row. The next read of that key will fall // DeleteSetting removes one row. The next read of that key will fall
@@ -197,7 +292,3 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
} }
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
// Public helper for the rate/usdt overrides — used by config package to
// read settings-backed values without importing the controller package.
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
+14 -12
View File
@@ -1,13 +1,13 @@
package admin package admin
import ( import (
"errors"
"strconv" "strconv"
"strings" "strings"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -105,7 +105,7 @@ func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error { func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
req := new(AdminAddWalletRequest) req := new(AdminAddWalletRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -140,14 +140,14 @@ func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error { func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
wallet, err := data.GetWalletAddressById(id) wallet, err := data.GetWalletAddressById(id)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if wallet.ID == 0 { if wallet.ID == 0 {
return c.FailJson(ctx, errors.New("wallet not found")) return c.FailJson(ctx, constant.WalletNotFoundErr)
} }
counts, _ := data.CountOrdersByAddress() counts, _ := data.CountOrdersByAddress()
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]}) return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
@@ -168,11 +168,11 @@ func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error { func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(AdminUpdateWalletRequest) req := new(AdminUpdateWalletRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
fields := map[string]interface{}{} fields := map[string]interface{}{}
if req.Remark != nil { if req.Remark != nil {
@@ -202,11 +202,11 @@ func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error { func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
req := new(AdminChangeWalletStatusRequest) req := new(AdminChangeWalletStatusRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -230,7 +230,7 @@ func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error { func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := data.DeleteWalletAddressById(id); err != nil { if err := data.DeleteWalletAddressById(id); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -242,7 +242,7 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
// them all as observation wallets. Per-row failures are collected and // them all as observation wallets. Per-row failures are collected and
// returned so the UI can surface which addresses already existed. // returned so the UI can surface which addresses already existed.
// @Summary Batch import wallets // @Summary Batch import wallets
// @Description Import multiple wallet addresses at once. Per-row status is returned. // @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
// @Tags Admin Wallets // @Tags Admin Wallets
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -254,7 +254,7 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error { func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
req := new(AdminBatchImportRequest) req := new(AdminBatchImportRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err := c.ValidateStruct(ctx, req); err != nil { if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
@@ -262,13 +262,15 @@ func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
type result struct { type result struct {
Address string `json:"address"` Address string `json:"address"`
OK bool `json:"ok"` OK bool `json:"ok"`
ErrorCode int `json:"error_code,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
out := make([]result, 0, len(req.Addresses)) out := make([]result, 0, len(req.Addresses))
for _, addr := range req.Addresses { for _, addr := range req.Addresses {
row, err := data.AddWalletAddressWithNetwork(req.Network, addr) row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
if err != nil { if err != nil {
out = append(out, result{Address: addr, OK: false, Error: err.Error()}) code, msg := constant.ResolveErrno(err)
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
continue continue
} }
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID). _ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
+2 -2
View File
@@ -1,7 +1,7 @@
package controller package controller
import ( import (
"errors" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http" "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/validate" "github.com/gookit/validate"
"github.com/gookit/validate/locales/zhcn" "github.com/gookit/validate/locales/zhcn"
@@ -46,6 +46,6 @@ func (c *BaseController) ValidateStruct(ctx echo.Context, i interface{}, scene .
if v.Validate() { if v.Validate() {
return nil return nil
} else { } else {
return errors.New(v.Errors.One()) return constant.ParamsMarshalErr
} }
} }
+4 -4
View File
@@ -41,7 +41,7 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
// @Param name formData string false "Order name" // @Param name formData string false "Order name"
// @Param payment_type formData string false "Payment type" // @Param payment_type formData string false "Payment type"
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse} // @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
// @Router /payments/gmpay/v1/order/create-transaction [post] // @Router /payments/gmpay/v1/order/create-transaction [post]
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest) req := new(request.CreateTransactionRequest)
@@ -68,7 +68,7 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
// @Produce json // @Produce json
// @Param request body request.SwitchNetworkRequest true "Switch network payload" // @Param request body request.SwitchNetworkRequest true "Switch network payload"
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse} // @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10017/10018/10019 provider errors, 10042 provider order creation failed"
// @Router /pay/switch-network [post] // @Router /pay/switch-network [post]
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
req := new(request.SwitchNetworkRequest) req := new(request.SwitchNetworkRequest)
@@ -85,7 +85,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
jsonBytes, err := json.MarshalIndent(resp, "", " ") jsonBytes, err := json.MarshalIndent(resp, "", " ")
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, constant.SystemErr)
} }
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes)) log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
@@ -121,7 +121,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param sign formData string true "MD5 signature" // @Param sign formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)" // @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter" // @Success 302 "Redirect to checkout counter"
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
// @Router /payments/epay/v1/order/create-transaction/submit.php [post] // @Router /payments/epay/v1/order/create-transaction/submit.php [post]
// @Router /payments/epay/v1/order/create-transaction/submit.php [get] // @Router /payments/epay/v1/order/create-transaction/submit.php [get]
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
+33
View File
@@ -1,8 +1,12 @@
package comm package comm
import ( import (
"strings"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -46,3 +50,32 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
} }
return c.SucJson(ctx, resp) return c.SucJson(ctx, resp)
} }
// SubmitTxHash 手动提交交易 hash 补单
// @Summary Submit transaction hash
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
// @Tags Payment
// @Accept json
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Param request body request.ManualPaymentRequest true "Transaction hash payload"
// @Success 200 {object} response.ApiResponse{data=response.ManualPaymentResponse}
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10009 invalid params, 10013 not waiting payment, 10038 verification failed, 10039 unsupported provider, 10007 hash already processed"
// @Router /pay/submit-tx-hash/{trade_id} [post]
func (c *BaseCommController) SubmitTxHash(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id")
req := new(request.ManualPaymentRequest)
if err = ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
if err = c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
resp, err := service.SubmitCashierManualPayment(tradeId, req.BlockTransactionId)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, resp)
}
+4 -4
View File
@@ -85,11 +85,11 @@ func SetupTestDatabases(t testing.TB) func() {
}) })
if err := dao.Mdb.Create(&mdb.Setting{ if err := dao.Mdb.Create(&mdb.Setting{
Group: "rate", Group: "rate",
Key: "rate.forced_usdt_rate", Key: "rate.forced_rate_list",
Value: "1.0", Value: `{"cny":{"usdt":1}}`,
Type: "string", Type: "json",
}).Error; err != nil { }).Error; err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err) t.Fatalf("seed rate.forced_rate_list: %v", err)
} }
return func() { return func() {
+23 -10
View File
@@ -27,16 +27,9 @@ const (
func CheckAdminJWT() echo.MiddlewareFunc { func CheckAdminJWT() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization")) token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
if raw == "" { if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header") return err
}
if !strings.HasPrefix(raw, "Bearer ") {
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
}
token := strings.TrimSpace(raw[len("Bearer "):])
if token == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
} }
claims, err := appjwt.Parse(token) claims, err := appjwt.Parse(token)
if err != nil { if err != nil {
@@ -48,3 +41,23 @@ func CheckAdminJWT() echo.MiddlewareFunc {
} }
} }
} }
func adminTokenFromAuthorization(header string) (string, error) {
raw := strings.TrimSpace(header)
if raw == "" {
return "", echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
}
const bearerPrefix = "Bearer "
token := raw
if strings.HasPrefix(raw, bearerPrefix) {
token = strings.TrimSpace(raw[len(bearerPrefix):])
} else if raw == strings.TrimSpace(bearerPrefix) {
token = ""
}
if token == "" {
return "", echo.NewHTTPError(http.StatusUnauthorized, "empty token")
}
return token, nil
}
+118
View File
@@ -0,0 +1,118 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
func TestAdminTokenFromAuthorization(t *testing.T) {
tests := []struct {
name string
header string
want string
wantErr bool
}{
{
name: "bearer token",
header: "Bearer jwt-token",
want: "jwt-token",
},
{
name: "bare token",
header: "jwt-token",
want: "jwt-token",
},
{
name: "trimmed bare token",
header: " jwt-token ",
want: "jwt-token",
},
{
name: "missing header",
header: "",
wantErr: true,
},
{
name: "empty bearer token",
header: "Bearer ",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := adminTokenFromAuthorization(tt.header)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("token = %q, want %q", got, tt.want)
}
})
}
}
func TestCheckAdminJWTAcceptsBearerAndBareTokens(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, "test-jwt-secret", mdb.SettingTypeString); err != nil {
t.Fatalf("seed jwt secret: %v", err)
}
token, err := appjwt.Sign(42, "admin")
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
tests := []struct {
name string
header string
}{
{
name: "bearer token",
header: "Bearer " + token,
},
{
name: "bare token",
header: token,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := echo.New()
e.Use(CheckAdminJWT())
e.GET("/", func(ctx echo.Context) error {
if got := ctx.Get(AdminUserIDKey); got != uint64(42) {
t.Fatalf("%s = %v, want 42", AdminUserIDKey, got)
}
if got := ctx.Get(AdminUsernameKey); got != "admin" {
t.Fatalf("%s = %v, want admin", AdminUsernameKey, got)
}
return ctx.NoContent(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(echo.HeaderAuthorization, tt.header)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
})
}
}
+25 -8
View File
@@ -46,6 +46,7 @@ func MdbTableInit() {
} }
seedChains() seedChains()
backfillRpcNodePurpose()
seedRpcNodes() seedRpcNodes()
seedChainTokens() seedChainTokens()
seedDefaultSettings() seedDefaultSettings()
@@ -106,14 +107,7 @@ func seedChainTokens() {
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per // seedRpcNodes inserts default RPC endpoints for each chain. Checks per
// (network, url) so re-runs are idempotent and admin edits persist. // (network, url) so re-runs are idempotent and admin edits persist.
func seedRpcNodes() { func seedRpcNodes() {
defaults := []mdb.RpcNode{ defaults := defaultRpcNodes()
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
}
for _, d := range defaults { for _, d := range defaults {
var count int64 var count int64
if err := Mdb.Model(&mdb.RpcNode{}). if err := Mdb.Model(&mdb.RpcNode{}).
@@ -131,6 +125,29 @@ func seedRpcNodes() {
} }
} }
func defaultRpcNodes() []mdb.RpcNode {
defaults := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
}
return defaults
}
func backfillRpcNodePurpose() {
if err := Mdb.Model(&mdb.RpcNode{}).
Where("purpose = '' OR purpose IS NULL").
Update("purpose", mdb.RpcNodePurposeGeneral).Error; err != nil {
color.Red.Printf("[store_db] backfill rpc_nodes purpose err=%s\n", err)
}
}
// seedDefaultSettings inserts built-in default settings. // seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts. // Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() { func seedDefaultSettings() {
+43
View File
@@ -0,0 +1,43 @@
package dao
import (
"testing"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
want := map[string]string{
mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum",
mdb.NetworkBsc: "https://rpc.epusdt.com/binance",
mdb.NetworkPolygon: "https://rpc.epusdt.com/polygon",
}
got := make(map[string]mdb.RpcNode)
for _, node := range defaultRpcNodes() {
if node.Purpose != mdb.RpcNodePurposeManualVerify {
continue
}
if _, ok := want[node.Network]; ok {
got[node.Network] = node
}
}
for network, url := range want {
node, ok := got[network]
if !ok {
t.Fatalf("missing manual_verify seed rpc node for %s", network)
}
if node.Url != url {
t.Fatalf("%s manual_verify seed url = %q, want %q", network, node.Url, url)
}
if node.Type != mdb.RpcNodeTypeHttp {
t.Fatalf("%s manual_verify seed type = %q, want %q", network, node.Type, mdb.RpcNodeTypeHttp)
}
if !node.Enabled {
t.Fatalf("%s manual_verify seed enabled = false, want true", network)
}
if node.Status != mdb.RpcNodeStatusUnknown {
t.Fatalf("%s manual_verify seed status = %q, want %q", network, node.Status, mdb.RpcNodeStatusUnknown)
}
}
}
+73 -44
View File
@@ -1,6 +1,7 @@
package data package data
import ( import (
"fmt"
"time" "time"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
@@ -28,25 +29,40 @@ type AddressDailyStat struct {
ActualAmount float64 `json:"actual_amount"` ActualAmount float64 `json:"actual_amount"`
} }
func statsBucketExpr(column string, hourly bool) (string, error) {
dialect := "sqlite"
if dao.Mdb != nil && dao.Mdb.Dialector != nil {
dialect = dao.Mdb.Dialector.Name()
}
return statsBucketExprForDialect(dialect, column, hourly)
}
func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) {
switch dialect {
case "sqlite", "":
// modernc SQLite stores time.Time as Go's time.String() by default.
// Prefix buckets work for that format and SQLite datetime text.
if hourly {
return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil
}
return fmt.Sprintf("substr(%s, 1, 10)", column), nil
case "postgres":
if hourly {
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil
}
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil
default:
return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect)
}
}
// DailyOrderStats returns per-day counts/amounts within [start, end], // DailyOrderStats returns per-day counts/amounts within [start, end],
// zero-filling any days in the range that have no orders. // zero-filling any days in the range that have no orders.
// Only orders in StatusPaySuccess contribute to Amount sums — that's // Only orders in StatusPaySuccess contribute to Amount sums — that's
// what the user means by "流水". Pending orders count toward OrderCount // what the user means by "流水". Pending orders count toward OrderCount
// to keep成交率 computable. // to keep成交率 computable.
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) { func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat rows, err := queryOrderStats(start, end, false)
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`DATE(created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -56,25 +72,34 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
// HourlyOrderStats returns per-hour counts/amounts within [start, end], // HourlyOrderStats returns per-hour counts/amounts within [start, end],
// zero-filling any hours that have no orders. // zero-filling any hours that have no orders.
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) { func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat rows, err := queryOrderStats(start, end, true)
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return fillHourlyStats(start, end, rows), nil return fillHourlyStats(start, end, rows), nil
} }
func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) {
var rows []DailyStat
bucket, err := statsBucketExpr("created_at", hourly)
if err != nil {
return nil, err
}
err = dao.Mdb.Model(&mdb.Orders{}).
Select(fmt.Sprintf(`%s AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket),
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group(bucket).
Order("day ASC").
Scan(&rows).Error
return rows, err
}
// fillDailyStats generates a zero-filled slice covering every calendar // fillDailyStats generates a zero-filled slice covering every calendar
// day in [start, end]. Days present in rows overwrite the zero entries. // day in [start, end]. Days present in rows overwrite the zero entries.
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat { func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
@@ -122,15 +147,7 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
// DailyAssetByAddress groups paid actual_amount by (day, receive_address) // DailyAssetByAddress groups paid actual_amount by (day, receive_address)
// for the per-address stacked chart, zero-filling missing days. // for the per-address stacked chart, zero-filling missing days.
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) { func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat rows, err := queryAssetByAddress(start, end, false)
err := dao.Mdb.Model(&mdb.Orders{}).
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -140,21 +157,33 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address) // HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
// for the per-address stacked chart, zero-filling missing hours. // for the per-address stacked chart, zero-filling missing hours.
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) { func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat rows, err := queryAssetByAddress(start, end, true)
err := dao.Mdb.Model(&mdb.Orders{}).
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return fillAddressHourlyStats(start, end, rows), nil return fillAddressHourlyStats(start, end, rows), nil
} }
func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) {
var rows []AddressDailyStat
bucket, err := statsBucketExpr("created_at", hourly)
if err != nil {
return nil, err
}
err = dao.Mdb.Model(&mdb.Orders{}).
Select(fmt.Sprintf(`%s AS day,
receive_address AS address,
SUM(actual_amount) AS actual_amount`, bucket)).
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group(bucket).
Group("receive_address").
Order("day ASC, address ASC").
Scan(&rows).Error
return rows, err
}
// fillAddressDailyStats zero-fills missing day×address combinations. // fillAddressDailyStats zero-fills missing day×address combinations.
// All addresses that appear in rows are represented for every day in range. // All addresses that appear in rows are represented for every day in range.
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat { func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
+50
View File
@@ -42,6 +42,56 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
} }
} }
func TestStatsBucketExprForDialect(t *testing.T) {
tests := []struct {
name string
dialect string
hourly bool
want string
}{
{
name: "sqlite daily",
dialect: "sqlite",
want: "substr(created_at, 1, 10)",
},
{
name: "sqlite hourly",
dialect: "sqlite",
hourly: true,
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
},
{
name: "postgres daily",
dialect: "postgres",
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
},
{
name: "postgres hourly",
dialect: "postgres",
hourly: true,
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
if err != nil {
t.Fatalf("statsBucketExprForDialect error: %v", err)
}
if got != tt.want {
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
}
})
}
}
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
t.Fatal("expected unsupported mysql dialect error")
}
}
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) { func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+253 -1
View File
@@ -1,14 +1,44 @@
package data package data
import ( import (
"fmt"
"math/rand" "math/rand"
"net/url"
"sort"
"strings" "strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
) )
const (
RpcFailoverThreshold = 3
RpcFailoverCooldown = 60 * time.Second
)
var gRpcFailover = struct {
sync.Mutex
failures map[uint64]int
cooldownUntil map[uint64]time.Time
}{
failures: make(map[uint64]int),
cooldownUntil: make(map[uint64]time.Time),
}
func NormalizeRpcNodePurpose(purpose string) string {
switch strings.ToLower(strings.TrimSpace(purpose)) {
case mdb.RpcNodePurposeManualVerify:
return mdb.RpcNodePurposeManualVerify
case mdb.RpcNodePurposeBoth:
return mdb.RpcNodePurposeBoth
default:
return mdb.RpcNodePurposeGeneral
}
}
// ListRpcNodes returns rows optionally filtered by network. // ListRpcNodes returns rows optionally filtered by network.
func ListRpcNodes(network string) ([]mdb.RpcNode, error) { func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode var rows []mdb.RpcNode
@@ -20,6 +50,84 @@ func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
return rows, err return rows, err
} }
// ListRpcNodesForHealth returns nodes that should be probed by the periodic
// health job. Manual verification nodes are intentionally excluded because
// they may be paid/high-limit endpoints that should only be used on demand.
func ListRpcNodesForHealth() ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Order("id ASC").
Find(&rows).Error
return rows, err
}
// ListGeneralRpcCandidates returns enabled non-manual nodes for ordinary
// scanners/listeners. Down nodes are skipped; ok nodes are ordered before
// unknown/bootstrap nodes.
func ListGeneralRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
Where("enabled = ?", true).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
Find(&rows).Error
if err != nil {
return nil, err
}
buckets := make([][]mdb.RpcNode, 2)
for _, row := range rows {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
if row.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], row)
} else {
buckets[1] = append(buckets[1], row)
}
}
out := make([]mdb.RpcNode, 0, len(rows))
for _, bucket := range buckets {
sortRpcNodes(bucket)
out = append(out, bucket...)
}
return out, nil
}
func SelectGeneralRpcNode(network, nodeType string, excludeIDs ...uint64) (*mdb.RpcNode, error) {
rows, err := ListGeneralRpcCandidates(network, nodeType)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
excluded := make(map[uint64]struct{}, len(excludeIDs))
for _, id := range excludeIDs {
if id > 0 {
excluded[id] = struct{}{}
}
}
for i := range rows {
if _, ok := excluded[rows[i].ID]; ok {
continue
}
if IsRpcNodeCoolingDown(rows[i].ID) {
continue
}
return &rows[i], nil
}
if len(excluded) == 0 {
for i := range rows {
return &rows[i], nil
}
}
return nil, nil
}
// GetRpcNodeByID fetches one row. // GetRpcNodeByID fetches one row.
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) { func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
row := new(mdb.RpcNode) row := new(mdb.RpcNode)
@@ -29,6 +137,7 @@ func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
// CreateRpcNode inserts a row. // CreateRpcNode inserts a row.
func CreateRpcNode(row *mdb.RpcNode) error { func CreateRpcNode(row *mdb.RpcNode) error {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
return dao.Mdb.Create(row).Error return dao.Mdb.Create(row).Error
} }
@@ -37,6 +146,9 @@ func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 { if len(fields) == 0 {
return nil return nil
} }
if purpose, ok := fields["purpose"].(string); ok {
fields["purpose"] = NormalizeRpcNodePurpose(purpose)
}
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
} }
@@ -48,13 +160,15 @@ func DeleteRpcNodeByID(id uint64) error {
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair. // SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
// Strategy: weighted random among rows where enabled=true AND status=ok. // Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to enabled rows with status=unknown when no health check has // Falls back to enabled rows with status=unknown when no health check has
// run yet. Explicitly down rows are not selected. // run yet. Explicitly down rows are not selected. Manual verification nodes
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) { func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}). err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(network)). Where("network = ?", strings.ToLower(network)).
Where("type = ?", strings.ToLower(nodeType)). Where("type = ?", strings.ToLower(nodeType)).
Where("enabled = ?", true). Where("enabled = ?", true).
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
Find(&rows).Error Find(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -82,6 +196,144 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
return pickWeighted(candidates), nil return pickWeighted(candidates), nil
} }
// ListManualPaymentRpcCandidates returns enabled RPC nodes for manual payment
// verification. Ordinary RPCs are tried first to avoid paid endpoint usage;
// manual_verify nodes are used only as a fallback.
func ListManualPaymentRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
Where("enabled = ?", true).
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
buckets := make([][]mdb.RpcNode, 4)
for _, row := range rows {
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
switch row.Purpose {
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
if row.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], row)
} else {
buckets[1] = append(buckets[1], row)
}
case mdb.RpcNodePurposeManualVerify:
if row.Status == mdb.RpcNodeStatusOk {
buckets[2] = append(buckets[2], row)
} else {
buckets[3] = append(buckets[3], row)
}
}
}
out := make([]mdb.RpcNode, 0, len(rows))
for _, bucket := range buckets {
sortRpcNodes(bucket)
out = append(out, bucket...)
}
return out, nil
}
func sortRpcNodes(rows []mdb.RpcNode) {
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Weight != rows[j].Weight {
return rows[i].Weight > rows[j].Weight
}
return rows[i].ID < rows[j].ID
})
}
func RecordRpcNodeSuccess(nodeID uint64) {
if nodeID == 0 {
return
}
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
delete(gRpcFailover.failures, nodeID)
delete(gRpcFailover.cooldownUntil, nodeID)
}
func RecordRpcNodeFailure(nodeID uint64) (int, bool) {
if nodeID == 0 {
return 0, false
}
now := time.Now()
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
if until, ok := gRpcFailover.cooldownUntil[nodeID]; ok {
if now.Before(until) {
return gRpcFailover.failures[nodeID], true
}
delete(gRpcFailover.cooldownUntil, nodeID)
delete(gRpcFailover.failures, nodeID)
}
gRpcFailover.failures[nodeID]++
failures := gRpcFailover.failures[nodeID]
if failures >= RpcFailoverThreshold {
gRpcFailover.failures[nodeID] = 0
gRpcFailover.cooldownUntil[nodeID] = now.Add(RpcFailoverCooldown)
return failures, true
}
return failures, false
}
func IsRpcNodeCoolingDown(nodeID uint64) bool {
if nodeID == 0 {
return false
}
now := time.Now()
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
until, ok := gRpcFailover.cooldownUntil[nodeID]
if !ok {
return false
}
if now.Before(until) {
return true
}
delete(gRpcFailover.cooldownUntil, nodeID)
delete(gRpcFailover.failures, nodeID)
return false
}
func RpcNodeLogLabel(node mdb.RpcNode) string {
return fmt.Sprintf("id=%d network=%s type=%s endpoint=%s", node.ID, node.Network, node.Type, safeRpcEndpoint(node.Url))
}
func safeRpcEndpoint(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Host != "" {
if parsed.Scheme != "" {
return parsed.Scheme + "://" + parsed.Host
}
return parsed.Host
}
cut := strings.IndexAny(raw, "/?")
if cut >= 0 {
return raw[:cut]
}
return raw
}
func ResetRpcFailoverForTest() {
gRpcFailover.Lock()
defer gRpcFailover.Unlock()
gRpcFailover.failures = make(map[uint64]int)
gRpcFailover.cooldownUntil = make(map[uint64]time.Time)
}
// pickWeighted chooses one row using the Weight column. Weights < 1 are // pickWeighted chooses one row using the Weight column. Weights < 1 are
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false // coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
// to truly disable). // to truly disable).
+304
View File
@@ -1,6 +1,7 @@
package data package data
import ( import (
"strings"
"testing" "testing"
"github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
@@ -99,3 +100,306 @@ func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
t.Fatalf("SelectRpcNode() = %#v, want nil", got) t.Fatalf("SelectRpcNode() = %#v, want nil", got)
} }
} }
func TestSelectRpcNodeExcludesManualVerifyRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://manual.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://general.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed general rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://general.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want general row", got)
}
}
func TestSelectRpcNodePurposeFilterAppliesToAnyNodeType(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const nodeType = "grpc"
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "grpc://manual.example.com",
Type: nodeType,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "grpc://general.example.com",
Type: nodeType,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed general rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, nodeType)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "grpc://general.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want general grpc row", got)
}
}
func TestSelectRpcNodeManualVerifyOnlyReturnsNil(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://manual-only.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
func TestListManualPaymentRpcCandidatesGeneralBeforeManualVerify(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://general-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://general-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://manual-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusDown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
}
var urls []string
for _, row := range got {
urls = append(urls, row.Url)
}
want := []string{
"https://general-ok.example.com",
"https://general-unknown.example.com",
"https://manual-ok.example.com",
"https://manual-unknown.example.com",
}
if len(urls) != len(want) {
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
}
for i := range want {
if urls[i] != want[i] {
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
}
}
}
func TestListManualPaymentRpcCandidatesAllowsManualVerifyFallbackOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://manual-down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusDown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
}
if len(got) != 1 || got[0].Url != "https://manual-ok.example.com" {
t.Fatalf("candidate rows = %#v, want only manual ok fallback", got)
}
}
func TestSelectGeneralRpcNodeSkipsCoolingNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
first, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if first == nil || first.Url != "https://primary.example.com" {
t.Fatalf("first node = %#v, want primary", first)
}
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(first.ID)
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got == nil || got.Url != "https://backup.example.com" {
t.Fatalf("node after cooldown = %#v, want backup", got)
}
}
func TestSelectGeneralRpcNodeFallsBackWhenAllNodesCooling(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
for i := range rows {
for n := 0; n < RpcFailoverThreshold; n++ {
RecordRpcNodeFailure(rows[i].ID)
}
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got == nil || got.Url != "https://primary.example.com" {
t.Fatalf("SelectGeneralRpcNode() = %#v, want primary when every node is cooling", got)
}
}
func TestSelectGeneralRpcNodeReturnsNilWhenExcludedAlternatesCooling(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(rows[1].ID)
}
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, rows[0].ID)
if err != nil {
t.Fatalf("SelectGeneralRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectGeneralRpcNode() = %#v, want nil when only alternate is cooling", got)
}
}
func TestRecordRpcNodeSuccessClearsCooldown(t *testing.T) {
ResetRpcFailoverForTest()
t.Cleanup(ResetRpcFailoverForTest)
const nodeID uint64 = 42
for i := 0; i < RpcFailoverThreshold; i++ {
RecordRpcNodeFailure(nodeID)
}
if !IsRpcNodeCoolingDown(nodeID) {
t.Fatal("node should be cooling after threshold")
}
RecordRpcNodeSuccess(nodeID)
if IsRpcNodeCoolingDown(nodeID) {
t.Fatal("node should not be cooling after success")
}
failures, cooling := RecordRpcNodeFailure(nodeID)
if cooling || failures != 1 {
t.Fatalf("failure state after success = failures=%d cooling=%v, want failures=1 cooling=false", failures, cooling)
}
}
func TestRpcNodeLogLabelHidesPathAndQuery(t *testing.T) {
label := RpcNodeLogLabel(mdb.RpcNode{
BaseModel: mdb.BaseModel{ID: 7},
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeWs,
Url: "wss://rpc.example.com/v3/secret-key?token=secret",
})
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
t.Fatalf("RpcNodeLogLabel() leaked sensitive URL parts: %s", label)
}
if !strings.Contains(label, "wss://rpc.example.com") {
t.Fatalf("RpcNodeLogLabel() = %s, want host", label)
}
}
func TestListRpcNodesForHealthExcludesManualVerify(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://general.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkTron, Url: "https://manual.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := ListRpcNodesForHealth()
if err != nil {
t.Fatalf("ListRpcNodesForHealth(): %v", err)
}
if len(got) != 1 || got[0].Url != "https://general.example.com" {
t.Fatalf("ListRpcNodesForHealth() = %#v, want only general row", got)
}
}
+108
View File
@@ -0,0 +1,108 @@
package data
import (
"strings"
"sync"
"time"
)
// RpcRuntimeStatsSnapshot is an in-process snapshot of RPC activity for one
// chain. It is intentionally not persisted; restarting the process resets it.
type RpcRuntimeStatsSnapshot struct {
Network string
SuccessCount int64
FailureCount int64
LatestBlockHeight int64
LastSyncAt time.Time
}
type rpcRuntimeStatsCounter struct {
successCount int64
failureCount int64
latestBlockHeight int64
lastSyncAt time.Time
}
var gRpcRuntimeStats = struct {
sync.RWMutex
byNetwork map[string]*rpcRuntimeStatsCounter
}{
byNetwork: make(map[string]*rpcRuntimeStatsCounter),
}
func normalizeRpcStatsNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}
func rpcStatsCounterLocked(network string) *rpcRuntimeStatsCounter {
counter := gRpcRuntimeStats.byNetwork[network]
if counter == nil {
counter = &rpcRuntimeStatsCounter{}
gRpcRuntimeStats.byNetwork[network] = counter
}
return counter
}
// RecordRpcSuccess records one successful application-level RPC operation.
func RecordRpcSuccess(network string) {
network = normalizeRpcStatsNetwork(network)
if network == "" {
return
}
gRpcRuntimeStats.Lock()
defer gRpcRuntimeStats.Unlock()
counter := rpcStatsCounterLocked(network)
counter.successCount++
counter.lastSyncAt = time.Now()
}
// RecordRpcFailure records one failed application-level RPC operation.
func RecordRpcFailure(network string) {
network = normalizeRpcStatsNetwork(network)
if network == "" {
return
}
gRpcRuntimeStats.Lock()
defer gRpcRuntimeStats.Unlock()
counter := rpcStatsCounterLocked(network)
counter.failureCount++
}
// RecordRpcBlockHeight records the latest observed chain height.
func RecordRpcBlockHeight(network string, height int64) {
network = normalizeRpcStatsNetwork(network)
if network == "" || height <= 0 {
return
}
gRpcRuntimeStats.Lock()
defer gRpcRuntimeStats.Unlock()
counter := rpcStatsCounterLocked(network)
counter.lastSyncAt = time.Now()
if height > counter.latestBlockHeight {
counter.latestBlockHeight = height
}
}
// SnapshotRpcRuntimeStats returns a copy of the current per-chain counters.
func SnapshotRpcRuntimeStats() map[string]RpcRuntimeStatsSnapshot {
gRpcRuntimeStats.RLock()
defer gRpcRuntimeStats.RUnlock()
out := make(map[string]RpcRuntimeStatsSnapshot, len(gRpcRuntimeStats.byNetwork))
for network, counter := range gRpcRuntimeStats.byNetwork {
out[network] = RpcRuntimeStatsSnapshot{
Network: network,
SuccessCount: counter.successCount,
FailureCount: counter.failureCount,
LatestBlockHeight: counter.latestBlockHeight,
LastSyncAt: counter.lastSyncAt,
}
}
return out
}
// ResetRpcRuntimeStatsForTest clears process-local stats for isolated tests.
func ResetRpcRuntimeStatsForTest() {
gRpcRuntimeStats.Lock()
defer gRpcRuntimeStats.Unlock()
gRpcRuntimeStats.byNetwork = make(map[string]*rpcRuntimeStatsCounter)
}
+81
View File
@@ -0,0 +1,81 @@
package data
import (
"testing"
"time"
)
func TestRpcRuntimeStatsRecordsCountsAndLatestHeight(t *testing.T) {
ResetRpcRuntimeStatsForTest()
t.Cleanup(ResetRpcRuntimeStatsForTest)
start := time.Now()
RecordRpcSuccess(" TRON ")
RecordRpcSuccess("tron")
RecordRpcFailure("TRON")
RecordRpcBlockHeight("Tron", 100)
RecordRpcBlockHeight("tron", 90)
stats := SnapshotRpcRuntimeStats()
got, ok := stats["tron"]
if !ok {
t.Fatalf("missing tron stats: %#v", stats)
}
if got.Network != "tron" {
t.Fatalf("network = %q, want tron", got.Network)
}
if got.SuccessCount != 2 {
t.Fatalf("success count = %d, want 2", got.SuccessCount)
}
if got.FailureCount != 1 {
t.Fatalf("failure count = %d, want 1", got.FailureCount)
}
if got.LatestBlockHeight != 100 {
t.Fatalf("latest block height = %d, want 100", got.LatestBlockHeight)
}
if got.LastSyncAt.Before(start) {
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
}
}
func TestRpcRuntimeStatsIgnoresEmptyNetworkAndInvalidHeight(t *testing.T) {
ResetRpcRuntimeStatsForTest()
t.Cleanup(ResetRpcRuntimeStatsForTest)
RecordRpcSuccess("")
RecordRpcFailure(" ")
RecordRpcBlockHeight("tron", 0)
stats := SnapshotRpcRuntimeStats()
if len(stats) != 0 {
t.Fatalf("stats = %#v, want empty", stats)
}
}
func TestRpcRuntimeStatsBlockHeightUpdatesLastSyncAt(t *testing.T) {
ResetRpcRuntimeStatsForTest()
t.Cleanup(ResetRpcRuntimeStatsForTest)
start := time.Now()
RecordRpcBlockHeight("ethereum", 123)
stats := SnapshotRpcRuntimeStats()
got := stats["ethereum"]
if got.SuccessCount != 0 {
t.Fatalf("success count = %d, want 0", got.SuccessCount)
}
if got.LastSyncAt.Before(start) {
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
}
}
func TestResetRpcRuntimeStatsForTest(t *testing.T) {
ResetRpcRuntimeStatsForTest()
RecordRpcSuccess("tron")
ResetRpcRuntimeStatsForTest()
stats := SnapshotRpcRuntimeStats()
if len(stats) != 0 {
t.Fatalf("stats = %#v, want empty after reset", stats)
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ const (
) )
type Orders struct { type Orders struct {
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"` TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"` OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"` ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
+1 -1
View File
@@ -23,7 +23,7 @@ const (
// The main orders table remains the source of truth for merchant-facing state, // The main orders table remains the source of truth for merchant-facing state,
// while this table keeps provider-specific identifiers and callback payloads. // while this table keeps provider-specific identifiers and callback payloads.
type ProviderOrder struct { type ProviderOrder struct {
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"T2026041612345678"` TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
// Provider values should match the bound Orders.PayProvider value. // Provider values should match the bound Orders.PayProvider value.
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"` Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
// ProviderOrderID is the downstream provider's own order identifier for the // ProviderOrderID is the downstream provider's own order identifier for the
+10 -3
View File
@@ -13,9 +13,14 @@ const (
RpcNodeStatusDown = "down" RpcNodeStatusDown = "down"
) )
// RpcNode records one RPC endpoint for a network. Clients select a node const (
// by weight among rows where Enabled=true AND Status=ok. A periodic RpcNodePurposeGeneral = "general"
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt. RpcNodePurposeManualVerify = "manual_verify"
RpcNodePurposeBoth = "both"
)
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
type RpcNode struct { type RpcNode struct {
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"` Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"` Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
@@ -24,6 +29,8 @@ type RpcNode struct {
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"` Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"` ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"` Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
Purpose string `gorm:"column:purpose;size:32;default:general;index:rpc_nodes_purpose_index" json:"purpose" enums:"general,manual_verify,both" example:"general"`
// 健康状态 unknown=未知 ok=正常 down=异常 // 健康状态 unknown=未知 ok=正常 down=异常
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"` Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"` LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
+5 -5
View File
@@ -41,7 +41,7 @@ const (
SettingKeyBrandSupportUrl = "brand.support_url" SettingKeyBrandSupportUrl = "brand.support_url"
SettingKeyBrandBackgroundColor = "brand.background_color" SettingKeyBrandBackgroundColor = "brand.background_color"
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url" SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate" SettingKeyRateForcedRateList = "rate.forced_rate_list"
SettingKeyRateAdjustPercent = "rate.adjust_percent" SettingKeyRateAdjustPercent = "rate.adjust_percent"
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled" SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
SettingKeyRateApiUrl = "rate.api_url" SettingKeyRateApiUrl = "rate.api_url"
@@ -64,10 +64,10 @@ const (
type Setting struct { type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"` Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"` Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_rate_list"`
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"` Value string `gorm:"column:value;type:text" json:"value" example:"{\"cny\":{\"usdt\":0.14635}}"`
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"` Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"json"`
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"` Description string `gorm:"column:description;size:255" json:"description" example:"强制汇率列表"`
BaseModel BaseModel
} }
+12 -1
View File
@@ -39,9 +39,20 @@ type OrderProcessingRequest struct {
BlockTransactionId string BlockTransactionId string
} }
// ManualPaymentRequest 手动提交交易 hash 补单
type ManualPaymentRequest struct {
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
}
func (r ManualPaymentRequest) Translates() map[string]string {
return validate.MS{
"BlockTransactionId": "交易哈希",
}
}
// SwitchNetworkRequest 切换支付网络 // SwitchNetworkRequest 切换支付网络
type SwitchNetworkRequest struct { type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"` TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
Token string `json:"token" validate:"required" example:"USDT"` Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"` Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
} }
+3
View File
@@ -4,8 +4,11 @@ package response
// Used for swagger documentation only — the actual response is built // Used for swagger documentation only — the actual response is built
// by util/http.Resp. // by util/http.Resp.
type ApiResponse struct { type ApiResponse struct {
// Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
StatusCode int `json:"status_code" example:"200"` StatusCode int `json:"status_code" example:"200"`
// Default server-side errno text. Frontend should prefer status_code for display translation.
Message string `json:"message" example:"success"` Message string `json:"message" example:"success"`
Data interface{} `json:"data"` Data interface{} `json:"data"`
// Request id echoed from X-Request-ID when present.
RequestID string `json:"request_id" example:"req-123456"` RequestID string `json:"request_id" example:"req-123456"`
} }
+4 -4
View File
@@ -2,7 +2,7 @@ package response
// CreateTransactionResponse 创建订单成功返回 // CreateTransactionResponse 创建订单成功返回
type CreateTransactionResponse struct { type CreateTransactionResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD...... Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
@@ -10,13 +10,13 @@ type CreateTransactionResponse struct {
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址 PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
} }
// OrderNotifyResponse 订单异步回调结构体 // OrderNotifyResponse 订单异步回调结构体
type OrderNotifyResponse struct { type OrderNotifyResponse struct {
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签 Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
@@ -31,7 +31,7 @@ type OrderNotifyResponse struct {
// OrderNotifyResponseEpay epay订单异步回调结构体 // OrderNotifyResponseEpay epay订单异步回调结构体
type OrderNotifyResponseEpay struct { type OrderNotifyResponseEpay struct {
PID int `json:"pid" example:"1001"` // 商户ID PID int `json:"pid" example:"1001"` // 商户ID
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号 TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号 OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
Type string `json:"type" example:"usdt"` // 订单类型 Type string `json:"type" example:"usdt"` // 订单类型
Name string `json:"name" example:"VIP月卡"` // 商品名称 Name string `json:"name" example:"VIP月卡"` // 商品名称
+9 -3
View File
@@ -1,7 +1,7 @@
package response package response
type CheckoutCounterResponse struct { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
@@ -10,13 +10,19 @@ type CheckoutCounterResponse struct {
Network string `json:"network" example:"tron"` // 网络 TRON ETH ... Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳 CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"` IsSelected bool `json:"is_selected" example:"false"`
} }
type CheckStatusResponse struct { type CheckStatusResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
// 订单状态 1=等待支付 2=支付成功 3=已过期 // 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"1"` Status int `json:"status" enums:"1,2,3" example:"1"`
} }
type ManualPaymentResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Status int `json:"status" enums:"1,2,3" example:"2"` // 订单状态 1=等待支付 2=支付成功 3=已过期
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123def456..."` // 已验证并入账的交易哈希
}
+142 -39
View File
@@ -54,7 +54,9 @@ func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string,
if fn == nil { if fn == nil {
manualOrderPaymentValidator = validateManualOrderPaymentDefault manualOrderPaymentValidator = validateManualOrderPaymentDefault
} else { } else {
manualOrderPaymentValidator = fn manualOrderPaymentValidator = func(order *mdb.Orders, blockTransactionID string) (string, error) {
return fn(order, blockTransactionID)
}
} }
manualOrderPaymentValidatorMu.Unlock() manualOrderPaymentValidatorMu.Unlock()
return func() { return func() {
@@ -282,25 +284,25 @@ func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainRead
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) { func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
var clients []manualEvmClient var clients []manualEvmClient
var connectErrors []string var connectErrors []string
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
node, err := data.SelectRpcNode(network, nodeType) nodes, err := listManualEvmRpcCandidates(network)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" { for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue continue
} }
rpcURL := strings.TrimSpace(node.Url) client, err := dialManualEvmClient(ctx, node)
client, err := ethclient.DialContext(ctx, rpcURL)
if err == nil { if err == nil {
clients = append(clients, manualEvmClient{ clients = append(clients, manualEvmClient{
label: fmt.Sprintf("%s %s", nodeType, rpcURL), label: manualRpcNodeLabel(node),
reader: client, reader: client,
close: client.Close, close: client.Close,
}) })
continue continue
} }
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err)) connectErrors = append(connectErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
} }
if len(clients) > 0 { if len(clients) > 0 {
return clients, nil return clients, nil
@@ -311,6 +313,49 @@ func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClien
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network) return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
} }
func listManualEvmRpcCandidates(network string) ([]mdb.RpcNode, error) {
buckets := make([][]mdb.RpcNode, 4)
for _, nodeType := range []string{mdb.RpcNodeTypeHttp, mdb.RpcNodeTypeWs} {
nodes, err := data.ListManualPaymentRpcCandidates(network, nodeType)
if err != nil {
return nil, err
}
for _, node := range nodes {
node.Purpose = data.NormalizeRpcNodePurpose(node.Purpose)
switch node.Purpose {
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
if node.Status == mdb.RpcNodeStatusOk {
buckets[0] = append(buckets[0], node)
} else {
buckets[1] = append(buckets[1], node)
}
case mdb.RpcNodePurposeManualVerify:
if node.Status == mdb.RpcNodeStatusOk {
buckets[2] = append(buckets[2], node)
} else {
buckets[3] = append(buckets[3], node)
}
}
}
}
out := make([]mdb.RpcNode, 0)
for _, bucket := range buckets {
out = append(out, bucket...)
}
return out, nil
}
func dialManualEvmClient(ctx context.Context, node mdb.RpcNode) (*ethclient.Client, error) {
rpcURL := strings.TrimSpace(node.Url)
return ethclient.DialContext(ctx, rpcURL)
}
func manualRpcNodeLabel(node mdb.RpcNode) string {
purpose := data.NormalizeRpcNodePurpose(node.Purpose)
return fmt.Sprintf("%s purpose=%s", data.RpcNodeLogLabel(node), purpose)
}
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error { func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
if order == nil { if order == nil {
return fmt.Errorf("order not found") return fmt.Errorf("order not found")
@@ -398,55 +443,77 @@ func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
return "", err return "", err
} }
baseURL, apiKey, err := ResolveTronNode() nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
if strings.TrimSpace(node.Url) == "" {
continue
}
if err = validateManualTronPaymentWithNode(order, normalizedTxID, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
continue
}
return normalizedTxID, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual TRON verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
func validateManualTronPaymentWithNode(order *mdb.Orders, normalizedTxID string, node mdb.RpcNode) error {
baseURL := strings.TrimSpace(node.Url)
var tx manualTronTransaction var tx manualTronTransaction
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil { if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
return "", fmt.Errorf("fetch tron transaction: %w", err) return fmt.Errorf("fetch tron transaction: %w", err)
} }
if strings.TrimSpace(tx.TxID) == "" { if strings.TrimSpace(tx.TxID) == "" {
return "", fmt.Errorf("transaction not found") return fmt.Errorf("transaction not found")
} }
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) { if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
return "", fmt.Errorf("transaction id mismatch") return fmt.Errorf("transaction id mismatch")
} }
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" { if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet) return fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
} }
var info manualTronTxInfo var info manualTronTxInfo
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil { if err := tronPostJSON(baseURL, node.ApiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
return "", fmt.Errorf("fetch tron transaction info: %w", err) return fmt.Errorf("fetch tron transaction info: %w", err)
} }
if info.BlockNumber <= 0 { if info.BlockNumber <= 0 {
return "", fmt.Errorf("transaction is not confirmed") return fmt.Errorf("transaction is not confirmed")
} }
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" { if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result) return fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
} }
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil { if err := ensureTronConfirmations(baseURL, node.ApiKey, order.Network, info.BlockNumber); err != nil {
return "", err return err
} }
if info.BlockTimeStamp <= 0 { if info.BlockTimeStamp <= 0 {
return "", fmt.Errorf("transaction block timestamp missing") return fmt.Errorf("transaction block timestamp missing")
} }
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() { if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
return "", fmt.Errorf("transaction predates the order") return fmt.Errorf("transaction predates the order")
} }
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") { if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
if err = validateManualTronNativeTransfer(order, &tx); err != nil { if err := validateManualTronNativeTransfer(order, &tx); err != nil {
return "", err return err
} }
return normalizedTxID, nil return nil
} }
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil { if err := validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
return "", err return err
} }
return normalizedTxID, nil return nil
} }
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error { func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
@@ -571,20 +638,48 @@ func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) { func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
sig = strings.TrimSpace(sig) sig = strings.TrimSpace(sig)
txData, err := SolGetTransaction(sig) nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch solana transaction: %w", err) return "", err
}
if len(nodes) == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
var verifyErrors []string
for _, node := range nodes {
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
continue
}
if err = validateManualSolanaPaymentWithRPC(order, sig, node); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
continue
}
return sig, nil
}
if len(verifyErrors) > 0 {
return "", fmt.Errorf("manual Solana verification failed: %s", strings.Join(verifyErrors, "; "))
}
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
func validateManualSolanaPaymentWithRPC(order *mdb.Orders, sig string, node mdb.RpcNode) error {
rpcURL := strings.TrimSpace(node.Url)
txData, err := solGetTransactionWithURL(rpcURL, sig)
if err != nil {
return fmt.Errorf("fetch solana transaction: %w", err)
} }
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null { if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
return "", fmt.Errorf("transaction not found") return fmt.Errorf("transaction not found")
} }
if err = ensureSolanaConfirmations(order.Network, sig); err != nil { if err = ensureSolanaConfirmationsWithRPC(order.Network, sig, rpcURL); err != nil {
return "", err return err
} }
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana) tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil { if err != nil {
return "", err return err
} }
mintTokens := make(map[string]*mdb.ChainToken, len(tokens)) mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken var nativeSolToken *mdb.ChainToken
@@ -615,17 +710,17 @@ func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error)
continue continue
} }
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil { if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
return "", err return err
} }
if amountMatchesFloat(order.ActualAmount, amount) { if amountMatchesFloat(order.ActualAmount, amount) {
return sig, nil return nil
} }
amountMismatch = true amountMismatch = true
} }
if amountMismatch { if amountMismatch {
return "", fmt.Errorf("transaction amount mismatch") return fmt.Errorf("transaction amount mismatch")
} }
return "", fmt.Errorf("matching solana transfer to order address not found") return fmt.Errorf("matching solana transfer to order address not found")
} }
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error { func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
@@ -642,6 +737,14 @@ func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) erro
} }
func ensureSolanaConfirmations(network, sig string) error { func ensureSolanaConfirmations(network, sig string) error {
rpcURL, err := resolveSolanaRpcURL()
if err != nil {
return err
}
return ensureSolanaConfirmationsWithRPC(network, sig, rpcURL)
}
func ensureSolanaConfirmationsWithRPC(network, sig string, rpcURL string) error {
chain, err := data.GetChainByNetwork(network) chain, err := data.GetChainByNetwork(network)
if err != nil { if err != nil {
return err return err
@@ -654,7 +757,7 @@ func ensureSolanaConfirmations(network, sig string) error {
return nil return nil
} }
body, err := SolRetryClient("getSignatureStatuses", []interface{}{ body, err := solRetryClientWithURL(rpcURL, "getSignatureStatuses", []interface{}{
[]string{sig}, []string{sig},
map[string]interface{}{"searchTransactionHistory": true}, map[string]interface{}{"searchTransactionHistory": true},
}) })
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -117,6 +118,154 @@ func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
} }
} }
func TestManualVerifyEvmCandidatesPreferGeneralAcrossNodeTypes(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeHttp, Url: "http://manual.example.com", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeWs, Url: "ws://general.example.com", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := listManualEvmRpcCandidates(mdb.NetworkEthereum)
if err != nil {
t.Fatalf("listManualEvmRpcCandidates(): %v", err)
}
urls := make([]string, 0, len(got))
for _, node := range got {
urls = append(urls, node.Url)
}
want := []string{"ws://general.example.com", "http://manual.example.com"}
if len(urls) != len(want) {
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
}
for i := range want {
if urls[i] != want[i] {
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
}
}
}
func TestManualRpcNodeLabelHidesSensitiveURLParts(t *testing.T) {
label := manualRpcNodeLabel(mdb.RpcNode{
BaseModel: mdb.BaseModel{ID: 9},
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeHttp,
Url: "https://rpc.example.com/v3/secret-key?token=secret",
Purpose: mdb.RpcNodePurposeManualVerify,
})
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
t.Fatalf("manualRpcNodeLabel() leaked sensitive URL parts: %s", label)
}
if !strings.Contains(label, "endpoint=https://rpc.example.com") {
t.Fatalf("manualRpcNodeLabel() = %s, want sanitized endpoint", label)
}
if !strings.Contains(label, "purpose=manual_verify") {
t.Fatalf("manualRpcNodeLabel() = %s, want purpose", label)
}
}
func TestManualVerifyDialEvmClientDoesNotForwardUserIPHeader(t *testing.T) {
var headerCalls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-USER-IP"); got != "" {
t.Errorf("X-USER-IP header = %q, want empty", got)
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
return
}
atomic.AddInt32(&headerCalls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32000,
"message": "not found",
},
})
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeHttp,
Url: server.URL,
Purpose: mdb.RpcNodePurposeManualVerify,
})
if err != nil {
t.Fatalf("dialManualEvmClient(): %v", err)
}
defer client.Close()
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
if got := atomic.LoadInt32(&headerCalls); got == 0 {
t.Fatal("manual verify EVM request did not reach test RPC")
}
}
func TestManualVerifyDialEvmClientDoesNotForwardUserIPForGeneralNode(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-USER-IP"); got != "" {
t.Errorf("X-USER-IP header = %q, want empty", got)
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
return
}
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32000,
"message": "not found",
},
})
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
Network: mdb.NetworkEthereum,
Type: mdb.RpcNodeTypeHttp,
Url: server.URL,
Purpose: mdb.RpcNodePurposeGeneral,
})
if err != nil {
t.Fatalf("dialManualEvmClient(): %v", err)
}
defer client.Close()
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
if got := atomic.LoadInt32(&calls); got == 0 {
t.Fatal("general EVM request did not reach test RPC")
}
}
func TestTronPostJSONDoesNotForwardUserIPWithoutHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-USER-IP"); got != "" {
t.Errorf("X-USER-IP header = %q, want empty", got)
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
var out map[string]interface{}
if err := tronPostJSON(server.URL, "", "/wallet/getnowblock", map[string]interface{}{}, &out); err != nil {
t.Fatalf("tronPostJSON(): %v", err)
}
}
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) { func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}} order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
txTime := uint64(time.Now().Add(-time.Hour).Unix()) txTime := uint64(time.Now().Add(-time.Hour).Unix())
@@ -271,7 +420,12 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
rawAmount := big.NewInt(1230000) rawAmount := big.NewInt(1230000)
blockTimeMs := time.Now().Add(time.Minute).UnixMilli() blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
requestCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-USER-IP"); got != "" {
t.Errorf("X-USER-IP header = %q, want empty", got)
}
requestCalls++
var req map[string]string var req map[string]string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode tron request: %v", err) t.Errorf("decode tron request: %v", err)
@@ -333,6 +487,7 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
Url: server.URL, Url: server.URL,
Enabled: true, Enabled: true,
Status: mdb.RpcNodeStatusOk, Status: mdb.RpcNodeStatusOk,
Purpose: mdb.RpcNodePurposeManualVerify,
}).Error; err != nil { }).Error; err != nil {
t.Fatalf("create tron rpc node: %v", err) t.Fatalf("create tron rpc node: %v", err)
} }
@@ -366,6 +521,9 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
if got != txID { if got != txID {
t.Fatalf("canonical tx id = %q, want %q", got, txID) t.Fatalf("canonical tx id = %q, want %q", got, txID)
} }
if requestCalls != 3 {
t.Fatalf("manual verify request calls = %d, want 3", requestCalls)
}
} }
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) { func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
+4
View File
@@ -268,10 +268,14 @@ func HandleOkPayNotify(form map[string]string, rawFormData string) error {
TradeId: order.TradeId, TradeId: order.TradeId,
BlockTransactionId: payload.OrderID, BlockTransactionId: payload.OrderID,
}) })
processed := err == nil
if err != nil && err != constant.OrderBlockAlreadyProcess { if err != nil && err != constant.OrderBlockAlreadyProcess {
return err return err
} }
if processed {
sendPaymentNotification(order)
}
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil { if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
return err return err
} }
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
)
func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
channelType = "test-okpay-pay-success"
shopID = "okpay-shop-notify"
shopToken = "okpay-shop-token"
tradeID = "okpay-notify-success-001"
orderID = "merchant-okpay-notify-success-001"
providerOrderID = "okpay-provider-success-001"
rawFormData = "raw-okpay-form"
)
got := make(chan string, 1)
notify.RegisterSender(channelType, func(config, text string) error {
got <- text
return nil
})
if err := dao.Mdb.Create(&mdb.NotificationChannel{
Type: channelType,
Name: "test okpay pay success",
Config: "{}",
Events: `{"pay_success":true}`,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed notification channel: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop token: %v", err)
}
order := &mdb.Orders{
TradeId: tradeID,
OrderId: orderID,
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := dao.Mdb.Create(&mdb.ProviderOrder{
TradeId: tradeID,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: providerOrderID,
Amount: 0.15,
Coin: "USDT",
Status: mdb.ProviderOrderStatusPending,
}).Error; err != nil {
t.Fatalf("seed provider order: %v", err)
}
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, "0.15000000", "USDT")
if err := HandleOkPayNotify(form, rawFormData); err != nil {
t.Fatalf("handle okpay notify: %v", err)
}
select {
case text := <-got:
if !strings.Contains(text, tradeID) {
t.Fatalf("notification text = %q, want trade id %s", text, tradeID)
}
if !strings.Contains(text, orderID) {
t.Fatalf("notification text = %q, want order id %s", text, orderID)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for payment notification")
}
if err := HandleOkPayNotify(form, rawFormData); err != nil {
t.Fatalf("handle duplicate okpay notify: %v", err)
}
select {
case text := <-got:
t.Fatalf("duplicate notification sent: %q", text)
case <-time.After(200 * time.Millisecond):
}
}
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
form := map[string]string{
"code": "200",
"id": shopID,
"status": "success",
"data[order_id]": providerOrderID,
"data[unique_id]": tradeID,
"data[pay_user_id]": "123456789",
"data[amount]": amount,
"data[coin]": coin,
"data[status]": "1",
"data[type]": "deposit",
}
form["sign"] = okPayNotifySign(form, shopToken)
return form
}
+104 -10
View File
@@ -1,12 +1,12 @@
package service package service
import ( import (
cryptorand "crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"strings" "strings"
"sync" "sync"
"time"
"github.com/GMWalletApp/epusdt/config" "github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
@@ -17,6 +17,7 @@ import (
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/GMWalletApp/epusdt/util/security"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -54,12 +55,17 @@ func normalizeOrderAddressByNetwork(network, address string) string {
// CreateTransaction creates a new payment order. // CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) { func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token)) token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency)) currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network)) network := strings.ToLower(strings.TrimSpace(req.Network))
notifyURL := strings.TrimSpace(req.NotifyUrl)
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
return nil, constant.NotifyURLErr
}
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
amountPrecision := data.GetAmountPrecision() amountPrecision := data.GetAmountPrecision()
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision) payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
@@ -116,7 +122,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
Token: token, Token: token,
Network: network, Network: network,
Status: mdb.StatusWaitPay, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl, NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
PaymentType: req.PaymentType, PaymentType: req.PaymentType,
@@ -316,9 +322,11 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
// GenerateCode creates a unique trade id. // GenerateCode creates a unique trade id.
func GenerateCode() string { func GenerateCode() string {
date := time.Now().Format("20060102") buf := make([]byte, 18)
r := rand.Intn(1000) if _, err := cryptorand.Read(buf); err != nil {
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
}
return base64.RawURLEncoding.EncodeToString(buf)
} }
// GetOrderInfoByTradeId returns a validated order. // GetOrderInfoByTradeId returns a validated order.
@@ -333,6 +341,92 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
return order, nil return order, nil
} }
// SubmitManualPayment verifies a submitted transaction hash and marks the
// matching on-chain order paid using the same path as automatic chain scans.
func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
tradeId = strings.TrimSpace(tradeId)
blockTransactionId = strings.TrimSpace(blockTransactionId)
order, err := GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
return submitManualPaymentForOrder(order, blockTransactionId)
}
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
// already stored on any order before touching RPC so repeated/public probes do
// not spend RPC quota. Admin mark-paid intentionally keeps using
// SubmitManualPayment and the existing verification path.
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
tradeId = strings.TrimSpace(tradeId)
blockTransactionId = strings.TrimSpace(blockTransactionId)
order, err := GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if err = validateManualPaymentOrder(order); err != nil {
return nil, err
}
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
return nil, err
}
return submitManualPaymentForOrder(order, blockTransactionId)
}
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
blockTransactionId = strings.TrimSpace(blockTransactionId)
if err := validateManualPaymentOrder(order); err != nil {
return nil, err
}
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
if err != nil {
var rspErr *constant.RspError
if errors.As(err, &rspErr) {
return nil, err
}
return nil, constant.ManualPaymentVerifyErr
}
if err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: verifiedBlockTransactionID,
}); err != nil {
return nil, err
}
updatedOrder, err := GetOrderInfoByTradeId(order.TradeId)
if err != nil {
return nil, err
}
return &response.ManualPaymentResponse{
TradeId: updatedOrder.TradeId,
Status: updatedOrder.Status,
BlockTransactionId: updatedOrder.BlockTransactionId,
}, nil
}
func validateManualPaymentOrder(order *mdb.Orders) error {
if order.Status != mdb.StatusWaitPay {
return constant.OrderNotWaitPay
}
if !isOnChainOrder(order.PayProvider) {
return constant.ManualPaymentProviderErr
}
return nil
}
func isOnChainOrder(payProvider string) bool {
payProvider = strings.TrimSpace(payProvider)
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
const MaxSubOrders = 2 const MaxSubOrders = 2
// SwitchNetwork creates or returns an existing sub-order for a different token+network. // SwitchNetwork creates or returns an existing sub-order for a different token+network.
@@ -588,7 +682,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
if err != nil { if err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay) _ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID) _ = data.ExpireOrderByTradeID(subTradeID)
return nil, err return nil, constant.PaymentProviderCreateErr
} }
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil { if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay) _ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
+40 -5
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -26,7 +27,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
Token: "USDT", Token: "USDT",
Network: "tron", Network: "tron",
Amount: amount, Amount: amount,
NotifyUrl: "https://merchant.example/callback", NotifyUrl: "https://93.184.216.34/callback",
} }
} }
@@ -36,6 +37,31 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req) return f(req)
} }
func TestGenerateCodeFormat(t *testing.T) {
tradeID := GenerateCode()
if len(tradeID) != 24 {
t.Fatalf("trade id length = %d, want 24", len(tradeID))
}
if strings.Contains(tradeID, "=") {
t.Fatalf("trade id = %q, contains padding '='", tradeID)
}
for _, ch := range tradeID {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
continue
}
t.Fatalf("trade id = %q, contains non URL-safe character %q", tradeID, ch)
}
decoded, err := base64.RawURLEncoding.DecodeString(tradeID)
if err != nil {
t.Fatalf("decode trade id %q: %v", tradeID, err)
}
if len(decoded) != 18 {
t.Fatalf("decoded trade id length = %d, want 18", len(decoded))
}
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) { func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper() t.Helper()
@@ -50,6 +76,15 @@ func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
}) })
} }
func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
req := newCreateTransactionRequest("order_private_notify_url", 1)
req.NotifyUrl = "http://127.0.0.1/notify"
if _, err := CreateTransaction(req, nil); err != constant.NotifyURLErr {
t.Fatalf("CreateTransaction error = %v, want %v", err, constant.NotifyURLErr)
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -167,8 +202,8 @@ func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T
}, nil }, nil
}) })
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err) t.Fatalf("set rate.forced_rate_list: %v", err)
} }
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil { if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
t.Fatalf("set rate.api_url: %v", err) t.Fatalf("set rate.api_url: %v", err)
@@ -190,8 +225,8 @@ func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositi
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err) t.Fatalf("set rate.forced_rate_list: %v", err)
} }
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil { if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
t.Fatalf("clear rate.api_url: %v", err) t.Fatalf("clear rate.api_url: %v", err)
+155 -18
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"net/http"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -26,6 +27,14 @@ import (
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction // gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
var gProcessedSignatures sync.Map // sig -> unix timestamp var gProcessedSignatures sync.Map // sig -> unix timestamp
var processSolanaOrder = OrderProcessing
var (
solRPCRetryCount = 5
solRPCRetryWait = 2 * time.Second
solRPCRetryMaxWait = 10 * time.Second
)
type TransferInfo struct { type TransferInfo struct {
Source string // Source address (for SOL) or source ATA (for SPL tokens) Source string // Source address (for SOL) or source ATA (for SPL tokens)
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens) Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
@@ -150,6 +159,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
// Process each transaction signature // Process each transaction signature
for sigIdx, txSig := range result { for sigIdx, txSig := range result {
sig := txSig.Signature sig := txSig.Signature
retrySignature := false
// Skip failed transactions // Skip failed transactions
if txSig.Err != nil { if txSig.Err != nil {
@@ -213,6 +223,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount) tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
if err != nil { if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err) log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
retrySignature = true
continue continue
} }
if tradeID == "" { if tradeID == "" {
@@ -226,6 +237,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
order, err := data.GetOrderInfoByTradeId(tradeID) order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err) log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
retrySignature = true
continue continue
} }
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d", log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
@@ -252,13 +264,14 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
} }
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f", log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
address, tradeID, sig, token, amount) address, tradeID, sig, token, amount)
err = OrderProcessing(req) err = processSolanaOrder(req)
if err != nil { if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err) log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
continue continue
} }
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err) log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
retrySignature = true
continue continue
} }
@@ -268,6 +281,11 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig) log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
} }
if retrySignature {
log.Sugar.Debugf("[SOL][%s] sig=%s not marked processed because retryable processing failed", address, sig)
continue
}
// 标记已处理 // 标记已处理
gProcessedSignatures.Store(sig, time.Now().Unix()) gProcessedSignatures.Store(sig, time.Now().Unix())
} }
@@ -282,26 +300,82 @@ type solSignatureResult struct {
} }
func resolveSolanaRpcURL() (string, error) { func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp) node, err := resolveSolanaRpcNode()
if err != nil { if err != nil {
return "", err return "", err
} }
if node == nil || node.ID == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url) rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil return rpcURL, nil
} }
func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 {
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
node.Url = rpcURL
return node, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试 // SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) { func SolRetryClient(method string, params []interface{}) (body []byte, err error) {
defer func() {
if err != nil {
data.RecordRpcFailure(mdb.NetworkSolana)
return
}
data.RecordRpcSuccess(mdb.NetworkSolana)
}()
tried := make([]uint64, 0, 3)
var lastErr error
for attempts := 0; attempts < 3; attempts++ {
node, err := resolveSolanaRpcNode(tried...)
if err != nil {
if lastErr != nil {
return nil, lastErr
}
return nil, err
}
if len(tried) > 0 {
log.Sugar.Warnf("[SOL] trying alternate RPC node method=%s node=%s", method, data.RpcNodeLogLabel(*node))
}
body, err := solRetryClientWithURL(node.Url, method, params)
if err == nil {
data.RecordRpcNodeSuccess(node.ID)
return body, nil
}
lastErr = err
failures, cooling := data.RecordRpcNodeFailure(node.ID)
nodeLabel := data.RpcNodeLogLabel(*node)
if !cooling {
log.Sugar.Warnf("[SOL] RPC node failed method=%s node=%s failures=%d/%d", method, nodeLabel, failures, data.RpcFailoverThreshold)
return nil, err
}
log.Sugar.Warnf("[SOL] RPC node reached fail threshold method=%s node=%s, trying another node", method, nodeLabel)
tried = append(tried, node.ID)
}
return nil, lastErr
}
func solRetryClientWithURL(rpcUrl string, method string, params []interface{}) ([]byte, error) {
return solRetryClientWithURLHeaders(rpcUrl, method, params, nil)
}
func solRetryClientWithURLHeaders(rpcUrl string, method string, params []interface{}, headers http.Header) ([]byte, error) {
client := resty.New() client := resty.New()
client.SetRetryCount(5) client.SetRetryCount(solRPCRetryCount)
client.SetRetryWaitTime(2 * time.Second) client.SetRetryWaitTime(solRPCRetryWait)
client.SetRetryMaxWaitTime(10 * time.Second) client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
client.AddRetryCondition(func(r *resty.Response, err error) bool { client.AddRetryCondition(func(r *resty.Response, err error) bool {
if err != nil { if err != nil {
return true return true
@@ -312,14 +386,14 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false return false
}) })
rpcUrl, err := resolveSolanaRpcURL() req := client.R().SetHeader("Content-Type", "application/json")
if err != nil { for key, values := range headers {
return nil, err for _, value := range values {
req.SetHeader(key, value)
}
} }
resp, err := client.R(). resp, err := req.SetBody(map[string]interface{}{
SetHeader("Content-Type", "application/json").
SetBody(map[string]interface{}{
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 1, "id": 1,
"method": method, "method": method,
@@ -329,8 +403,18 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode() >= http.StatusBadRequest {
return nil, fmt.Errorf("solana rpc HTTP %d", resp.StatusCode())
}
respBody := resp.Body() respBody := resp.Body()
if errValue := gjson.GetBytes(respBody, "error"); errValue.Exists() && errValue.Type != gjson.Null {
code := errValue.Get("code").String()
if code == "" {
code = "unknown"
}
return nil, fmt.Errorf("solana rpc error code=%s", code)
}
return respBody, nil return respBody, nil
} }
@@ -367,6 +451,32 @@ func SolGetSignaturesForAddress(address string, limit int, untilSig string, befo
return bodyData, nil return bodyData, nil
} }
func SolGetBlockHeight() (height int64, err error) {
defer func() {
if err != nil {
data.RecordRpcFailure(mdb.NetworkSolana)
return
}
data.RecordRpcSuccess(mdb.NetworkSolana)
}()
rpcURL, err := resolveSolanaRpcURL()
if err != nil {
return 0, err
}
bodyData, err := solRetryClientWithURL(rpcURL, "getBlockHeight", []interface{}{
map[string]interface{}{"commitment": "finalized"},
})
if err != nil {
return 0, err
}
height = gjson.GetBytes(bodyData, "result").Int()
if height <= 0 {
return 0, fmt.Errorf("unexpected getBlockHeight response: %s", string(bodyData))
}
return height, nil
}
func SolGetTransaction(sig string) ([]byte, error) { func SolGetTransaction(sig string) ([]byte, error) {
txData, err := SolRetryClient("getTransaction", []interface{}{ txData, err := SolRetryClient("getTransaction", []interface{}{
sig, sig,
@@ -390,6 +500,33 @@ func SolGetTransaction(sig string) ([]byte, error) {
return txData, nil return txData, nil
} }
func solGetTransactionWithURL(rpcURL string, sig string) ([]byte, error) {
return solGetTransactionWithURLHeaders(rpcURL, sig, nil)
}
func solGetTransactionWithURLHeaders(rpcURL string, sig string, headers http.Header) ([]byte, error) {
txData, err := solRetryClientWithURLHeaders(rpcURL, "getTransaction", []interface{}{
sig,
map[string]interface{}{
"encoding": "jsonParsed",
"commitment": "confirmed",
"maxSupportedTransactionVersion": 0, // suport
},
}, headers)
if err != nil {
log.Sugar.Errorf("SolRetryClient failed: %v", err)
return nil, err
}
errField := gjson.GetBytes(txData, "result.meta.err")
if errField.Exists() && errField.Type != gjson.Null {
log.Sugar.Warnf("Transaction failed: %v", errField.String())
return nil, fmt.Errorf("transaction failed: %s", errField.String())
}
return txData, nil
}
// isTransferToAddress 判断转账目标是否为指定钱包地址 // isTransferToAddress 判断转账目标是否为指定钱包地址
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool { func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
// Native SOL transfer - check destination directly // Native SOL transfer - check destination directly
+452
View File
@@ -2,12 +2,19 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os" "os"
"sync"
"testing" "testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -21,6 +28,7 @@ func setupSolanaRPCNode(t *testing.T, url string) func() {
Type: mdb.RpcNodeTypeHttp, Type: mdb.RpcNodeTypeHttp,
Weight: 1, Weight: 1,
Enabled: true, Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk, Status: mdb.RpcNodeStatusOk,
} }
if err := dao.Mdb.Create(node).Error; err != nil { if err := dao.Mdb.Create(node).Error; err != nil {
@@ -52,6 +60,443 @@ func TestResolveSolanaRpcURLWithRow(t *testing.T) {
} }
} }
func TestResolveSolanaRpcURLIgnoresManualVerifyOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://paid-solana.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if got, err := resolveSolanaRpcURL(); err == nil {
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
}
}
func TestResolveSolanaRpcURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkSolana, Url: "https://paid-solana.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkSolana, Url: " https://general-solana.example.com ", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, err := resolveSolanaRpcURL()
if err != nil {
t.Fatalf("resolveSolanaRpcURL(): %v", err)
}
if got != "https://general-solana.example.com" {
t.Fatalf("resolveSolanaRpcURL() = %q, want general node", got)
}
}
func TestSolGetBlockHeightDoesNotAffectRpcNodeFailover(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
oldRetryCount := solRPCRetryCount
solRPCRetryCount = 0
t.Cleanup(func() {
solRPCRetryCount = oldRetryCount
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "temporary", http.StatusBadGateway)
}))
defer server.Close()
node := &mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: server.URL,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed solana rpc_node: %v", err)
}
for i := 0; i < data.RpcFailoverThreshold+1; i++ {
if _, err := SolGetBlockHeight(); err == nil {
t.Fatalf("SolGetBlockHeight attempt %d unexpectedly succeeded", i+1)
}
}
if data.IsRpcNodeCoolingDown(node.ID) {
t.Fatal("SolGetBlockHeight failures should not affect rpc_node failover cooldown")
}
stats := data.SnapshotRpcRuntimeStats()
if got := stats[mdb.NetworkSolana].FailureCount; got != int64(data.RpcFailoverThreshold+1) {
t.Fatalf("solana runtime failure_count = %d, want %d", got, data.RpcFailoverThreshold+1)
}
}
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
oldRetryCount := solRPCRetryCount
oldRetryWait := solRPCRetryWait
oldRetryMaxWait := solRPCRetryMaxWait
solRPCRetryCount = 0
solRPCRetryWait = time.Millisecond
solRPCRetryMaxWait = time.Millisecond
t.Cleanup(func() {
solRPCRetryCount = oldRetryCount
solRPCRetryWait = oldRetryWait
solRPCRetryMaxWait = oldRetryMaxWait
})
var primaryCalls int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
primaryCalls++
http.Error(w, "temporary", http.StatusBadGateway)
}))
defer primary.Close()
var backupCalls int
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": "ok",
})
}))
defer backup.Close()
rows := []mdb.RpcNode{
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
if _, err := SolRetryClient("getHealth", nil); err == nil {
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
}
}
if backupCalls != 0 {
t.Fatalf("backup calls before threshold = %d, want 0", backupCalls)
}
body, err := SolRetryClient("getHealth", nil)
if err != nil {
t.Fatalf("SolRetryClient after threshold: %v", err)
}
if gjson.GetBytes(body, "result").String() != "ok" {
t.Fatalf("response body = %s, want result ok", string(body))
}
if backupCalls == 0 {
t.Fatal("backup RPC was not called after threshold")
}
if primaryCalls == 0 {
t.Fatal("primary RPC was not called")
}
}
func TestSolRetryClientCountsJSONRPCErrorAsNodeFailure(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
oldRetryCount := solRPCRetryCount
oldRetryWait := solRPCRetryWait
oldRetryMaxWait := solRPCRetryMaxWait
solRPCRetryCount = 0
solRPCRetryWait = time.Millisecond
solRPCRetryMaxWait = time.Millisecond
t.Cleanup(func() {
solRPCRetryCount = oldRetryCount
solRPCRetryWait = oldRetryWait
solRPCRetryMaxWait = oldRetryMaxWait
})
var primaryCalls int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
primaryCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32005,
},
})
}))
defer primary.Close()
var backupCalls int
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": "ok",
})
}))
defer backup.Close()
rows := []mdb.RpcNode{
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
if _, err := SolRetryClient("getHealth", nil); err == nil {
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
}
}
body, err := SolRetryClient("getHealth", nil)
if err != nil {
t.Fatalf("SolRetryClient after JSON-RPC error threshold: %v", err)
}
if gjson.GetBytes(body, "result").String() != "ok" {
t.Fatalf("response body = %s, want result ok", string(body))
}
if primaryCalls < data.RpcFailoverThreshold {
t.Fatalf("primary calls = %d, want at least threshold", primaryCalls)
}
if backupCalls == 0 {
t.Fatal("backup RPC was not called after JSON-RPC error threshold")
}
}
func TestSolRetryClientWithURLHeadersForwardsCustomHeaders(t *testing.T) {
oldRetryCount := solRPCRetryCount
oldRetryWait := solRPCRetryWait
oldRetryMaxWait := solRPCRetryMaxWait
solRPCRetryCount = 0
solRPCRetryWait = time.Millisecond
solRPCRetryMaxWait = time.Millisecond
t.Cleanup(func() {
solRPCRetryCount = oldRetryCount
solRPCRetryWait = oldRetryWait
solRPCRetryMaxWait = oldRetryMaxWait
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Test-Header"); got != "test-value" {
t.Errorf("X-Test-Header = %q, want test-value", got)
http.Error(w, "bad custom header", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": "ok",
})
}))
defer server.Close()
headers := http.Header{}
headers.Set("X-Test-Header", "test-value")
body, err := solRetryClientWithURLHeaders(server.URL, "getHealth", nil, headers)
if err != nil {
t.Fatalf("solRetryClientWithURLHeaders(): %v", err)
}
if gjson.GetBytes(body, "result").String() != "ok" {
t.Fatalf("response body = %s, want result ok", string(body))
}
}
func TestSolRetryClientWithURLDoesNotForwardUserIPByDefault(t *testing.T) {
oldRetryCount := solRPCRetryCount
oldRetryWait := solRPCRetryWait
oldRetryMaxWait := solRPCRetryMaxWait
solRPCRetryCount = 0
solRPCRetryWait = time.Millisecond
solRPCRetryMaxWait = time.Millisecond
t.Cleanup(func() {
solRPCRetryCount = oldRetryCount
solRPCRetryWait = oldRetryWait
solRPCRetryMaxWait = oldRetryMaxWait
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-USER-IP"); got != "" {
t.Errorf("X-USER-IP header = %q, want empty", got)
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": "ok",
})
}))
defer server.Close()
body, err := solRetryClientWithURL(server.URL, "getHealth", nil)
if err != nil {
t.Fatalf("solRetryClientWithURL(): %v", err)
}
if gjson.GetBytes(body, "result").String() != "ok" {
t.Fatalf("response body = %s, want result ok", string(body))
}
}
func TestSolCallBackDoesNotCacheSignatureAfterRetryableOrderProcessingError(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
clearProcessedSignatures()
t.Cleanup(clearProcessedSignatures)
const (
address = "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
sig = "retryable-sol-signature"
tradeID = "sol-retryable-order-001"
amount = 1.23
)
blockTime := time.Now().Add(time.Minute).Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var rpcReq struct {
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&rpcReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch rpcReq.Method {
case "getSignaturesForAddress":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": []map[string]interface{}{
{
"signature": sig,
"slot": 1,
"err": nil,
"blockTime": blockTime,
},
},
})
case "getTransaction":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]interface{}{
"blockTime": blockTime,
"meta": map[string]interface{}{"err": nil},
"transaction": map[string]interface{}{
"message": map[string]interface{}{
"instructions": []map[string]interface{}{
{
"programId": SystemProgramID,
"parsed": map[string]interface{}{
"type": "transfer",
"info": map[string]interface{}{
"source": "source-wallet",
"destination": address,
"lamports": 1_230_000_000,
},
},
},
},
},
},
},
})
default:
http.Error(w, "unexpected method "+rpcReq.Method, http.StatusBadRequest)
}
}))
defer server.Close()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: server.URL,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed solana rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkSolana,
Symbol: "SOL",
ContractAddress: "",
Decimals: SOL_Decimals,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed SOL chain token: %v", err)
}
order := &mdb.Orders{
TradeId: tradeID,
OrderId: "merchant-sol-retryable-001",
Amount: 100,
Currency: "CNY",
ActualAmount: amount,
ReceiveAddress: address,
Token: "SOL",
Network: mdb.NetworkSolana,
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkSolana, address, "SOL", tradeID, amount, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
oldProcessSolanaOrder := processSolanaOrder
calls := 0
processSolanaOrder = func(req *request.OrderProcessingRequest) error {
calls++
if req.TradeId != tradeID {
t.Fatalf("order processing trade_id = %q, want %q", req.TradeId, tradeID)
}
return errors.New("temporary database error")
}
t.Cleanup(func() {
processSolanaOrder = oldProcessSolanaOrder
})
var wg sync.WaitGroup
wg.Add(1)
SolCallBack(address, &wg)
wg.Wait()
if calls != 1 {
t.Fatalf("order processing calls = %d, want 1", calls)
}
if _, ok := gProcessedSignatures.Load(sig); ok {
t.Fatalf("signature %s was cached after retryable order processing error", sig)
}
}
func TestSolClientHealthy(t *testing.T) { func TestSolClientHealthy(t *testing.T) {
requireSolanaIntegration(t) requireSolanaIntegration(t)
@@ -78,6 +523,13 @@ func TestSolClientHealthy(t *testing.T) {
} }
} }
func clearProcessedSignatures() {
gProcessedSignatures.Range(func(key, value interface{}) bool {
gProcessedSignatures.Delete(key)
return true
})
}
func TestSolClientGetSignaturesForAddress(t *testing.T) { func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t) requireSolanaIntegration(t)
+18 -4
View File
@@ -18,18 +18,28 @@ import (
) )
func resolveTronNode() (string, string, error) { func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp) node, err := ResolveTronRpcNode()
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
return rpcURL, node.ApiKey, nil
}
func ResolveTronRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
node, err := data.SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, excludeIDs...)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 { if node == nil || node.ID == 0 {
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp) return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
} }
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/") rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
if rpcURL == "" { if rpcURL == "" {
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID) return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
} }
return rpcURL, node.ApiKey, nil node.Url = rpcURL
return node, nil
} }
func ResolveTronNode() (string, string, error) { func ResolveTronNode() (string, string, error) {
@@ -257,6 +267,10 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token) log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return return
} }
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr, ReceiveAddress: walletAddr,
+65
View File
@@ -144,3 +144,68 @@ func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID) t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID)
} }
} }
func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
tradeID = "T202605110002"
orderID = "ORD202605110002"
tokenSym = "USDT"
amount = 25.5
)
contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkEthereum,
Symbol: tokenSym,
ContractAddress: contract.Hex(),
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed chain token: %v", err)
}
order := &mdb.Orders{
TradeId: tradeID,
OrderId: orderID,
Amount: 100,
Currency: "CNY",
ActualAmount: amount,
Token: tokenSym,
Network: mdb.NetworkEthereum,
ReceiveAddress: strings.ToLower(receiveAddress.Hex()),
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkEthereum, order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
rawValue := big.NewInt(25_500_000) // 25.5 with 6 decimals
oldBlockTsMs := order.CreatedAt.TimestampMilli() - 1
TryProcessEvmERC20Transfer(mdb.NetworkEthereum, contract, receiveAddress, rawValue, "0xold-hash", oldBlockTsMs)
got, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load order: %v", err)
}
if got.Status != mdb.StatusWaitPay {
t.Fatalf("order status = %d, want %d", got.Status, mdb.StatusWaitPay)
}
if got.BlockTransactionId != "" {
t.Fatalf("block transaction id = %q, want empty", got.BlockTransactionId)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, receiveAddress.Hex(), tokenSym, amount)
if err != nil {
t.Fatalf("lookup retained lock: %v", err)
}
if lockTradeID != tradeID {
t.Fatalf("runtime lock trade_id = %q, want %q", lockTradeID, tradeID)
}
}
+43
View File
@@ -51,6 +51,49 @@ func TestResolveTronNode_WithRow(t *testing.T) {
} }
} }
func TestResolveTronNodeIgnoresManualVerifyOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://paid-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "paid-key",
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
func TestResolveTronNodeUsesGeneralWhenManualVerifyExists(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://paid-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "paid-key", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkTron, Url: "https://general-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "general-key", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
gotURL, gotKey, err := resolveTronNode()
if err != nil {
t.Fatalf("resolveTronNode(): %v", err)
}
if gotURL != "https://general-tron.example.com" || gotKey != "general-key" {
t.Fatalf("resolveTronNode() = (%q, %q), want general node", gotURL, gotKey)
}
}
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored. // TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
func TestResolveTronNode_DisabledRow(t *testing.T) { func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
+569 -10
View File
@@ -1,6 +1,8 @@
package route package route
import ( import (
"bufio"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -8,11 +10,14 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -227,6 +232,7 @@ func TestAdminProtectedRoute_NoToken(t *testing.T) {
"/admin/api/v1/wallets", "/admin/api/v1/wallets",
"/admin/api/v1/orders", "/admin/api/v1/orders",
"/admin/api/v1/dashboard/overview", "/admin/api/v1/dashboard/overview",
"/admin/api/v1/dashboard/rpc-stats",
"/admin/api/v1/settings", "/admin/api/v1/settings",
"/admin/api/v1/rpc-nodes", "/admin/api/v1/rpc-nodes",
"/admin/api/v1/notification-channels", "/admin/api/v1/notification-channels",
@@ -337,8 +343,15 @@ func TestAdminInitPasswordFlow(t *testing.T) {
if recFetch2.Code != http.StatusBadRequest { if recFetch2.Code != http.StatusBadRequest {
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String()) t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
} }
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") { var respFetch2 map[string]interface{}
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String()) if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
t.Fatalf("unmarshal second fetch response: %v", err)
}
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
}
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
} }
token := adminLogin(t, e, testAdminUsername, initPassword) token := adminLogin(t, e, testAdminUsername, initPassword)
@@ -521,14 +534,25 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
if nodeID == nil { if nodeID == nil {
t.Fatal("CreateRpcNode missing id") t.Fatal("CreateRpcNode missing id")
} }
if got, _ := dataObj["purpose"].(string); got != mdb.RpcNodePurposeGeneral {
t.Fatalf("created purpose = %q, want %q", got, mdb.RpcNodePurposeGeneral)
}
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64)) nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
// Update. // Update.
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{ rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "https://eth-mainnet2.example.com", "url": "https://eth-mainnet2.example.com",
"purpose": mdb.RpcNodePurposeManualVerify,
}, token) }, token)
t.Logf("UpdateRpcNode: %s", rec.Body.String()) t.Logf("UpdateRpcNode: %s", rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
updatedNode, err := data.GetRpcNodeByID(uint64(nodeID.(float64)))
if err != nil {
t.Fatalf("reload rpc node: %v", err)
}
if updatedNode.Purpose != mdb.RpcNodePurposeManualVerify {
t.Fatalf("updated purpose = %q, want %q", updatedNode.Purpose, mdb.RpcNodePurposeManualVerify)
}
// Health check — network likely unreachable in test, but route must not 404/500. // Health check — network likely unreachable in test, but route must not 404/500.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token) rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
@@ -543,6 +567,95 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
assertOK(t, rec) assertOK(t, rec)
} }
func TestAdminRpcNodes_RejectsURLTypeMismatch(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "wss://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
}, token)
t.Logf("CreateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10022)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeWs,
}, token)
t.Logf("CreateRpcNode(ws with https): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10023)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
"purpose": "invalid-purpose",
}, token)
t.Logf("CreateRpcNode(invalid purpose): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected invalid purpose to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10020)
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": mdb.RpcNodeTypeHttp,
}, token)
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
nodeID := uint64(dataObj["id"].(float64))
nodeIDStr := fmt.Sprintf("%d", nodeID)
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "wss://eth-mainnet.example.com",
}, token)
t.Logf("UpdateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected update mismatch to fail, got 200: %s", rec.Body.String())
}
assertErrorCode(t, rec, 10022)
node, err := data.GetRpcNodeByID(nodeID)
if err != nil {
t.Fatalf("reload rpc node: %v", err)
}
if node.Url != "https://eth-mainnet.example.com" {
t.Fatalf("node url changed after rejected update: %q", node.Url)
}
}
func TestAdminPathIDParseErrorUsesParamsErrno(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/api-keys/not-a-number/stats", token)
assertErrorCode(t, rec, 10009)
}
func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode int) {
t.Helper()
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected HTTP 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != wantCode {
t.Fatalf("status_code = %d, want %d; response=%v", got, wantCode, resp)
}
if got, _ := resp["message"].(string); got != constant.Errno[wantCode] {
t.Fatalf("message = %q, want %q; response=%v", got, constant.Errno[wantCode], resp)
}
}
// ─── Wallets ───────────────────────────────────────────────────────────────── // ─── Wallets ─────────────────────────────────────────────────────────────────
// TestAdminWallets_CRUD verifies wallet management routes. // TestAdminWallets_CRUD verifies wallet management routes.
@@ -691,9 +804,14 @@ func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
}) })
defer restore() defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{ jsonBytes, _ := json.Marshal(map[string]interface{}{
"block_transaction_id": "block-admin-ok", "block_transaction_id": "block-admin-ok",
}, token) })
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String()) t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
if !verified { if !verified {
@@ -856,7 +974,7 @@ func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
// ─── Dashboard ─────────────────────────────────────────────────────────────── // ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200. // TestAdminDashboard_AllRoutes verifies ordinary JSON dashboard endpoints return 200.
func TestAdminDashboard_AllRoutes(t *testing.T) { func TestAdminDashboard_AllRoutes(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
routes := []string{ routes := []string{
@@ -873,6 +991,306 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
} }
} }
func TestAdminDashboardRpcStatsSSE(t *testing.T) {
e, token := setupAdminTestEnv(t)
data.RecordRpcSuccess("TRON")
data.RecordRpcSuccess("tron")
data.RecordRpcFailure(" tron ")
data.RecordRpcBlockHeight("tron", 64532100)
server := httptest.NewServer(e)
defer server.Close()
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, server.URL+"/admin/api/v1/dashboard/rpc-stats", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
req.Header.Set(echo.HeaderAccept, "text/event-stream")
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("sse request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("sse status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get(echo.HeaderContentType); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
reader := bufio.NewReader(resp.Body)
var eventName string
var dataLine string
for {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read sse line: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if strings.HasPrefix(line, "event: ") {
eventName = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") {
dataLine = strings.TrimPrefix(line, "data: ")
}
if line == "" && dataLine != "" {
break
}
}
if eventName != "rpc_stats" {
t.Fatalf("event = %q, want rpc_stats", eventName)
}
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(dataLine), &envelope); err != nil {
t.Fatalf("unmarshal sse data: %v data=%s", err, dataLine)
}
if got, _ := envelope["status_code"].(float64); got != 200 {
t.Fatalf("sse status_code = %v, want 200", got)
}
dataObj, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("sse data = %#v, want object", envelope["data"])
}
rows, ok := dataObj["chains"].([]interface{})
if !ok {
t.Fatalf("sse chains = %#v, want array", dataObj["chains"])
}
summary, ok := dataObj["summary"].(map[string]interface{})
if !ok {
t.Fatalf("summary = %#v, want object", dataObj["summary"])
}
if got, _ := summary["success_count"].(float64); got != 2 {
t.Fatalf("summary success_count = %v, want 2", got)
}
if got, _ := summary["failure_count"].(float64); got != 1 {
t.Fatalf("summary failure_count = %v, want 1", got)
}
if got, _ := summary["success_rate"].(float64); got < 0.666 || got > 0.667 {
t.Fatalf("summary success_rate = %v, want about 0.6667", got)
}
var tron map[string]interface{}
var solana map[string]interface{}
for _, row := range rows {
item, _ := row.(map[string]interface{})
switch item["network"] {
case mdb.NetworkTron:
tron = item
case mdb.NetworkSolana:
solana = item
}
}
if tron == nil {
t.Fatalf("missing tron row in %#v", rows)
}
if got, _ := tron["success_count"].(float64); got != 2 {
t.Fatalf("tron success_count = %v, want 2", got)
}
if got, _ := tron["failure_count"].(float64); got != 1 {
t.Fatalf("tron failure_count = %v, want 1", got)
}
if got, _ := tron["success_rate"].(float64); got < 0.666 || got > 0.667 {
t.Fatalf("tron success_rate = %v, want about 0.6667", got)
}
if got, _ := tron["latest_block_height"].(float64); got != 64532100 {
t.Fatalf("tron latest_block_height = %v, want 64532100", got)
}
if got, _ := tron["last_sync_at"].(string); got == "" {
t.Fatalf("tron last_sync_at is empty: %#v", tron)
}
if got, _ := tron["display_name"].(string); got != "TRON" {
t.Fatalf("tron display_name = %q, want TRON", got)
}
if got, _ := tron["status"].(string); got != "warning" {
t.Fatalf("tron status = %q, want warning", got)
}
if solana == nil {
t.Fatalf("missing solana row in %#v", rows)
}
if got, _ := solana["success_count"].(float64); got != 0 {
t.Fatalf("solana success_count = %v, want 0", got)
}
if got, _ := solana["failure_count"].(float64); got != 0 {
t.Fatalf("solana failure_count = %v, want 0", got)
}
if got, _ := solana["success_rate"].(float64); got != 0 {
t.Fatalf("solana success_rate = %v, want 0", got)
}
if got, _ := solana["last_sync_at"].(string); got != "" {
t.Fatalf("solana last_sync_at = %q, want empty", got)
}
if got, _ := solana["latest_block_height"].(float64); got != 0 {
t.Fatalf("solana latest_block_height = %v, want 0", got)
}
if got, _ := solana["status"].(string); got != "unknown" {
t.Fatalf("solana status = %q, want unknown", got)
}
}
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
e, token := setupAdminTestEnv(t)
yesterday := time.Now().AddDate(0, 0, -1)
createdAt := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 10, 0, 0, 0, time.Local)
updatedAt := createdAt.Add(5 * time.Minute)
order := &mdb.Orders{
TradeId: "trade-dashboard-yesterday",
OrderId: "order-dashboard-yesterday",
Amount: 100,
Currency: "CNY",
ActualAmount: 12.5,
ReceiveAddress: "TTestDashboardAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusPaySuccess,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create historical order: %v", err)
}
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
"created_at": createdAt,
"updated_at": updatedAt,
}).Error; err != nil {
t.Fatalf("age historical order: %v", err)
}
todayResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats", token))
todayData, _ := todayResp["data"].(map[string]interface{})
if got, _ := todayData["order_count"].(float64); got != 0 {
t.Fatalf("default order-stats order_count = %v, want 0 for today's range", got)
}
statsResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/order-stats?range=7d", token))
statsData, _ := statsResp["data"].(map[string]interface{})
if got, _ := statsData["order_count"].(float64); got != 1 {
t.Fatalf("7d order-stats order_count = %v, want 1", got)
}
if got, _ := statsData["success_count"].(float64); got != 1 {
t.Fatalf("7d order-stats success_count = %v, want 1", got)
}
overviewResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/overview?range=7d", token))
overviewData, _ := overviewResp["data"].(map[string]interface{})
if got, _ := overviewData["order_count"].(float64); got != 1 {
t.Fatalf("7d overview order_count = %v, want 1", got)
}
if got, _ := overviewData["volume"].(float64); got != 12.5 {
t.Fatalf("7d overview volume = %v, want 12.5", got)
}
for _, path := range []string{
"/admin/api/v1/dashboard/asset-trend?range=7d",
"/admin/api/v1/dashboard/revenue-trend?range=7d",
"/admin/api/v1/dashboard/asset-trend?range=30d",
"/admin/api/v1/dashboard/revenue-trend?range=30d",
} {
resp := assertOK(t, doGetAdmin(e, path, token))
rows, _ := resp["data"].([]interface{})
day := createdAt.Format("2006-01-02")
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["day"] != day {
continue
}
found = true
if got, _ := item["order_count"].(float64); got != 1 {
t.Fatalf("%s order_count for %s = %v, want 1", path, day, got)
}
if got, _ := item["success_count"].(float64); got != 1 {
t.Fatalf("%s success_count for %s = %v, want 1", path, day, got)
}
if got, _ := item["actual_amount"].(float64); got != 12.5 {
t.Fatalf("%s actual_amount for %s = %v, want 12.5", path, day, got)
}
}
if !found {
t.Fatalf("%s missing historical bucket %s: %#v", path, day, rows)
}
}
addressResp := assertOK(t, doGetAdmin(e, "/admin/api/v1/dashboard/asset-trend?range=7d&group_by=address", token))
addressRows, _ := addressResp["data"].([]interface{})
day := createdAt.Format("2006-01-02")
foundAddress := false
for _, row := range addressRows {
item, _ := row.(map[string]interface{})
if item["day"] != day || item["address"] != order.ReceiveAddress {
continue
}
foundAddress = true
if got, _ := item["actual_amount"].(float64); got != 12.5 {
t.Fatalf("address trend actual_amount for %s = %v, want 12.5", day, got)
}
}
if !foundAddress {
t.Fatalf("address trend missing historical bucket %s/%s: %#v", day, order.ReceiveAddress, addressRows)
}
}
func TestAdminDashboard_TodayTrendUsesHourlyBuckets(t *testing.T) {
e, token := setupAdminTestEnv(t)
now := time.Now()
createdAt := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.Local)
updatedAt := createdAt.Add(2 * time.Minute)
order := &mdb.Orders{
TradeId: "trade-dashboard-today",
OrderId: "order-dashboard-today",
Amount: 80,
Currency: "CNY",
ActualAmount: 9.75,
ReceiveAddress: "TTestDashboardAddressHourly",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusPaySuccess,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create today order: %v", err)
}
if err := dao.Mdb.Model(order).Updates(map[string]interface{}{
"created_at": createdAt,
"updated_at": updatedAt,
}).Error; err != nil {
t.Fatalf("set today order time: %v", err)
}
for _, path := range []string{
"/admin/api/v1/dashboard/asset-trend?range=today",
"/admin/api/v1/dashboard/revenue-trend?range=today",
} {
resp := assertOK(t, doGetAdmin(e, path, token))
rows, _ := resp["data"].([]interface{})
hour := createdAt.Format("2006-01-02 15:00")
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["day"] != hour {
continue
}
found = true
if got, _ := item["order_count"].(float64); got != 1 {
t.Fatalf("%s order_count for %s = %v, want 1", path, hour, got)
}
if got, _ := item["success_count"].(float64); got != 1 {
t.Fatalf("%s success_count for %s = %v, want 1", path, hour, got)
}
if got, _ := item["actual_amount"].(float64); got != 9.75 {
t.Fatalf("%s actual_amount for %s = %v, want 9.75", path, hour, got)
}
}
if !found {
t.Fatalf("%s missing hourly bucket %s: %#v", path, hour, rows)
}
}
}
// ─── Settings ──────────────────────────────────────────────────────────────── // ─── Settings ────────────────────────────────────────────────────────────────
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings. // TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
@@ -908,13 +1326,64 @@ func TestAdminSettings_ListAndUpsert(t *testing.T) {
// Upsert a setting. // Upsert a setting.
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"cny": map[string]interface{}{"usdt": 0.14635},
"usd": map[string]interface{}{"usdt": 1},
}, "type": "json"},
}, },
}, token) }, token)
t.Logf("UpsertSettings: %s", rec.Body.String()) t.Logf("UpsertSettings: %s", rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
} }
func TestAdminSettings_ForcedRateListValidation(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
" CNY ": map[string]interface{}{" USDT ": 0.14635},
}, "type": "string"},
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": `{"cny":`, "type": "json"},
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.14635},
"cny": map[string]interface{}{"usdt": 0.14636},
}, "type": "json"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 3 {
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
}
first, _ := results[0].(map[string]interface{})
if first["ok"] != true {
t.Fatalf("valid forced rate list result = %v", first)
}
second, _ := results[1].(map[string]interface{})
if second["ok"] != false {
t.Fatalf("invalid forced rate list result = %v, want ok=false", second)
}
third, _ := results[2].(map[string]interface{})
if third["ok"] != false {
t.Fatalf("duplicate forced rate list result = %v, want ok=false", third)
}
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.14635}}` {
t.Fatalf("forced rate list value = %q", got)
}
if rate := config.GetRateForCoin("USDT", "CNY"); rate != 0.14635 {
t.Fatalf("GetRateForCoin(USDT, CNY) = %v, want 0.14635", rate)
}
var row mdb.Setting
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&row).Error; err != nil {
t.Fatalf("load forced rate list: %v", err)
}
if row.Type != mdb.SettingTypeJSON {
t.Fatalf("forced rate list type = %q, want %q", row.Type, mdb.SettingTypeJSON)
}
}
func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) { func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -983,12 +1452,56 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
} }
} }
func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "http://127.0.0.1:8080/", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != false {
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
}
if got := int(result["error_code"].(float64)); got != 10043 {
t.Fatalf("private rate.api_url error_code = %d, want 10043; result=%v", got, result)
}
if got, _ := result["error"].(string); got != constant.Errno[10043] {
t.Fatalf("private rate.api_url error = %q, want %q", got, constant.Errno[10043])
}
}
func TestAdminSettings_AllowsPublicRateAPIURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate", "type": "string"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != true {
t.Fatalf("public rate.api_url result = %v, want ok=true", result)
}
}
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) { func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.old.example", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-old", "type": "string"},
}, },
}, token) }, token)
assertOK(t, rec) assertOK(t, rec)
@@ -1001,7 +1514,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.new.example", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://93.184.216.34/rate-new", "type": "string"},
}, },
}, token) }, token)
assertOK(t, rec) assertOK(t, rec)
@@ -1017,7 +1530,7 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
item, _ := row.(map[string]interface{}) item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyRateApiUrl { if item["key"] == mdb.SettingKeyRateApiUrl {
found = true found = true
if item["value"] != "https://rate.new.example" { if item["value"] != "https://93.184.216.34/rate-new" {
t.Fatalf("rate.api_url value = %v, want new value", item["value"]) t.Fatalf("rate.api_url value = %v, want new value", item["value"])
} }
} }
@@ -1035,6 +1548,52 @@ func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
} }
} }
func TestAdminSettings_DeleteThenReupsertRestoresForcedRateList(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.14635},
}, "type": "json"},
},
}, token)
assertOK(t, rec)
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateForcedRateList, token)
assertOK(t, rec)
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, "fallback"); got != "fallback" {
t.Fatalf("deleted forced rate list still in cache/read path: got %q", got)
}
if got := config.GetRateForCoin("USDT", "CNY"); got != 0 {
t.Fatalf("deleted forced rate list still used by rate lookup: got %v", got)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.15},
}, "type": "json"},
},
}, token)
assertOK(t, rec)
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.15}}` {
t.Fatalf("restored forced rate list = %q", got)
}
if got := config.GetRateForCoin("USDT", "CNY"); got != 0.15 {
t.Fatalf("restored forced rate list lookup = %v, want 0.15", got)
}
var restored mdb.Setting
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&restored).Error; err != nil {
t.Fatalf("load restored forced rate list unscoped: %v", err)
}
if restored.DeletedAt.Valid {
t.Fatalf("restored forced rate list still has deleted_at=%v", restored.DeletedAt)
}
}
func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) { func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil { if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil {
@@ -1252,7 +1811,7 @@ func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
"currency": "CNY", "currency": "CNY",
"token": "USDT", "token": "USDT",
"network": "tron", "network": "tron",
"notify_url": "https://merchant.example/callback", "notify_url": "https://93.184.216.34/callback",
"redirect_url": "https://merchant.example/redirect", "redirect_url": "https://merchant.example/redirect",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
+5 -3
View File
@@ -37,6 +37,7 @@ func RegisterRoute(e *echo.Echo) {
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
payRoute.POST("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
// payment routes // payment routes
@@ -74,7 +75,7 @@ func RegisterRoute(e *echo.Echo) {
formParams, err := ctx.FormParams() formParams, err := ctx.FormParams()
if err != nil && ctx.Request().Method == http.MethodPost { if err != nil && ctx.Request().Method == http.MethodPost {
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err)) return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err == nil { if err == nil {
copyParams(formParams) copyParams(formParams)
@@ -131,7 +132,7 @@ func RegisterRoute(e *echo.Echo) {
amountFloat, err := strconv.ParseFloat(money, 64) amountFloat, err := strconv.ParseFloat(money, 64)
if err != nil { if err != nil {
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money)) return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
} }
body := map[string]interface{}{ body := map[string]interface{}{
@@ -153,7 +154,7 @@ func RegisterRoute(e *echo.Echo) {
jsonBytes, err := json.Marshal(body) jsonBytes, err := json.Marshal(body)
if err != nil { if err != nil {
return comm.Ctrl.FailJson(ctx, err) return comm.Ctrl.FailJson(ctx, constant.SystemErr)
} }
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
@@ -268,6 +269,7 @@ func registerAdminRoutes(e *echo.Echo) {
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend) authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend) authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats) authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
authed.GET("/dashboard/rpc-stats", admin.Ctrl.RpcStats)
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders) authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
// Settings // Settings
+298 -13
View File
@@ -17,6 +17,8 @@ import (
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
@@ -75,14 +77,15 @@ func setupTestEnv(t *testing.T) *echo.Echo {
// reset the settings cache so stale entries from a prior test don't leak // reset the settings cache so stale entries from a prior test don't leak
_ = data.ReloadSettings() _ = data.ReloadSettings()
data.ResetRpcRuntimeStatsForTest()
config.SettingsGetString = func(key string) string { config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "") return data.GetSettingString(key, "")
} }
t.Cleanup(func() { t.Cleanup(func() {
config.SettingsGetString = nil config.SettingsGetString = nil
}) })
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.14285714285714285}}`, "json"); err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err) t.Fatalf("seed rate.forced_rate_list: %v", err)
} }
// seed wallet addresses // seed wallet addresses
@@ -240,7 +243,7 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -269,6 +272,57 @@ func TestCreateOrderGmpayV1Solana(t *testing.T) {
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"]) t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
} }
func TestCreateOrderGmpayRejectsPrivateNotifyURL(t *testing.T) {
e := setupTestEnv(t)
body := signBody(map[string]interface{}{
"order_id": "test-private-notify-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://127.0.0.1/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal private notify response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10041 {
t.Fatalf("status_code = %d, want 10041; response=%v", got, resp)
}
if got, _ := resp["message"].(string); got != constant.Errno[10041] {
t.Fatalf("message = %q, want %q", got, constant.Errno[10041])
}
}
func TestPublicJSONBindErrorUsesParamsErrno(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/bad-json-order", strings.NewReader("{"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal bind error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009; response=%v", got, resp)
}
if got, _ := resp["message"].(string); got != constant.Errno[10009] {
t.Fatalf("message = %q, want %q", got, constant.Errno[10009])
}
}
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token. // TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
func TestCreateOrderGmpayV1SolNative(t *testing.T) { func TestCreateOrderGmpayV1SolNative(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -279,7 +333,7 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
"token": "sol", "token": "sol",
"currency": "usd", "currency": "usd",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -334,7 +388,7 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
"token": {"usdt"}, "token": {"usdt"},
"currency": {"cny"}, "currency": {"cny"},
"network": {"solana"}, "network": {"solana"},
"notify_url": {"http://localhost/notify"}, "notify_url": {"https://93.184.216.34/notify"},
}) })
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values) rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
@@ -688,7 +742,7 @@ func seedOkPayNotifyFixture(t *testing.T) *okPayNotifyFixture {
Token: "USDT", Token: "USDT",
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay, Status: mdb.StatusWaitPay,
NotifyUrl: "http://localhost/notify", NotifyUrl: "https://93.184.216.34/notify",
PaymentType: mdb.PaymentTypeEpay, PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOnChain, PayProvider: mdb.PaymentProviderOnChain,
} }
@@ -928,7 +982,7 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -956,7 +1010,7 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
"type": {"alipay"}, "type": {"alipay"},
"money": {"1.00"}, "money": {"1.00"},
"out_trade_no": {"epay-get-001"}, "out_trade_no": {"epay-get-001"},
"notify_url": {"http://localhost/notify"}, "notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"}, "return_url": {"http://localhost/return"},
}) })
@@ -981,7 +1035,7 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
"type": {"alipay"}, "type": {"alipay"},
"money": {"1.00"}, "money": {"1.00"},
"out_trade_no": {"epay-post-001"}, "out_trade_no": {"epay-post-001"},
"notify_url": {"http://localhost/notify"}, "notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"}, "return_url": {"http://localhost/return"},
"sitename": {"example-shop"}, "sitename": {"example-shop"},
}) })
@@ -1065,6 +1119,237 @@ func TestCheckStatus_WithOrder(t *testing.T) {
} }
} }
func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-ok-001")
verified := false
restore := service.SetManualOrderPaymentValidatorForTest(func(order *mdb.Orders, blockID string) (string, error) {
verified = true
if order.TradeId != tradeID {
t.Fatalf("validator trade_id = %s, want %s", order.TradeId, tradeID)
}
if blockID != "user-submitted-hash" {
t.Fatalf("validator block id = %s, want user-submitted-hash", blockID)
}
return "canonical-user-submitted-hash", nil
})
defer restore()
jsonBytes, _ := json.Marshal(map[string]interface{}{
"block_transaction_id": " user-submitted-hash ",
})
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/"+tradeID, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("SubmitTxHash(success): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if !verified {
t.Fatal("expected chain verifier to be called")
}
var submitResp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &submitResp); err != nil {
t.Fatalf("unmarshal submit response: %v", err)
}
submitData, ok := submitResp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in submit response, got: %v", submitResp)
}
if got, _ := submitData["block_transaction_id"].(string); got != "canonical-user-submitted-hash" {
t.Fatalf("block_transaction_id = %q", got)
}
if got := int(submitData["status"].(float64)); got != mdb.StatusPaySuccess {
t.Fatalf("submit response status = %d, want %d", got, mdb.StatusPaySuccess)
}
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
statusRec := httptest.NewRecorder()
e.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
}
var statusResp map[string]interface{}
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResp); err != nil {
t.Fatalf("unmarshal check-status response: %v", err)
}
statusData, ok := statusResp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in check-status response, got: %v", statusResp)
}
if got := int(statusData["status"].(float64)); got != mdb.StatusPaySuccess {
t.Fatalf("check-status status = %d, want %d", got, mdb.StatusPaySuccess)
}
}
func TestSubmitTxHash_VerificationFailureAllowsRetrySameHash(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-retry-001")
calls := 0
restore := service.SetManualOrderPaymentValidatorForTest(func(order *mdb.Orders, blockID string) (string, error) {
calls++
if order.TradeId != tradeID {
t.Fatalf("validator trade_id = %s, want %s", order.TradeId, tradeID)
}
if blockID != "retry-hash" {
t.Fatalf("validator block id = %s, want retry-hash", blockID)
}
if calls == 1 {
return "", fmt.Errorf("rpc verification failed")
}
return "canonical-retry-hash", nil
})
defer restore()
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
"block_transaction_id": "retry-hash",
})
t.Logf("SubmitTxHash(verification failure): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
var failResp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &failResp); err != nil {
t.Fatalf("unmarshal failure response: %v", err)
}
if got := int(failResp["status_code"].(float64)); got != 10038 {
t.Fatalf("failure status_code = %d, want 10038", got)
}
if got, _ := failResp["message"].(string); got != constant.Errno[10038] {
t.Fatalf("failure message = %q, want %q", got, constant.Errno[10038])
}
reloaded, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload order after failure: %v", err)
}
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
t.Fatalf("order changed after failed submit: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
}
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
statusRec := httptest.NewRecorder()
e.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
}
var statusResp map[string]interface{}
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResp); err != nil {
t.Fatalf("unmarshal check-status response: %v", err)
}
statusData := statusResp["data"].(map[string]interface{})
if got := int(statusData["status"].(float64)); got != mdb.StatusWaitPay {
t.Fatalf("check-status status after failed submit = %d, want %d", got, mdb.StatusWaitPay)
}
retryRec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
"block_transaction_id": "retry-hash",
})
t.Logf("SubmitTxHash(retry same hash): status=%d body=%s", retryRec.Code, retryRec.Body.String())
if retryRec.Code != http.StatusOK {
t.Fatalf("expected retry success, got %d: %s", retryRec.Code, retryRec.Body.String())
}
if calls != 2 {
t.Fatalf("validator calls = %d, want 2", calls)
}
reloaded, err = data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload order after retry: %v", err)
}
if reloaded.Status != mdb.StatusPaySuccess || reloaded.BlockTransactionId != "canonical-retry-hash" {
t.Fatalf("order after retry: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
}
}
func TestSubmitTxHash_RejectsOkPayOrder(t *testing.T) {
e := setupTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-submit-tx-hash-okpay",
OrderId: "order-submit-tx-hash-okpay",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
called := false
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
called = true
return "canonical-okpay-hash", nil
})
defer restore()
rec := doPost(e, "/pay/submit-tx-hash/"+order.TradeId, map[string]interface{}{
"block_transaction_id": "okpay-hash",
})
t.Logf("SubmitTxHash(okpay): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
if called {
t.Fatal("verifier should not run for OkPay/provider orders")
}
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
t.Fatalf("order changed after rejected submit: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
}
}
func TestSubmitTxHash_RejectsExistingHashBeforeRpc(t *testing.T) {
e := setupTestEnv(t)
existing := &mdb.Orders{
TradeId: "trade-submit-tx-hash-existing",
OrderId: "order-submit-tx-hash-existing",
BlockTransactionId: "existing-hash",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusPaySuccess,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(existing).Error; err != nil {
t.Fatalf("create existing paid order: %v", err)
}
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-existing-001")
called := false
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
called = true
return "existing-hash", nil
})
defer restore()
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
"block_transaction_id": "existing-hash",
})
t.Logf("SubmitTxHash(existing hash): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
if called {
t.Fatal("verifier should not run when hash already exists")
}
}
// TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id // TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id
// with an unknown trade_id does not return an empty 404 (which would mean the // with an unknown trade_id does not return an empty 404 (which would mean the
// route is not registered). When the static HTML template is present the // route is not registered). When the static HTML template is present the
@@ -1162,7 +1447,7 @@ func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID stri
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "tron", "network": "tron",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
"redirect_url": "https://merchant.example/return", "redirect_url": "https://merchant.example/return",
}) })
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
@@ -1252,7 +1537,7 @@ func TestSwitchNetwork_WithOrder(t *testing.T) {
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody) createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK { if createRec.Code != http.StatusOK {
@@ -1344,7 +1629,7 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody) createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK { if createRec.Code != http.StatusOK {
@@ -1441,7 +1726,7 @@ func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
"token": "usdt", "token": "usdt",
"currency": "cny", "currency": "cny",
"network": "solana", "network": "solana",
"notify_url": "http://localhost/notify", "notify_url": "https://93.184.216.34/notify",
}) })
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody) createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK { if createRec.Code != http.StatusOK {
+5 -3
View File
@@ -66,18 +66,18 @@ func runBscListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkBsc, "[BSC-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[BSC-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkBsc, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -98,9 +98,11 @@ func runBscListener(contracts []common.Address) {
var blockTsMs int64 var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkBsc)
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli() blockTsMs = time.Now().UnixMilli()
} else { } else {
data.RecordRpcSuccess(mdb.NetworkBsc)
blockTsMs = int64(header.Time) * 1000 blockTsMs = int64(header.Time) * 1000
} }
+13 -4
View File
@@ -93,19 +93,28 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
// given network. If no enabled node is configured, the caller skips the // given network. If no enabled node is configured, the caller skips the
// current listener run so admin-side disabled/deleted rows are respected. // current listener run so admin-side disabled/deleted rows are respected.
func resolveChainWsURL(network, logPrefix string) (string, bool) { func resolveChainWsURL(network, logPrefix string) (string, bool) {
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs) node, ok := resolveChainWsNode(network, logPrefix)
if !ok {
return "", false
}
return strings.TrimSpace(node.Url), true
}
func resolveChainWsNode(network, logPrefix string, excludeIDs ...uint64) (mdb.RpcNode, bool) {
node, err := data.SelectGeneralRpcNode(network, mdb.RpcNodeTypeWs, excludeIDs...)
if err == nil && node != nil && node.ID > 0 { if err == nil && node != nil && node.ID > 0 {
rpcURL := strings.TrimSpace(node.Url) rpcURL := strings.TrimSpace(node.Url)
if rpcURL != "" { if rpcURL != "" {
return rpcURL, true node.Url = rpcURL
return *node, true
} }
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID) log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
return "", false return mdb.RpcNode{}, false
} }
if err != nil { if err != nil {
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err) log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
} else { } else {
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network) log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
} }
return "", false return mdb.RpcNode{}, false
} }
+147
View File
@@ -1,13 +1,23 @@
package task package task
import ( import (
"math/big"
"testing" "testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
epLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum/core/types"
"go.uber.org/zap"
) )
func init() {
epLog.Sugar = zap.NewNop().Sugar()
}
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) { func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -42,6 +52,143 @@ func TestResolveChainWsURLWithRow(t *testing.T) {
} }
} }
func TestResolveChainWsURLIgnoresManualVerifyOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkEthereum,
Url: "wss://paid-ethereum.example.com",
Type: mdb.RpcNodeTypeWs,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeManualVerify,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed manual rpc_node: %v", err)
}
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
}
}
func TestResolveChainWsURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
rows := []mdb.RpcNode{
{Network: mdb.NetworkEthereum, Url: "wss://paid-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkEthereum, Url: " wss://general-ethereum.example.com ", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
if !ok {
t.Fatalf("resolveChainWsURL() ok=false, want true")
}
if got != "wss://general-ethereum.example.com" {
t.Fatalf("resolveChainWsURL() = %q, want general node", got)
}
}
func TestResolveChainWsNodeSkipsCoolingNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
rows := []mdb.RpcNode{
{Network: mdb.NetworkEthereum, Url: "wss://primary-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
{Network: mdb.NetworkEthereum, Url: "wss://backup-ethereum.example.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
}
if err := dao.Mdb.Create(&rows).Error; err != nil {
t.Fatalf("seed rpc_nodes: %v", err)
}
primary, ok := resolveChainWsNode(mdb.NetworkEthereum, "[TEST]")
if !ok || primary.Url != "wss://primary-ethereum.example.com" {
t.Fatalf("primary node = %#v ok=%v, want primary", primary, ok)
}
for i := 0; i < data.RpcFailoverThreshold; i++ {
data.RecordRpcNodeFailure(primary.ID)
}
got, ok := resolveChainWsNode(mdb.NetworkEthereum, "[TEST]")
if !ok {
t.Fatalf("resolveChainWsNode() ok=false, want true")
}
if got.Url != "wss://backup-ethereum.example.com" {
t.Fatalf("resolveChainWsNode() = %#v, want backup", got)
}
}
func TestEvmWsNodeFailureRecordsRuntimeStats(t *testing.T) {
data.ResetRpcFailoverForTest()
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
recordEvmWsNodeFailure("[TEST]", mdb.NetworkEthereum, mdb.RpcNode{
BaseModel: mdb.BaseModel{ID: 123},
Url: "wss://ethereum.example.com",
Type: mdb.RpcNodeTypeWs,
}, "dial")
stats := data.SnapshotRpcRuntimeStats()
eth := stats[mdb.NetworkEthereum]
if eth.SuccessCount != 0 {
t.Fatalf("ethereum success_count = %d, want 0", eth.SuccessCount)
}
if eth.FailureCount != 1 {
t.Fatalf("ethereum failure_count = %d, want 1", eth.FailureCount)
}
}
func TestEvmLogBlockHeightRecordsRuntimeHeight(t *testing.T) {
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123456)
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123455)
stats := data.SnapshotRpcRuntimeStats()
eth := stats[mdb.NetworkEthereum]
if eth.LatestBlockHeight != 123456 {
t.Fatalf("ethereum latest_block_height = %d, want 123456", eth.LatestBlockHeight)
}
}
func TestEvmHeaderBlockHeightRecordsRuntimeHeight(t *testing.T) {
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654321)})
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654320)})
stats := data.SnapshotRpcRuntimeStats()
polygon := stats[mdb.NetworkPolygon]
if polygon.LatestBlockHeight != 7654321 {
t.Fatalf("polygon latest_block_height = %d, want 7654321", polygon.LatestBlockHeight)
}
}
func TestShouldRefreshEvmLatestHeaderAfterIdleInterval(t *testing.T) {
lastUpdate := time.Now()
if shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval-time.Second)) {
t.Fatal("should not refresh latest header before idle interval")
}
if !shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval)) {
t.Fatal("should refresh latest header at idle interval")
}
if shouldRefreshEvmLatestHeader(lastUpdate, true, lastUpdate.Add(evmStatsIdleInterval+time.Second)) {
t.Fatal("should not refresh latest header while request is in flight")
}
}
func TestResolveChainWsURLDisabledRow(t *testing.T) { func TestResolveChainWsURLDisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+5 -3
View File
@@ -71,18 +71,18 @@ func runEthereumListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkEthereum, "[ETH-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[ETH-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkEthereum, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -100,9 +100,11 @@ func runEthereumListener(contracts []common.Address) {
var blockTsMs int64 var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkEthereum)
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli() blockTsMs = time.Now().UnixMilli()
} else { } else {
data.RecordRpcSuccess(mdb.NetworkEthereum)
blockTsMs = int64(header.Time) * 1000 blockTsMs = int64(header.Time) * 1000
} }
+131 -11
View File
@@ -2,8 +2,11 @@ package task
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
@@ -11,18 +14,26 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs, const (
evmStatsIdleInterval = 30 * time.Second
evmStatsHeaderTimeout = 10 * time.Second
)
// runEvmWsLogListener connects to node.Url, subscribes to Transfer logs,
// and dispatches each log to handleLog. It retries on transient errors // and dispatches each log to handleLog. It retries on transient errors
// with exponential backoff. The ctx lets the caller trigger a clean // with exponential backoff until the node reaches the failure threshold.
// exit — e.g. when admin disables the chain, the caller cancels the // The ctx lets the caller trigger a clean exit — e.g. when admin disables
// context and the function returns instead of reconnecting forever. // the chain, the caller cancels the context and the function returns.
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) { func runEvmWsLogListener(ctx context.Context, network string, logPrefix string, node mdb.RpcNode, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
const ( const (
minBackoff = 2 * time.Second minBackoff = 2 * time.Second
maxBackoff = 60 * time.Second maxBackoff = 60 * time.Second
rejoinWait = 3 * time.Second rejoinWait = 3 * time.Second
stableResetAfter = 60 * time.Second
) )
failWait := minBackoff failWait := minBackoff
wsURL := node.Url
nodeLabel := data.RpcNodeLogLabel(node)
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -31,7 +42,13 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
client, err := ethclient.Dial(wsURL) client, err := ethclient.Dial(wsURL)
if err != nil { if err != nil {
if ctx.Err() != nil {
return
}
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait) log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
if recordEvmWsNodeFailure(logPrefix, network, node, "dial") {
return
}
if !sleepOrDone(ctx, failWait) { if !sleepOrDone(ctx, failWait) {
return return
} }
@@ -43,7 +60,13 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh) sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
if err != nil { if err != nil {
client.Close() client.Close()
if ctx.Err() != nil {
return
}
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait) log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
if recordEvmWsNodeFailure(logPrefix, network, node, "subscribe") {
return
}
if !sleepOrDone(ctx, failWait) { if !sleepOrDone(ctx, failWait) {
return return
} }
@@ -51,22 +74,49 @@ func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query eth
continue continue
} }
failWait = minBackoff failWait = minBackoff
data.RecordRpcSuccess(network)
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix) log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog) connectedAt := time.Now()
recvErr := recvLoop(ctx, client, sub, logsCh, network, logPrefix, handleLog)
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
if time.Since(connectedAt) >= stableResetAfter {
if recvErr != nil {
data.RecordRpcFailure(network)
}
data.RecordRpcNodeSuccess(node.ID)
} else if recvErr != nil && recordEvmWsNodeFailure(logPrefix, network, node, recvErr.Error()) {
return
}
if !sleepOrDone(ctx, rejoinWait) { if !sleepOrDone(ctx, rejoinWait) {
return return
} }
} }
} }
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) { func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, network string, logPrefix string, handleLog func(*ethclient.Client, types.Log)) error {
statsCtx, cancelStats := context.WithCancel(ctx)
statsTicker := time.NewTicker(evmStatsIdleInterval)
headerStatsDone := make(chan bool, 1)
lastStatsUpdateAt := time.Now()
headerStatsInFlight := false
requestLatestHeader := func() {
if headerStatsInFlight {
return
}
headerStatsInFlight = true
go func() {
headerStatsDone <- recordEvmLatestHeader(statsCtx, network, logPrefix, client)
}()
}
defer func() { defer func() {
cancelStats()
statsTicker.Stop()
sub.Unsubscribe() sub.Unsubscribe()
client.Close() client.Close()
}() }()
@@ -75,24 +125,94 @@ func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscr
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Sugar.Infof("%s context cancelled, stopping", logPrefix) log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
return return nil
case <-statsTicker.C:
if shouldRefreshEvmLatestHeader(lastStatsUpdateAt, headerStatsInFlight, time.Now()) {
requestLatestHeader()
}
case updated := <-headerStatsDone:
headerStatsInFlight = false
if updated {
lastStatsUpdateAt = time.Now()
}
case err := <-sub.Err(): case err := <-sub.Err():
if err != nil { if err != nil {
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err) log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
return fmt.Errorf("subscription error")
} else { } else {
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix) log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
return fmt.Errorf("subscription closed")
} }
return
case vLog, ok := <-logsCh: case vLog, ok := <-logsCh:
if !ok { if !ok {
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix) log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
return return fmt.Errorf("log channel closed")
}
if recordEvmLogBlockHeight(network, logPrefix, vLog.BlockNumber) {
lastStatsUpdateAt = time.Now()
} }
handleLog(client, vLog) handleLog(client, vLog)
} }
} }
} }
func shouldRefreshEvmLatestHeader(lastStatsUpdateAt time.Time, inFlight bool, now time.Time) bool {
return !inFlight && now.Sub(lastStatsUpdateAt) >= evmStatsIdleInterval
}
func recordEvmLogBlockHeight(network string, logPrefix string, blockNumber uint64) bool {
if blockNumber == 0 {
return false
}
if blockNumber > uint64(1<<63-1) {
log.Sugar.Warnf("%s log block number exceeds int64 range: %d", logPrefix, blockNumber)
return false
}
data.RecordRpcBlockHeight(network, int64(blockNumber))
return true
}
func recordEvmLatestHeader(ctx context.Context, network string, logPrefix string, client *ethclient.Client) bool {
headerCtx, cancel := context.WithTimeout(ctx, evmStatsHeaderTimeout)
defer cancel()
header, err := client.HeaderByNumber(headerCtx, nil)
if err != nil {
if ctx.Err() != nil {
return false
}
data.RecordRpcFailure(network)
log.Sugar.Warnf("%s latest header stats failed: %v", logPrefix, err)
return false
}
data.RecordRpcSuccess(network)
return recordEvmHeaderBlockHeight(network, logPrefix, header)
}
func recordEvmHeaderBlockHeight(network string, logPrefix string, header *types.Header) bool {
if header == nil || header.Number == nil {
return false
}
if !header.Number.IsInt64() {
log.Sugar.Warnf("%s latest header block number exceeds int64 range: %s", logPrefix, header.Number.String())
return false
}
data.RecordRpcBlockHeight(network, header.Number.Int64())
return true
}
func recordEvmWsNodeFailure(logPrefix string, network string, node mdb.RpcNode, reason string) bool {
data.RecordRpcFailure(network)
failures, cooling := data.RecordRpcNodeFailure(node.ID)
nodeLabel := data.RpcNodeLogLabel(node)
if !cooling {
log.Sugar.Warnf("%s WSS node failed (%s), node=%s failures=%d/%d", logPrefix, reason, nodeLabel, failures, data.RpcFailoverThreshold)
return false
}
log.Sugar.Warnf("%s WSS node reached fail threshold (%s), node=%s, resolving another node", logPrefix, reason, nodeLabel)
return true
}
// sleepOrDone waits for d or for ctx cancellation, whichever comes // sleepOrDone waits for d or for ctx cancellation, whichever comes
// first. Returns true if the sleep completed normally, false if ctx // first. Returns true if the sleep completed normally, false if ctx
// was cancelled (caller should exit). // was cancelled (caller should exit).
+5 -3
View File
@@ -65,18 +65,18 @@ func runPlasmaListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkPlasma, "[PLASMA-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[PLASMA-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkPlasma, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -97,9 +97,11 @@ func runPlasmaListener(contracts []common.Address) {
var blockTsMs int64 var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkPlasma)
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli() blockTsMs = time.Now().UnixMilli()
} else { } else {
data.RecordRpcSuccess(mdb.NetworkPlasma)
blockTsMs = int64(header.Time) * 1000 blockTsMs = int64(header.Time) * 1000
} }
+5 -3
View File
@@ -65,18 +65,18 @@ func runPolygonListener(contracts []common.Address) {
} }
}() }()
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]") wsNode, ok := resolveChainWsNode(mdb.NetworkPolygon, "[POLYGON-WS]")
if !ok { if !ok {
return return
} }
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[POLYGON-WS] connecting using WSS node %s watching %d contract(s)", data.RpcNodeLogLabel(wsNode), len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
Addresses: contracts, Addresses: contracts,
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { runEvmWsLogListener(ctx, mdb.NetworkPolygon, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 { if len(vLog.Topics) < 3 {
return return
} }
@@ -97,9 +97,11 @@ func runPolygonListener(contracts []common.Address) {
var blockTsMs int64 var blockTsMs int64
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkPolygon)
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
blockTsMs = time.Now().UnixMilli() blockTsMs = time.Now().UnixMilli()
} else { } else {
data.RecordRpcSuccess(mdb.NetworkPolygon)
blockTsMs = int64(header.Time) * 1000 blockTsMs = int64(header.Time) * 1000
} }
+28
View File
@@ -12,15 +12,21 @@ import (
type ListenSolJob struct{} type ListenSolJob struct{}
var gListenSolJobLock sync.Mutex var gListenSolJobLock sync.Mutex
var gSolanaBlockHeightLock = make(chan struct{}, 1)
func (r ListenSolJob) Run() { func (r ListenSolJob) Run() {
shouldRecordHeight := false
func() {
gListenSolJobLock.Lock() gListenSolJobLock.Lock()
defer gListenSolJobLock.Unlock() defer gListenSolJobLock.Unlock()
log.Sugar.Debug("[ListenSolJob] Job triggered") log.Sugar.Debug("[ListenSolJob] Job triggered")
if !data.IsChainEnabled(mdb.NetworkSolana) { if !data.IsChainEnabled(mdb.NetworkSolana) {
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping") log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
return return
} }
shouldRecordHeight = true
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana) walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
if err != nil { if err != nil {
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err) log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
@@ -39,4 +45,26 @@ func (r ListenSolJob) Run() {
} }
wg.Wait() wg.Wait()
log.Sugar.Debug("[ListenSolJob] Job completed") log.Sugar.Debug("[ListenSolJob] Job completed")
}()
if shouldRecordHeight {
recordSolanaLatestBlockHeight()
}
}
func recordSolanaLatestBlockHeight() {
select {
case gSolanaBlockHeightLock <- struct{}{}:
defer func() { <-gSolanaBlockHeightLock }()
default:
log.Sugar.Debug("[ListenSolJob] previous Solana block height stats still running, skipping")
return
}
height, err := service.SolGetBlockHeight()
if err != nil {
log.Sugar.Warnf("[ListenSolJob] get Solana block height failed: %v", err)
} else {
data.RecordRpcBlockHeight(mdb.NetworkSolana, height)
}
} }
+124
View File
@@ -0,0 +1,124 @@
package task
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestListenSolJobRecordsRuntimeBlockHeight(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
methodCalls := make(map[string]int)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
methodCalls[req.Method]++
w.Header().Set("Content-Type", "application/json")
if req.Method == "getBlockHeight" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": int64(654321),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"result": []map[string]interface{}{
{
"signature": "solana-test-signature",
"slot": uint64(123456),
"err": map[string]interface{}{
"InstructionError": []interface{}{0, "Custom"},
},
"blockTime": nil,
},
},
})
}))
defer server.Close()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: server.URL,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed solana rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkSolana,
Address: "SolTestAddress001",
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed solana wallet: %v", err)
}
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkSolana,
Symbol: "SOL",
ContractAddress: "",
Decimals: 9,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed solana chain_token: %v", err)
}
ListenSolJob{}.Run()
if methodCalls["getSignaturesForAddress"] != 1 {
t.Fatalf("getSignaturesForAddress calls = %d, want 1", methodCalls["getSignaturesForAddress"])
}
if methodCalls["getBlockHeight"] != 1 {
t.Fatalf("getBlockHeight calls = %d, want 1", methodCalls["getBlockHeight"])
}
stats := data.SnapshotRpcRuntimeStats()
solana := stats[mdb.NetworkSolana]
if solana.SuccessCount != 2 {
t.Fatalf("solana success_count = %d, want 2", solana.SuccessCount)
}
if solana.FailureCount != 0 {
t.Fatalf("solana failure_count = %d, want 0", solana.FailureCount)
}
if solana.LatestBlockHeight != 654321 {
t.Fatalf("solana latest_block_height = %d, want 654321", solana.LatestBlockHeight)
}
if solana.LastSyncAt.IsZero() {
t.Fatal("solana last_sync_at is zero")
}
}
func TestRecordSolanaLatestBlockHeightSkipsWhenPreviousStatsRunning(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
gSolanaBlockHeightLock <- struct{}{}
defer func() { <-gSolanaBlockHeightLock }()
recordSolanaLatestBlockHeight()
stats := data.SnapshotRpcRuntimeStats()
if _, ok := stats[mdb.NetworkSolana]; ok {
t.Fatalf("solana stats = %#v, want no stats when height reporter is already running", stats[mdb.NetworkSolana])
}
}
+56 -7
View File
@@ -167,19 +167,32 @@ func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
func GetNowBlock(baseURL string, apiKey string) (*Block, error) { func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{}) b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkTron)
return nil, err return nil, err
} }
var block Block var block Block
return &block, json.Unmarshal(b, &block) if err := json.Unmarshal(b, &block); err != nil {
data.RecordRpcFailure(mdb.NetworkTron)
return nil, err
}
data.RecordRpcSuccess(mdb.NetworkTron)
data.RecordRpcBlockHeight(mdb.NetworkTron, block.BlockHeader.RawData.Number)
return &block, nil
} }
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) { func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num}) b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
if err != nil { if err != nil {
data.RecordRpcFailure(mdb.NetworkTron)
return nil, err return nil, err
} }
var block Block var block Block
return &block, json.Unmarshal(b, &block) if err := json.Unmarshal(b, &block); err != nil {
data.RecordRpcFailure(mdb.NetworkTron)
return nil, err
}
data.RecordRpcSuccess(mdb.NetworkTron)
return &block, nil
} }
func loadTronTRC20TokenMap() map[string]mdb.ChainToken { func loadTronTRC20TokenMap() map[string]mdb.ChainToken {
@@ -337,6 +350,8 @@ func processBlock(block *Block, tokenMap map[string]mdb.ChainToken) {
type Scanner struct { type Scanner struct {
baseURL string baseURL string
apiKey string apiKey string
nodeID uint64
nodeLabel string
lastBlock int64 lastBlock int64
// 统计 // 统计
totalBlocks int64 totalBlocks int64
@@ -349,16 +364,16 @@ func NewScanner() *Scanner {
} }
func (s *Scanner) Init() error { func (s *Scanner) Init() error {
baseURL, apiKey, err := service.ResolveTronNode() node, err := service.ResolveTronRpcNode()
if err != nil { if err != nil {
return fmt.Errorf("resolve tron node: %w", err) return fmt.Errorf("resolve tron node: %w", err)
} }
s.baseURL = baseURL s.useRpcNode(node)
s.apiKey = apiKey
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL) log.Sugar.Infof("[TRON-BLOCK] using RPC node %s", s.nodeLabel)
block, err := GetNowBlock(s.baseURL, s.apiKey) block, err := GetNowBlock(s.baseURL, s.apiKey)
if err != nil { if err != nil {
s.recordRpcFailure("initial get latest block")
return fmt.Errorf("获取初始块失败: %w", err) return fmt.Errorf("获取初始块失败: %w", err)
} }
s.lastBlock = block.BlockHeader.RawData.Number s.lastBlock = block.BlockHeader.RawData.Number
@@ -366,6 +381,13 @@ func (s *Scanner) Init() error {
return nil return nil
} }
func (s *Scanner) useRpcNode(node *mdb.RpcNode) {
s.baseURL = strings.TrimRight(strings.TrimSpace(node.Url), "/")
s.apiKey = node.ApiKey
s.nodeID = node.ID
s.nodeLabel = data.RpcNodeLogLabel(*node)
}
func (s *Scanner) Run() { func (s *Scanner) Run() {
log.Sugar.Info("[TRON-BLOCK] start scanning (TRC20 + TRX)") log.Sugar.Info("[TRON-BLOCK] start scanning (TRC20 + TRX)")
ticker := time.NewTicker(PollInterval) ticker := time.NewTicker(PollInterval)
@@ -388,14 +410,17 @@ func (s *Scanner) poll() {
latest, err := GetNowBlock(s.baseURL, s.apiKey) latest, err := GetNowBlock(s.baseURL, s.apiKey)
if err != nil { if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err) log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
s.recordRpcFailure("get latest block")
return return
} }
latestNum := latest.BlockHeader.RawData.Number latestNum := latest.BlockHeader.RawData.Number
if latestNum <= s.lastBlock { if latestNum <= s.lastBlock {
data.RecordRpcNodeSuccess(s.nodeID)
return return
} }
tokenMap := loadTronTRC20TokenMap() tokenMap := loadTronTRC20TokenMap()
hadBlockFetchError := false
for num := s.lastBlock + 1; num <= latestNum; num++ { for num := s.lastBlock + 1; num <= latestNum; num++ {
var block *Block var block *Block
if num == latestNum { if num == latestNum {
@@ -404,7 +429,8 @@ func (s *Scanner) poll() {
block, err = GetBlockByNum(s.baseURL, s.apiKey, num) block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
if err != nil { if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err) log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
continue hadBlockFetchError = true
break
} }
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
@@ -420,6 +446,29 @@ func (s *Scanner) poll() {
} }
} }
} }
if hadBlockFetchError {
s.recordRpcFailure("get historical block")
return
}
data.RecordRpcNodeSuccess(s.nodeID)
}
func (s *Scanner) recordRpcFailure(reason string) {
failures, cooling := data.RecordRpcNodeFailure(s.nodeID)
if !cooling {
log.Sugar.Warnf("[TRON-BLOCK] RPC node failed (%s), node=%s failures=%d/%d", reason, s.nodeLabel, failures, data.RpcFailoverThreshold)
return
}
log.Sugar.Warnf("[TRON-BLOCK] RPC node reached fail threshold (%s), node=%s, trying another node", reason, s.nodeLabel)
node, err := service.ResolveTronRpcNode(s.nodeID)
if err != nil {
log.Sugar.Warnf("[TRON-BLOCK] no alternate RPC node available after failure, keep current node=%s: %v", s.nodeLabel, err)
return
}
oldLabel := s.nodeLabel
s.useRpcNode(node)
log.Sugar.Warnf("[TRON-BLOCK] switched RPC node from %s to %s", oldLabel, s.nodeLabel)
} }
func StartTronBlockScannerListener() { func StartTronBlockScannerListener() {
+166 -13
View File
@@ -1,23 +1,176 @@
package task package task
import "testing" import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
func TestTronAddressToHexConvertsBase58Contract(t *testing.T) { "github.com/GMWalletApp/epusdt/internal/testutil"
got, err := TronAddressToHex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") "github.com/GMWalletApp/epusdt/model/dao"
if err != nil { "github.com/GMWalletApp/epusdt/model/data"
t.Fatalf("TronAddressToHex(): %v", err) "github.com/GMWalletApp/epusdt/model/mdb"
)
func TestTronScannerSwitchesNodeAfterFailureThreshold(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
primary := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://primary-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 100,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
backup := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://backup-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(primary).Error; err != nil {
t.Fatalf("seed primary rpc_node: %v", err)
}
if err := dao.Mdb.Create(backup).Error; err != nil {
t.Fatalf("seed backup rpc_node: %v", err)
} }
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" scanner := NewScanner()
if got != want { scanner.useRpcNode(primary)
t.Fatalf("TronAddressToHex() = %q, want %q", got, want) for i := 0; i < data.RpcFailoverThreshold-1; i++ {
scanner.recordRpcFailure("test")
if scanner.nodeID != primary.ID {
t.Fatalf("node switched before threshold to id=%d", scanner.nodeID)
} }
} }
func TestNormalizeTronHexAddress(t *testing.T) { scanner.recordRpcFailure("test")
got := normalizeTronHexAddress("0X41A614F803B6FD780986A42C78EC9C7F77E6DED13C") if scanner.nodeID != backup.ID {
const want = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" t.Fatalf("scanner node id = %d, want backup id=%d", scanner.nodeID, backup.ID)
if got != want { }
t.Fatalf("normalizeTronHexAddress() = %q, want %q", got, want) if scanner.baseURL != backup.Url {
t.Fatalf("scanner baseURL = %q, want %q", scanner.baseURL, backup.Url)
}
}
func TestTronScannerStopsOnHistoricalBlockFetchError(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
data.ResetRpcFailoverForTest()
t.Cleanup(data.ResetRpcFailoverForTest)
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Purpose: mdb.RpcNodePurposeGeneral,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/wallet/getnowblock":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"block_header": map[string]interface{}{
"raw_data": map[string]interface{}{
"number": 3,
"timestamp": int64(1000),
},
},
"transactions": []interface{}{},
})
case "/wallet/getblockbynum":
http.Error(w, "temporary", http.StatusBadGateway)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
node.Url = server.URL
scanner := NewScanner()
scanner.useRpcNode(node)
scanner.lastBlock = 1
scanner.poll()
if scanner.lastBlock != 1 {
t.Fatalf("lastBlock = %d, want 1 so failed block is retried", scanner.lastBlock)
}
}
func TestTronRPCRecordsRuntimeStats(t *testing.T) {
data.ResetRpcRuntimeStatsForTest()
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/wallet/getnowblock":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"block_header": map[string]interface{}{
"raw_data": map[string]interface{}{
"number": 10,
"timestamp": int64(1000),
},
},
"transactions": []interface{}{},
})
case "/wallet/getblockbynum":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"block_header": map[string]interface{}{
"raw_data": map[string]interface{}{
"number": 9,
"timestamp": int64(1000),
},
},
"transactions": []interface{}{},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
if _, err := GetNowBlock(server.URL, ""); err != nil {
t.Fatalf("GetNowBlock(): %v", err)
}
if _, err := GetBlockByNum(server.URL, "", 9); err != nil {
t.Fatalf("GetBlockByNum(): %v", err)
}
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "temporary", http.StatusBadGateway)
}))
defer failing.Close()
if _, err := GetBlockByNum(failing.URL, "", 8); err == nil {
t.Fatal("GetBlockByNum() error = nil, want failure")
}
stats := data.SnapshotRpcRuntimeStats()
tron := stats[mdb.NetworkTron]
if tron.SuccessCount != 2 {
t.Fatalf("tron success_count = %d, want 2", tron.SuccessCount)
}
if tron.FailureCount != 1 {
t.Fatalf("tron failure_count = %d, want 1", tron.FailureCount)
}
if tron.LatestBlockHeight != 10 {
t.Fatalf("tron latest_block_height = %d, want 10", tron.LatestBlockHeight)
}
if tron.LastSyncAt.IsZero() {
t.Fatal("tron last_sync_at is zero")
} }
} }
+4 -4
View File
@@ -14,9 +14,9 @@ import (
const rpcProbeTimeout = 5 * time.Second const rpcProbeTimeout = 5 * time.Second
// RpcHealthJob periodically probes every enabled rpc_nodes row and // RpcHealthJob periodically probes enabled general/both rpc_nodes rows.
// writes status/last_latency_ms. Results drive SelectRpcNode weighted // Manual verification nodes are left for on-demand admin checks so paid
// picking at runtime. // endpoints are not consumed by the scheduler.
type RpcHealthJob struct{} type RpcHealthJob struct{}
var gRpcHealthJobLock sync.Mutex var gRpcHealthJobLock sync.Mutex
@@ -25,7 +25,7 @@ func (r RpcHealthJob) Run() {
gRpcHealthJobLock.Lock() gRpcHealthJobLock.Lock()
defer gRpcHealthJobLock.Unlock() defer gRpcHealthJobLock.Unlock()
nodes, err := data.ListRpcNodes("") nodes, err := data.ListRpcNodesForHealth()
if err != nil { if err != nil {
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err) log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
return return
+58
View File
@@ -1,5 +1,7 @@
package constant package constant
import "errors"
var Errno = map[int]string{ var Errno = map[int]string{
400: "system error", 400: "system error",
401: "signature verification failed", 401: "signature verification failed",
@@ -21,6 +23,30 @@ var Errno = map[int]string{
10017: "payment provider is not enabled", 10017: "payment provider is not enabled",
10018: "payment provider configuration is incomplete", 10018: "payment provider configuration is incomplete",
10019: "payment provider does not support this token or network", 10019: "payment provider does not support this token or network",
10020: "invalid rpc node purpose",
10021: "invalid rpc node url",
10022: "rpc node http url required",
10023: "rpc node websocket url required",
10024: "invalid rpc node type",
10025: "invalid username or password",
10026: "admin user disabled",
10027: "admin unauthorized",
10028: "admin user not found",
10029: "old password incorrect",
10030: "wallet not found",
10031: "api key not found",
10032: "rpc node not found",
10033: "order callback not applicable",
10034: "order notify url empty",
10035: "resend callback failed",
10036: "invalid notification config",
10037: "invalid notification events",
10038: "manual payment verification failed",
10039: "manual payment only supports on-chain orders",
10040: "initial admin password unavailable, check from logs",
10041: "invalid notify url",
10042: "payment provider order creation failed",
10043: "invalid setting item",
} }
var ( var (
@@ -44,6 +70,30 @@ var (
PaymentProviderNotEnabled = Err(10017) PaymentProviderNotEnabled = Err(10017)
PaymentProviderConfigErr = Err(10018) PaymentProviderConfigErr = Err(10018)
PaymentProviderNotSupport = Err(10019) PaymentProviderNotSupport = Err(10019)
RpcNodePurposeErr = Err(10020)
RpcNodeURLErr = Err(10021)
RpcNodeHTTPURLErr = Err(10022)
RpcNodeWebSocketURLErr = Err(10023)
RpcNodeTypeErr = Err(10024)
AdminInvalidCredentialErr = Err(10025)
AdminUserDisabledErr = Err(10026)
AdminUnauthorizedErr = Err(10027)
AdminUserNotFoundErr = Err(10028)
AdminOldPasswordErr = Err(10029)
WalletNotFoundErr = Err(10030)
ApiKeyNotFoundErr = Err(10031)
RpcNodeNotFoundErr = Err(10032)
OrderCallbackNotApplicable = Err(10033)
OrderNotifyURLEmptyErr = Err(10034)
OrderResendCallbackErr = Err(10035)
NotificationConfigErr = Err(10036)
NotificationEventsErr = Err(10037)
ManualPaymentVerifyErr = Err(10038)
ManualPaymentProviderErr = Err(10039)
InitialAdminPasswordErr = Err(10040)
NotifyURLErr = Err(10041)
PaymentProviderCreateErr = Err(10042)
SettingItemErr = Err(10043)
) )
type RspError struct { type RspError struct {
@@ -67,6 +117,14 @@ func (re *RspError) Render() (code int, msg string) {
return re.Code, re.Msg return re.Code, re.Msg
} }
func ResolveErrno(err error) (code int, msg string) {
var rspErr *RspError
if errors.As(err, &rspErr) {
return rspErr.Render()
}
return SystemErr.(*RspError).Render()
}
// HttpStatus maps a RspError code to the HTTP status the handler // HttpStatus maps a RspError code to the HTTP status the handler
// should use on the wire. Small codes (< 1000) are treated as real // should use on the wire. Small codes (< 1000) are treated as real
// HTTP status codes (e.g. 400 system error, 401 signature failure) so // HTTP status codes (e.g. 400 system error, 401 signature failure) so
+7 -7
View File
@@ -1,6 +1,7 @@
package http package http
import ( import (
"errors"
"github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/page" "github.com/GMWalletApp/epusdt/util/page"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -65,13 +66,12 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
func (r *Resp) FailJson(e echo.Context, err error) error { func (r *Resp) FailJson(e echo.Context, err error) error {
rr := new(Response) rr := new(Response)
httpStatus := http.StatusBadRequest httpStatus := http.StatusBadRequest
switch t := err.(type) { var rspErr *constant.RspError
case *constant.RspError: if errors.As(err, &rspErr) {
rr.StatusCode, rr.Message = t.Render() rr.StatusCode, rr.Message = rspErr.Render()
httpStatus = t.HttpStatus() httpStatus = rspErr.HttpStatus()
default: } else {
rr.StatusCode = http.StatusBadRequest rr.StatusCode, rr.Message = constant.ResolveErrno(err)
rr.Message = err.Error()
} }
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID) rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
return r.Json(e, httpStatus, &rr) return r.Json(e, httpStatus, &rr)
+61
View File
@@ -0,0 +1,61 @@
package http
import (
"encoding/json"
"errors"
"fmt"
nethttp "net/http"
"net/http/httptest"
"testing"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4"
)
func TestFailJsonHidesPlainErrorsBehindSystemErrno(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(nethttp.MethodGet, "/", nil)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
if err := new(Resp).FailJson(ctx, errors.New("database connection details")); err != nil {
t.Fatalf("FailJson returned error: %v", err)
}
if rec.Code != nethttp.StatusBadRequest {
t.Fatalf("http status = %d, want 400", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := int(body["status_code"].(float64)); got != 400 {
t.Fatalf("status_code = %d, want 400; body=%v", got, body)
}
if got, _ := body["message"].(string); got != constant.Errno[400] {
t.Fatalf("message = %q, want %q", got, constant.Errno[400])
}
}
func TestFailJsonPreservesWrappedRspErrorErrno(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(nethttp.MethodGet, "/", nil)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
err := fmt.Errorf("wrap: %w", constant.OrderNotExists)
if callErr := new(Resp).FailJson(ctx, err); callErr != nil {
t.Fatalf("FailJson returned error: %v", callErr)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := int(body["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; body=%v", got, body)
}
if got, _ := body["message"].(string); got != constant.Errno[10008] {
t.Fatalf("message = %q, want %q", got, constant.Errno[10008])
}
}
+88
View File
@@ -0,0 +1,88 @@
package security
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
const dnsLookupTimeout = 3 * time.Second
var lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) {
return net.DefaultResolver.LookupIPAddr(ctx, host)
}
// ValidatePublicHTTPURL verifies that raw is an HTTP(S) URL whose host does not
// point to localhost, private networks, link-local addresses, or metadata IPs.
func ValidatePublicHTTPURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return errors.New("url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return errors.New("url scheme must be http or https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return errors.New("url host is required")
}
if strings.Contains(host, "%") {
return fmt.Errorf("url host %q is not allowed", host)
}
if strings.TrimSuffix(strings.ToLower(host), ".") == "localhost" {
return fmt.Errorf("url host %q is not allowed", host)
}
if ip := net.ParseIP(host); ip != nil {
return validatePublicIP(host, ip)
}
ctx, cancel := context.WithTimeout(context.Background(), dnsLookupTimeout)
defer cancel()
addrs, err := lookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve url host %q: %w", host, err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolve url host %q: no addresses", host)
}
for _, addr := range addrs {
if err := validatePublicIP(host, addr.IP); err != nil {
return err
}
}
return nil
}
func validatePublicIP(host string, ip net.IP) error {
if isUnsafeIP(ip) {
return fmt.Errorf("url host %q resolves to disallowed address %s", host, ip.String())
}
return nil
}
func isUnsafeIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
!ip.IsGlobalUnicast()
}
+77
View File
@@ -0,0 +1,77 @@
package security
import (
"context"
"errors"
"net"
"testing"
)
func withLookupIPAddr(t *testing.T, fn func(context.Context, string) ([]net.IPAddr, error)) {
t.Helper()
old := lookupIPAddr
lookupIPAddr = fn
t.Cleanup(func() {
lookupIPAddr = old
})
}
func TestValidatePublicHTTPURLAllowsPublicHTTPURL(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
if host != "example.com" {
t.Fatalf("lookup host = %q, want example.com", host)
}
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err != nil {
t.Fatalf("ValidatePublicHTTPURL returned error: %v", err)
}
}
func TestValidatePublicHTTPURLRejectsUnsafeURLs(t *testing.T) {
tests := []string{
"",
"://bad",
"ftp://example.com/file",
"https:///missing-host",
"http://localhost/notify",
"http://localhost./notify",
"http://127.0.0.1/notify",
"http://[::1]/notify",
"http://10.0.0.1/notify",
"http://172.16.0.1/notify",
"http://172.31.255.255/notify",
"http://192.168.1.1/notify",
"http://169.254.1.1/notify",
"http://169.254.169.254/latest/meta-data/",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
if err := ValidatePublicHTTPURL(raw); err == nil {
t.Fatalf("ValidatePublicHTTPURL(%q) returned nil, want error", raw)
}
})
}
}
func TestValidatePublicHTTPURLRejectsDomainResolvingToPrivateIP(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}
func TestValidatePublicHTTPURLRejectsUnresolvableDomain(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return nil, errors.New("lookup failed")
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./unauthorized-error-Dv-DRB5e.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-nJjRbgyJ.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./forbidden-Z2osNUbj.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./forbidden-CHYUR1RB.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./not-found-error-AnL_Yh5s.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./not-found-error-B-GnW0fC.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./general-error-BWiWPoKk.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./general-error-CkWgD-Kh.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./maintenance-error-DVvcryIx.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./maintenance-error-CpldkShG.js";var t=e;export{t as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Pp as n}from"./messages-d1hRBHLP.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-CMPEj0pJ.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-9UShCljL.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
-1
View File
@@ -1 +0,0 @@
import{cf as e,df as t,sf as n}from"./messages-CNn8r9_i.js";import{o as r,s as i,t as a}from"./useRouter-nAc8XVed.js";import{t as o}from"./useStore-DvqIJ51s.js";import{f as s}from"./ClientOnly-CnnlJdPz.js";import"./redirect-dE0wk0WH.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-Dtcv7YR3.js";import{n as p}from"./matchContext-DFaf2uaD.js";import{t as m}from"./atom-DI1Bn-1s.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=t(e(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
@@ -0,0 +1 @@
import{h as e}from"./editor.api2-BpueNIaM.js";import"./toggleHighContrast-DRztGxhw.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
+1
View File
@@ -0,0 +1 @@
import{Pp as e}from"./messages-d1hRBHLP.js";import{m as t}from"./search-provider-Zrg-SPXk.js";import{n,t as r}from"./theme-switch-DporjlJW.js";import{t as i}from"./general-error-CkWgD-Kh.js";import{t as a}from"./not-found-error-AnL_Yh5s.js";import{t as o}from"./_error-DqSF-qO2.js";import{t as s}from"./unauthorized-error-Dv-DRB5e.js";import{t as c}from"./forbidden-CHYUR1RB.js";import{t as l}from"./maintenance-error-CpldkShG.js";import{n as u,r as d,t as f}from"./header-BZlnQmen.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-W1gJEn3q.js","assets/messages-CNn8r9_i.js","assets/search-provider-CSlvLJVo.js","assets/dist-BTQ1T4Z1.js","assets/dist-DO0nnv5Z.js","assets/confirm-dialog-BfcDUlJ6.js","assets/button-JhAGlo93.js","assets/dist-G_XNJPxa.js","assets/dist-DB35ZrXr.js","assets/dist-CItiEXJ0.js","assets/dist-C1FpE_gz.js","assets/es2015-Cpoj36Vk.js","assets/dist-DF0CDGwe.js","assets/dist-D3XJ366n.js","assets/dist-Hi9laCRZ.js","assets/dist-CxXfuAtu.js","assets/dist-CsZK0Yly.js","assets/dist-rKTljhTV.js","assets/dist-Dd3gWbKI.js","assets/separator-qol0tj1x.js","assets/tooltip-MvkuIGNu.js","assets/dist-Dx8cqxxi.js","assets/dist-BsUCk_Bp.js","assets/auth-store-5w5M8JMv.js","assets/dist-ZMQ7qKQl.js","assets/with-selector-BQwabIHR.js","assets/cookies-DtK8C3EL.js","assets/useRouter-nAc8XVed.js","assets/useNavigate-BCccwuC3.js","assets/useStore-DvqIJ51s.js","assets/command-CKXbBl-6.js","assets/createLucideIcon-jd6iYnna.js","assets/x-Bg6gd5mH.js","assets/skeleton-C4OTIiu5.js","assets/wallet-lYtGOgRb.js","assets/sun-BRbC962t.js","assets/shield-check-BgZA2DnP.js","assets/logo-B2eAImQj.js","assets/input-B_aFxEOi.js","assets/font-provider-Mxc80MA7.js","assets/theme-provider-MZRg99c_.js","assets/theme-switch-D0f_-5f4.js","assets/dropdown-menu-CmpC2aB4.js","assets/check-DR6b4Bqj.js","assets/header-CvU1929-.js","assets/link-DtcJCjen.js","assets/ClientOnly-CnnlJdPz.js","assets/forbidden-Z2osNUbj.js","assets/general-error-BWiWPoKk.js","assets/maintenance-error-DVvcryIx.js","assets/not-found-error-B-GnW0fC.js","assets/unauthorized-error-nJjRbgyJ.js"])))=>i.map(i=>d[i]);
import{n as e,r as t,t as n}from"./preload-helper-OIYFJRFs.js";var r=t(`/_authenticated/errors/$error`)({component:e(()=>n(()=>import(`./_error-W1gJEn3q.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51])),`component`)});export{r as t};
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-D4dSyXw0.js","assets/search-provider-Zrg-SPXk.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-CIovDv3v.js","assets/messages-d1hRBHLP.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-CIBixfaW.js","assets/button-CbdgUDvS.js","assets/clsx-D9aGYY3V.js","assets/dist-BjDKUEMi.js","assets/dist-ByVfo7qC.js","assets/dist-qAA-axL4.js","assets/dist-CXfAGnWR.js","assets/es2015-Chgs9D7p.js","assets/dist-xVhEP_Vg.js","assets/dist-s_CiB-DN.js","assets/dist-BlTmpVme.js","assets/dist-D13PZ7es.js","assets/dist-DOx-EAQr.js","assets/dist-BixeH3nX.js","assets/dist-DuNl0ZwE.js","assets/separator-CE12xF6y.js","assets/tooltip-DuzUBeLr.js","assets/dist-BIXdBdVU.js","assets/dist-BGJ_1VEi.js","assets/auth-store-GibZbQPL.js","assets/dist-Bk35TVDN.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-DIyNKYlo.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-SgRLLGoQ.js","assets/skeleton-DwLAyCLM.js","assets/wallet-CkKQfl5z.js","assets/sun-BwqrnykR.js","assets/shield-check-CiWq1V4z.js","assets/logo-CGyEdT5T.js","assets/input-CusCjhBW.js","assets/font-provider-aBht_Njp.js","assets/theme-provider-BXElecjF.js","assets/theme-switch-DporjlJW.js","assets/dropdown-menu-DwTFINO1.js","assets/check-DldThLle.js","assets/header-BZlnQmen.js","assets/link-Br2JDLvw.js","assets/ClientOnly-CMPEj0pJ.js","assets/forbidden-CHYUR1RB.js","assets/general-error-CkWgD-Kh.js","assets/maintenance-error-CpldkShG.js","assets/not-found-error-AnL_Yh5s.js","assets/unauthorized-error-Dv-DRB5e.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CFOfOLgA.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-D4dSyXw0.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
-1
View File
@@ -1 +0,0 @@
import{sf as e}from"./messages-CNn8r9_i.js";import{m as t}from"./search-provider-CSlvLJVo.js";import{n,t as r}from"./theme-switch-D0f_-5f4.js";import{t as i}from"./general-error-BWiWPoKk.js";import{t as a}from"./not-found-error-B-GnW0fC.js";import{t as o}from"./_error-DXXkRW28.js";import{t as s}from"./unauthorized-error-nJjRbgyJ.js";import{t as c}from"./forbidden-Z2osNUbj.js";import{t as l}from"./maintenance-error-DVvcryIx.js";import{n as u,r as d,t as f}from"./header-CvU1929-.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Ck9STVZb.js","assets/chunk-DECur_0Z.js","assets/button-CbdgUDvS.js","assets/clsx-D9aGYY3V.js","assets/messages-d1hRBHLP.js","assets/react-CO2uhaBc.js","assets/select-Cv_sRi68.js","assets/dist-BIXdBdVU.js","assets/dist-CIovDv3v.js","assets/dist-qAA-axL4.js","assets/dist-CXfAGnWR.js","assets/dist-DuNl0ZwE.js","assets/dist-CRowTfGU.js","assets/dist-DOx-EAQr.js","assets/dist-s_CiB-DN.js","assets/dist-ByVfo7qC.js","assets/es2015-Chgs9D7p.js","assets/dist-BixeH3nX.js","assets/dist-BGJ_1VEi.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-GibZbQPL.js","assets/dist-Bk35TVDN.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard--eZunBzP.js","assets/arrow-left-Ck1MERx0.js","assets/radio-group-CX51BC9z.js","assets/dist-BlTmpVme.js","assets/dist-xVhEP_Vg.js","assets/dist-D13PZ7es.js","assets/loader-circle-DRj7MiMc.js","assets/send-g-CrrC4k.js","assets/triangle-alert-BEkslGAT.js","assets/dialog-SgRLLGoQ.js","assets/dist-BjDKUEMi.js","assets/crypto-icon-BwB-F_ID.js","assets/labels-CfnKbsNx.js","assets/input-CusCjhBW.js","assets/checkout-model-CY0q1fms.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CFOfOLgA.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Ck9STVZb.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42])),`component`)});export{r as t};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-hOrsJyaR.js","assets/messages-CNn8r9_i.js","assets/button-JhAGlo93.js","assets/select-CdsI6h86.js","assets/dist-Dx8cqxxi.js","assets/dist-DO0nnv5Z.js","assets/dist-CItiEXJ0.js","assets/dist-C1FpE_gz.js","assets/dist-Dd3gWbKI.js","assets/dist-BTQ1T4Z1.js","assets/dist-CsZK0Yly.js","assets/dist-D3XJ366n.js","assets/dist-DB35ZrXr.js","assets/es2015-Cpoj36Vk.js","assets/dist-rKTljhTV.js","assets/dist-BsUCk_Bp.js","assets/createLucideIcon-jd6iYnna.js","assets/check-DR6b4Bqj.js","assets/chevron-down-ChSg82Ll.js","assets/auth-store-5w5M8JMv.js","assets/dist-ZMQ7qKQl.js","assets/with-selector-BQwabIHR.js","assets/cookies-DtK8C3EL.js","assets/useNavigate-BCccwuC3.js","assets/useRouter-nAc8XVed.js","assets/copy-to-clipboard-BREe-5_K.js","assets/arrow-left-CZGzVtKN.js","assets/radio-group-OobWtrkU.js","assets/dist-Hi9laCRZ.js","assets/dist-DF0CDGwe.js","assets/dist-CxXfuAtu.js","assets/loader-circle-5pHE73zj.js","assets/triangle-alert-BtrfVojt.js","assets/x-Bg6gd5mH.js","assets/labels-in-p_FvL.js","assets/checkout-model-NEs9l9im.js"])))=>i.map(i=>d[i]);
import{n as e,r as t,t as n}from"./preload-helper-OIYFJRFs.js";var r=t(`/cashier/$trade_id/`)({component:e(()=>n(()=>import(`./_trade_id-hOrsJyaR.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35])),`component`)});export{r as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Xd as e,Yd as t,an as n,cn as r,dn as i,ln as a,on as o,sf as s,sn as c,un as l}from"./messages-CNn8r9_i.js";import{i as u,n as d,t as f}from"./button-JhAGlo93.js";import{n as p,r as m}from"./font-provider-Mxc80MA7.js";import{n as h}from"./theme-provider-MZRg99c_.js";import{t as g}from"./chevron-down-ChSg82Ll.js";import{n as _,t as v}from"./radio-group-OobWtrkU.js";import{r as y,s as b}from"./zod-owHEOVTN.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-LXiclVxl.js";import{t as A}from"./page-header-geN5Hrcb.js";import{t as j}from"./show-submitted-data-DVgKD85p.js";var M=s(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:i,setFont:s}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:i},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==i&&s(e.font),e.theme!==l&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:a()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:r()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:n})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:n.value,onValueChange:n.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:t()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:n()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:i(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component}; import{Cp as e,Pp as t,Sp as n,_n as r,bn as i,gn as a,hn as o,mn as s,vn as c,yn as l}from"./messages-d1hRBHLP.js";import{i as u,n as d,t as f}from"./button-CbdgUDvS.js";import{n as p,r as m}from"./font-provider-aBht_Njp.js";import{n as h}from"./theme-provider-BXElecjF.js";import{t as g}from"./chevron-down-BPPKEraq.js";import{n as _,t as v}from"./radio-group-CX51BC9z.js";import{r as y,s as b}from"./zod-CQemhjci.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-Db7d571a.js";import{t as A}from"./page-header-DkeFH2Rh.js";import{t as j}from"./show-submitted-data-CfmGDLbU.js";var M=t(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:i}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&i(e.font),e.theme!==l&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:r()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:t})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:a()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:t.value,onValueChange:t.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:n()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:s()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:i(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
@@ -1 +1 @@
import{t as e}from"./createLucideIcon-jd6iYnna.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t}; import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Pp as n}from"./messages-d1hRBHLP.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
@@ -1 +0,0 @@
import{cf as e,df as t,sf as n}from"./messages-CNn8r9_i.js";var r=t(e(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More