16 Commits

Author SHA1 Message Date
line-6000 0c059e8a28 Merge pull request #50 from GMWalletApp/dev 2026-04-24 19:18:45 +08:00
line-6000 485059f60e - Removed manifest link from index.html.
- Updated script and module preload links in index.html for optimized loading.
- Modified service worker (sw.js) to reflect new asset revisions and improve caching strategy.
- Updated configuration documentation in BT_RUN.md, docker-RUN.md, and manual_RUN.md to clarify currency rate settings.
2026-04-24 19:10:11 +08:00
root 143bc84ac3 docs: sync docker examples to latest image 2026-04-23 07:25:56 +00:00
Ginta 06856e5caa release to group 2026-04-23 03:15:56 +08:00
Ginta b9bde0f95b release to group 2026-04-23 02:49:58 +08:00
line-6000 1b92ec1905 Merge pull request #47 from GMWalletApp/dev
Dev
2026-04-23 02:26:18 +08:00
line-6000 89be1fc967 Refactor project structure and update import paths to GMWalletApp/epusdt 2026-04-23 02:17:21 +08:00
line-6000 91936d580f refactor(rpc): streamline RPC node selection and remove hardcoded defaults 2026-04-23 02:08:18 +08:00
line-6000 09514a6946 fix(http): block /install page after setup is complete
- skip SPA fallback for `/install` and `/install/*` in main server
- keep install wizard accessible only during `RunInstallServer` phase
- prevent post-install access to install UI
2026-04-23 00:39:22 +08:00
Ginta b6cd4325b8 release action 2026-04-22 22:46:14 +08:00
Ginta 27d5e5bcfe add .env file into docker image 2026-04-22 22:00:51 +08:00
Ginta b625bac1e3 add .env file into docker image 2026-04-22 21:03:10 +08:00
Ginta bc5bbf72f7 add .env file into docker image 2026-04-22 20:46:11 +08:00
line-6000 3a3a830d93 fix(server): prevent SPA fallback from swallowing API routes
- add ShouldSkipSPAFallback() deny prefixes: /api, /admin/api, /payments, /pay
- apply StaticWithConfig Skipper in both main http server and install server
- add unit tests for fallback skip matching behavior
2026-04-22 20:27:34 +08:00
Ginta 59aa71209c action 2026-04-22 18:06:59 +08:00
Ginta cdf274599e build 2026-04-22 16:43:47 +08:00
280 changed files with 1604 additions and 664 deletions
+26 -15
View File
@@ -5,28 +5,40 @@ on:
push: push:
branches: branches:
- "main" - "main"
- "master"
tags:
- "v*"
env: env:
DOCKERHUB_NAMESPACE: gmwallet
IMAGE_NAME: epusdt IMAGE_NAME: epusdt
jobs: jobs:
push-docker-hub: push-docker-hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Compute Build Metadata - name: Set Up QEMU
id: meta uses: docker/setup-qemu-action@v3
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login - name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=alpine
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,suffix=-alpine
type=ref,event=tag
type=sha,prefix=sha-,suffix=-alpine
- name: Login To Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -38,9 +50,8 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64
build-args: | build-args: |
VERSION=${{ steps.meta.outputs.version }} API_RATE_URL=${{ vars.API_RATE_URL }}
COMMIT=${{ steps.meta.outputs.commit }} tags: ${{ steps.meta.outputs.tags }}
DATE=${{ steps.meta.outputs.date }} labels: ${{ steps.meta.outputs.labels }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
+7
View File
@@ -4,6 +4,12 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
inputs:
release_tag:
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
required: true
type: string
permissions: permissions:
contents: write contents: write
@@ -25,6 +31,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
- name: Ensure Tag Commit Is On Default Branch - name: Ensure Tag Commit Is On Default Branch
env: env:
+6 -1
View File
@@ -5,7 +5,7 @@ ENV CGO_ENABLED=0
WORKDIR /app WORKDIR /app
RUN git clone https://github.com/GMwalletApp/epusdt.git . COPY . /app
WORKDIR /app/src WORKDIR /app/src
RUN go mod download RUN go mod download
@@ -14,10 +14,15 @@ RUN go build -o /app/epusdt .
FROM alpine:latest AS runner FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
ARG API_RATE_URL=""
WORKDIR /app WORKDIR /app
COPY --from=builder /app/src/static /app/static COPY --from=builder /app/src/static /app/static
COPY --from=builder /app/src/static /static COPY --from=builder /app/src/static /static
COPY --from=builder /app/src/.env.example /app/.env
RUN if [ -n "$API_RATE_URL" ]; then \
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
fi
COPY --from=builder /app/epusdt . COPY --from=builder /app/epusdt .
VOLUME /app/conf VOLUME /app/conf
+4 -4
View File
@@ -15,10 +15,10 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a> <a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a> <a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a> <a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
<a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a> <a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
</p> </p>
--- ---
@@ -42,7 +42,7 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
| **Polygon** | USDT、USDC | | **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… | | **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 > 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
--- ---
@@ -123,7 +123,7 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
## 💬 社区与支持 ## 💬 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。 **遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源: 加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
epusdt: epusdt:
image: gmwallet/epusdt:alpine image: gmwallet/epusdt:latest
restart: always restart: always
build: build:
context: . context: .
@@ -2,8 +2,8 @@
/** /**
* The file was created by Assimon. * The file was created by Assimon.
* *
* @author assimon<ashang@utf8.hk> * @author GMWalletApp<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk> * @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/ * @link http://utf8.hk/
*/ */
+2 -2
View File
@@ -2,8 +2,8 @@
/** /**
* The file was created by Assimon. * The file was created by Assimon.
* *
* @author assimon<ashang@utf8.hk> * @author GMWalletApp<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk> * @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/ * @link http://utf8.hk/
*/ */
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
+12 -15
View File
@@ -3,14 +3,14 @@ package bootstrap
import ( import (
"sync" "sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/mq" "github.com/GMWalletApp/epusdt/mq"
"github.com/assimon/luuu/task" "github.com/GMWalletApp/epusdt/task"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
) )
@@ -22,7 +22,7 @@ func InitApp() {
log.Init() log.Init()
dao.Init() dao.Init()
// Wire settings-table lookups into the config package so // Wire settings-table lookups into the config package so
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides. // GetRateApiUrl / GetUsdtRate can consult admin-configured values.
config.SettingsGetString = func(key string) string { config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "") return data.GetSettingString(key, "")
} }
@@ -36,20 +36,17 @@ func InitApp() {
} }
} }
} }
// config.Init() computes RateApiUrl before SettingsGetString is
// installed, so refresh the cache once DB-backed settings are available.
config.RateApiUrl = config.GetRateApiUrl()
// Seed admin account and JWT secret so the management console is // Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent. // immediately usable on a fresh install. Both are idempotent.
adminPwd, isNew, err := data.EnsureDefaultAdmin() _, isNew, err := data.EnsureDefaultAdmin()
if err != nil { if err != nil {
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err) color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
} }
if isNew { if isNew {
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗") color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
color.Yellow.Println("║ Default admin account created. Change the password via admin console! ║") color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
color.Yellow.Printf("║ Username: admin ║\n") color.Yellow.Printf("║ Username: admin ║\n")
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd) color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝") color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
} }
if _, err := appjwt.EnsureSecret(); err != nil { if _, err := appjwt.EnsureSecret(); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
package command package command
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/gookit/color" "github.com/gookit/color"
) )
+18 -8
View File
@@ -6,16 +6,17 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/assimon/luuu/bootstrap" "github.com/GMWalletApp/epusdt/bootstrap"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/install" "github.com/GMWalletApp/epusdt/install"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/route" "github.com/GMWalletApp/epusdt/route"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
luluHttp "github.com/assimon/luuu/util/http" luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -72,6 +73,15 @@ func HttpServerStart() {
} }
} }
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{ e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
Skipper: func(c echo.Context) bool {
path := c.Request().URL.Path
if path == "/install" || strings.HasPrefix(path, "/install/") {
// The install wizard is only served by install.RunInstallServer
// before bootstrap. Once main server starts, block /install.
return true
}
return luluHttp.ShouldSkipSPAFallback(path)
},
HTML5: true, HTML5: true,
Index: "index.html", Index: "index.html",
Root: wwwRoot, Root: wwwRoot,
+1 -1
View File
@@ -1,7 +1,7 @@
package command package command
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package command
import ( import (
"fmt" "fmt"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+14 -32
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -34,9 +34,6 @@ var (
TgBotToken string TgBotToken string
TgProxy string TgProxy string
TgManage int64 TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev" BuildVersion = "0.0.0-dev"
BuildCommit = "none" BuildCommit = "none"
BuildDate = "unknown" BuildDate = "unknown"
@@ -77,9 +74,6 @@ func Init() {
TgBotToken = viper.GetString("tg_bot_token") TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy") TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
} }
func mustMkdir(path string) { func mustMkdir(path string) {
@@ -277,13 +271,14 @@ func GetRateForCoin(coin string, base string) float64 {
if usdtRate > 0 { if usdtRate > 0 {
return 1 / usdtRate return 1 / usdtRate
} }
return 0
} }
} }
return getRateForCoinFromAPI(coin, base)
}
baseURL := RateApiUrl func getRateForCoinFromAPI(coin string, base string) float64 {
if baseURL == "" { baseURL := GetRateApiUrl()
baseURL = GetRateApiUrl()
}
if baseURL == "" { if baseURL == "" {
log.Printf("rate api url is empty") log.Printf("rate api url is empty")
return 0.0 return 0.0
@@ -315,19 +310,18 @@ func GetRateForCoin(coin string, base string) float64 {
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
// Prefer the DB-backed override (admin-configurable). Fall back to // Only the admin setting can force the USDT/CNY rate. When the
// the legacy .env var, then the hardcoded 6.4 default. // setting is unset, zero, or negative, fall back to the rate API.
if forced := settingsForcedUsdtRate(); forced > 0 { if forced := settingsForcedUsdtRate(); forced > 0 {
return forced return forced
} }
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 { apiRate := getRateForCoinFromAPI("usdt", "cny")
return forcedUsdtRate if apiRate > 0 {
return 1 / apiRate
} }
if UsdtRate <= 0 { log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
return 6.4 return 0
}
return UsdtRate
} }
func GetOrderExpirationTime() int { func GetOrderExpirationTime() int {
@@ -398,15 +392,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
} }
return time.Duration(seconds) * time.Second return time.Duration(seconds) * time.Second
} }
func GetSolanaRpcUrl() string {
rpcUrl := viper.GetString("solana_rpc_url")
if rpcUrl == "" {
return "https://api.mainnet-beta.solana.com"
}
return rpcUrl
}
func GetEthereumWsUrl() string {
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
}
+165
View File
@@ -1,11 +1,52 @@
package config package config
import ( import (
"io"
"math"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
"github.com/spf13/viper"
) )
func installSettingsGetter(t *testing.T, values map[string]string) {
t.Helper()
oldGetter := SettingsGetString
SettingsGetString = func(key string) string {
return values[key]
}
t.Cleanup(func() {
SettingsGetString = oldGetter
})
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) { func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
t.Helper() t.Helper()
@@ -135,3 +176,127 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Fatalf("config path = %s, want %s", got, flagPath) t.Fatalf("config path = %s, want %s", got, flagPath)
} }
} }
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
apiCalled := false
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
apiCalled = true
return &http.Response{
StatusCode: http.StatusInternalServerError,
Status: http.StatusText(http.StatusInternalServerError),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "7.25",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
if got != 7.25 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
}
if apiCalled {
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
}
}
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(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_usdt_rate": "-1",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
want := 1 / 0.14635
if math.Abs(got-want) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
}
rate := GetRateForCoin("usdt", "cny")
if math.Abs(rate-0.14635) > 1e-9 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
}
}
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetUsdtRate(); got != 0 {
t.Fatalf("GetUsdtRate() = %v, want 0", got)
}
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
}
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
callCount := 0
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
callCount++
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
if callCount != 1 {
t.Fatalf("rate api call count = %d, want 1", callCount)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package config
// create a cycle via `model/dao -> config`. // create a cycle via `model/dao -> config`.
// //
// If unset (e.g. during early startup or tests) the getters return the // If unset (e.g. during early startup or tests) the getters return the
// zero string / 0 and callers fall through to the .env / default path. // zero string / 0 and callers apply their own fallback behavior.
// SettingsGetString is installed by bootstrap.Init with a closure that // SettingsGetString is installed by bootstrap.Init with a closure that
// reads from the settings table. Runtime-only. // reads from the settings table. Runtime-only.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+48 -6
View File
@@ -3,9 +3,9 @@ package admin
import ( import (
"errors" "errors"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -34,6 +34,12 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"` PasswordIsDefault bool `json:"password_is_default" example:"true"`
} }
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT. // Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login // @Summary Admin login
// @Description Verify credentials and return a signed JWT token // @Description Verify credentials and return a signed JWT token
@@ -77,6 +83,42 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
}) })
} }
// GetInitialPassword returns the one-time initial admin password.
// @Summary Get initial admin password (one-time)
// @Description Returns the initial random admin password once, then invalidates it.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password [get]
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, InitialPasswordResponse{
Username: "admin",
Password: password,
})
}
// GetInitialPasswordHash returns the initial-password fingerprint so the
// frontend can warn until the password is changed.
// @Summary Get initial admin password hash
// @Description Returns the initial password hash and password-changed flag.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password-hash [get]
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
info, err := data.GetInitialAdminPasswordHashInfo()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, info)
}
// Logout is a no-op stub — tokens are stateless and the frontend // Logout is a no-op stub — tokens are stateless and the frontend
// discards them. Kept for API symmetry with the documented spec. // discards them. Kept for API symmetry with the documented spec.
// @Summary Admin logout // @Summary Admin logout
@@ -111,9 +153,9 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
if user.ID == 0 { if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found")) return c.FailJson(ctx, errors.New("user not found"))
} }
// Warn the frontend when the operator hasn't changed the default // Warn the frontend when the operator hasn't changed the seeded
// password so the UI can show a prominent reminder. // initial password.
isDefault := data.VerifyPassword(user.PasswordHash, "admin") isDefault := data.IsUsingInitialAdminPassword()
return c.SucJson(ctx, MeResponse{ return c.SucJson(ctx, MeResponse{
AdminUser: *user, AdminUser: *user,
PasswordIsDefault: isDefault, PasswordIsDefault: isDefault,
+2 -2
View File
@@ -1,8 +1,8 @@
package admin package admin
import ( import (
"github.com/assimon/luuu/controller" "github.com/GMWalletApp/epusdt/controller"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
package admin package admin
import ( import (
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -4,8 +4,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -6,10 +6,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/notify" "github.com/GMWalletApp/epusdt/notify"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/task" "github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+7 -7
View File
@@ -3,10 +3,10 @@ package admin
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -14,8 +14,8 @@ import (
// Supported groups and keys: // Supported groups and keys:
// //
// - group=rate: // - group=rate:
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api) // rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
// rate.api_url (string) — external rate API URL // rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
// 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
// //
@@ -73,7 +73,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// @Description Batch insert/update settings. Returns per-key status. // @Description Batch insert/update settings. Returns per-key status.
// @Description Supported groups: brand, rate, system, epay. // @Description Supported groups: brand, rate, system, epay.
// @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 rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @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 brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url. // @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
// @Description system group keys: system.order_expiration_time. // @Description system group keys: system.order_expiration_time.
// @Tags Admin Settings // @Tags Admin Settings
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -2,7 +2,7 @@ package controller
import ( import (
"errors" "errors"
"github.com/assimon/luuu/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"
"github.com/gookit/validate/locales/zhtw" "github.com/gookit/validate/locales/zhtw"
+1 -1
View File
@@ -1,6 +1,6 @@
package comm package comm
import "github.com/assimon/luuu/controller" import "github.com/GMWalletApp/epusdt/controller"
var Ctrl = &BaseCommController{} var Ctrl = &BaseCommController{}
+6 -6
View File
@@ -4,12 +4,12 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -5,9 +5,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/assimon/luuu module github.com/GMWalletApp/epusdt
go 1.25.0 go 1.25.0
+4
View File
@@ -28,6 +28,7 @@ import (
"text/template" "text/template"
"time" "time"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@@ -195,6 +196,9 @@ func RunInstallServer(listenAddr, envFilePath string) {
} }
} }
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: func(c echo.Context) bool {
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
},
HTML5: true, HTML5: true,
Index: "index.html", Index: "index.html",
Root: wwwRoot, Root: wwwRoot,
+23 -6
View File
@@ -4,10 +4,10 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
appLog "github.com/assimon/luuu/util/log" appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/libtnb/sqlite" "github.com/libtnb/sqlite"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
@@ -20,7 +20,6 @@ func SetupTestDatabases(t testing.TB) func() {
t.Helper() t.Helper()
viper.Reset() viper.Reset()
viper.Set("forced_usdt_rate", 1.0)
viper.Set("app_uri", "https://example.com") viper.Set("app_uri", "https://example.com")
viper.Set("order_expiration_time", 10) viper.Set("order_expiration_time", 10)
viper.Set("order_notice_max_retry", 2) viper.Set("order_notice_max_retry", 2)
@@ -31,7 +30,6 @@ func SetupTestDatabases(t testing.TB) func() {
config.HTTPAccessLog = false config.HTTPAccessLog = false
config.SQLDebug = false config.SQLDebug = false
config.LogLevel = "error" config.LogLevel = "error"
config.UsdtRate = 0
appLog.Sugar = zap.NewNop().Sugar() appLog.Sugar = zap.NewNop().Sugar()
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db")) mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
@@ -52,6 +50,16 @@ func SetupTestDatabases(t testing.TB) func() {
dao.Mdb = mainDB dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB dao.RuntimeDB = runtimeDB
config.SettingsGetString = func(key string) string {
if dao.Mdb == nil {
return ""
}
var row mdb.Setting
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
return ""
}
return row.Value
}
// Seed all standard chains as enabled so IsChainEnabled checks pass. // Seed all standard chains as enabled so IsChainEnabled checks pass.
for _, network := range []string{ for _, network := range []string{
@@ -74,12 +82,21 @@ func SetupTestDatabases(t testing.TB) func() {
Pid: "1001", SecretKey: "test-token", Pid: "1001", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable, Status: mdb.ApiKeyStatusEnable,
}) })
if err := dao.Mdb.Create(&mdb.Setting{
Group: "rate",
Key: "rate.forced_usdt_rate",
Value: "1.0",
Type: "string",
}).Error; err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
return func() { return func() {
closeDB(t, runtimeDB) closeDB(t, runtimeDB)
closeDB(t, mainDB) closeDB(t, mainDB)
dao.Mdb = nil dao.Mdb = nil
dao.RuntimeDB = nil dao.RuntimeDB = nil
config.SettingsGetString = nil
viper.Reset() viper.Reset()
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/command" "github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color" "github.com/gookit/color"
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/json" "github.com/GMWalletApp/epusdt/util/json"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+2 -2
View File
@@ -3,8 +3,8 @@ package dao
import ( import (
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color" "github.com/gookit/color"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
+3 -3
View File
@@ -1,9 +1,9 @@
package dao package dao
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
+216 -5
View File
@@ -2,16 +2,38 @@ package data
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"errors"
"fmt"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const defaultAdminUsername = "admin" const defaultAdminUsername = "admin"
const InitialAdminPasswordHashAlgorithm = "sha256"
var (
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
)
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
// so the frontend can detect whether the operator is still using the
// initial password.
type InitialAdminPasswordHashInfo struct {
Algorithm string `json:"algorithm" example:"sha256"`
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
PasswordChanged bool `json:"password_changed" example:"false"`
Available bool `json:"available" example:"true"`
}
// EnsureDefaultAdmin seeds an initial admin account when no admin user // EnsureDefaultAdmin seeds an initial admin account when no admin user
// exists. The password is randomly generated and returned so the caller // exists. The password is randomly generated and returned so the caller
@@ -35,7 +57,15 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
PasswordHash: hash, PasswordHash: hash,
Status: mdb.AdminUserStatusEnable, Status: mdb.AdminUserStatusEnable,
} }
return password, true, dao.Mdb.Create(user).Error if err := dao.Mdb.Create(user).Error; err != nil {
return "", false, err
}
if err := initAdminPasswordState(password); err != nil {
// Account was already created; surface both for visibility while
// preserving created=true and password for emergency fallback.
return password, true, err
}
return password, true, nil
} }
func randomAdminPassword() string { func randomAdminPassword() string {
@@ -44,6 +74,141 @@ func randomAdminPassword() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// HashInitialAdminPassword fingerprints the initial plaintext password so
// the frontend can compare user input locally without exposing plaintext.
func HashInitialAdminPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return fmt.Sprintf("%x", sum[:])
}
func initAdminPasswordState(plain string) error {
hash := HashInitialAdminPassword(plain)
settings := []mdb.Setting{
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: plain, Type: mdb.SettingTypeString,
Description: "One-time readable initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
Value: hash, Type: mdb.SettingTypeString,
Description: "SHA-256 fingerprint for initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
},
}
for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil {
return err
}
}
// Keep in-process cache coherent for the current process.
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock()
return nil
}
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
}).Create(&row).Error
}
// ConsumeInitialAdminPassword returns the one-time initial admin password.
// After a successful read, the plaintext is deleted and cannot be fetched
// again.
func ConsumeInitialAdminPassword() (string, error) {
var password string
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
row := new(mdb.Setting)
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInitAdminPasswordAlreadyFetched
}
return upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
})
})
if err != nil {
return "", err
}
settingsCacheMu.Lock()
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
settingsCacheMu.Unlock()
return password, nil
}
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
// and the changed-state flag used by the admin frontend.
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: "",
PasswordChanged: true,
Available: false,
}, nil
}
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: hash,
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
Available: true,
}, nil
}
// IsUsingInitialAdminPassword reports whether the current admin password is
// still considered the seeded initial password.
func IsUsingInitialAdminPassword() bool {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return false
}
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
}
// HashPassword bcrypts a plaintext password. // HashPassword bcrypts a plaintext password.
func HashPassword(plain string) (string, error) { func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
@@ -80,9 +245,55 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
if err != nil { if err != nil {
return err return err
} }
return dao.Mdb.Model(&mdb.AdminUser{}). changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}).
Where("id = ?", id). Where("id = ?", id).
Update("password_hash", hash).Error Update("password_hash", hash).Error; err != nil {
return err
}
// Old installs may not have initial-password metadata; skip in
// that case to keep password updates backward compatible.
var initHashRow mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
Limit(1).
Find(&initHashRow).Error; err != nil {
return err
}
initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" {
return nil
}
newHash := HashInitialAdminPassword(newPlain)
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
changedValue := "true"
if !changed {
changedValue = "false"
}
if err := upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: changedValue,
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
}); err != nil {
return err
}
changedCacheValue = changedValue
return nil
})
if err != nil {
return err
}
if changedCacheValue != "" {
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
settingsCacheMu.Unlock()
}
return nil
} }
// TouchAdminUserLastLogin stamps last_login_at to now. // TouchAdminUserLastLogin stamps last_login_at to now.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListChains returns all chain rows ordered by id. // ListChains returns all chain rows ordered by id.
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network. // ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListNotificationChannels returns all rows, optionally filtered by type. // ListNotificationChannels returns all rows, optionally filtered by type.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm" "gorm.io/gorm"
) )
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// DailyStat is one bucket of day-level aggregation used by the // DailyStat is one bucket of day-level aggregation used by the
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) { func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
+14 -7
View File
@@ -4,8 +4,8 @@ import (
"math/rand" "math/rand"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
) )
@@ -47,8 +47,8 @@ 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 any enabled row when none are healthy yet (bootstrap case // Falls back to enabled rows with status=unknown when no health check has
// where the health check hasn't run). Returns nil if nothing is defined. // run yet. Explicitly down rows are not selected.
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{}).
@@ -62,15 +62,22 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
if len(rows) == 0 { if len(rows) == 0 {
return nil, nil return nil, nil
} }
healthy := rows[:0] healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if r.Status == mdb.RpcNodeStatusOk { switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r) healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
} }
} }
candidates := healthy candidates := healthy
if len(candidates) == 0 { if len(candidates) == 0 {
candidates = rows // bootstrap: no health check has run yet candidates = bootstrap
}
if len(candidates) == 0 {
return nil, nil
} }
return pickWeighted(candidates), nil return pickWeighted(candidates), nil
} }
+101
View File
@@ -0,0 +1,101 @@
package data
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestSelectRpcNodeUsesHealthyRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://ok.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed ok rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://ok.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want ok row", got)
}
}
func TestSelectRpcNodeFallsBackToUnknownOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://unknown.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want unknown row", got)
}
}
func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
+6 -2
View File
@@ -5,8 +5,8 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -151,6 +151,10 @@ func DeleteSetting(key string) error {
// sensitiveSettingKeys lists keys that must never be returned to API callers. // sensitiveSettingKeys lists keys that must never be returned to API callers.
var sensitiveSettingKeys = []string{ var sensitiveSettingKeys = []string{
mdb.SettingKeyJwtSecret, mdb.SettingKeyJwtSecret,
mdb.SettingKeyInitAdminPasswordPlain,
mdb.SettingKeyInitAdminPasswordHash,
mdb.SettingKeyInitAdminPasswordFetched,
mdb.SettingKeyInitAdminPasswordChanged,
} }
// ListSettingsByGroup returns all rows for a given group (empty group = all), // ListSettingsByGroup returns all rows for a given group (empty group = all),
+3 -3
View File
@@ -3,9 +3,9 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
) )
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加) // AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) { func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
+4
View File
@@ -23,6 +23,10 @@ const (
const ( const (
SettingKeyJwtSecret = "system.jwt_secret" SettingKeyJwtSecret = "system.jwt_secret"
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
SettingKeyOrderExpiration = "system.order_expiration_time" SettingKeyOrderExpiration = "system.order_expiration_time"
SettingKeyBrandSiteName = "brand.site_name" SettingKeyBrandSiteName = "brand.site_name"
SettingKeyBrandLogoUrl = "brand.logo_url" SettingKeyBrandLogoUrl = "brand.logo_url"
+9 -9
View File
@@ -8,15 +8,15 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
+84 -6
View File
@@ -2,16 +2,21 @@ package service
import ( import (
"fmt" "fmt"
"io"
"net/http"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
) )
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest { func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
@@ -25,6 +30,26 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
} }
} }
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -72,6 +97,59 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
} }
} }
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
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
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
t.Fatalf("set rate.api_url: %v", err)
}
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
t.Fatalf("actual amount = %s, want 1.46", got)
}
}
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
t.Fatalf("clear rate.api_url: %v", err)
}
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
if err != constant.RateAmountErr {
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
}
}
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) { func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+4 -4
View File
@@ -3,10 +3,10 @@ package service
import ( import (
"errors" "errors"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
) )
var ErrOrder = errors.New("不存在待支付订单或已过期") var ErrOrder = errors.New("不存在待支付订单或已过期")
+26 -8
View File
@@ -10,13 +10,13 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -281,6 +281,21 @@ type solSignatureResult struct {
BlockTime *int64 `json:"blockTime"` BlockTime *int64 `json:"blockTime"`
} }
func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
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)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试 // SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) { func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New() client := resty.New()
@@ -297,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false return false
}) })
rpcUrl := config.GetSolanaRpcUrl() rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R(). resp, err := client.R().
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
+63 -11
View File
@@ -2,13 +2,59 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "os"
"testing" "testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
func setupSolanaRPCNode(t *testing.T, url string) func() {
t.Helper()
cleanup := testutil.SetupTestDatabases(t)
node := &mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: url,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
cleanup()
t.Fatalf("seed solana rpc_node: %v", err)
}
return cleanup
}
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if got, err := resolveSolanaRpcURL(); err == nil {
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
}
}
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
defer cleanup()
got, err := resolveSolanaRpcURL()
if err != nil {
t.Fatalf("resolveSolanaRpcURL(): %v", err)
}
if got != "https://solana.example.com" {
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
}
}
func TestSolClientHealthy(t *testing.T) { func TestSolClientHealthy(t *testing.T) {
requireSolanaIntegration(t)
bodyData, err := SolRetryClient("getHealth", nil) bodyData, err := SolRetryClient("getHealth", nil)
if err != nil { if err != nil {
t.Fatalf("SolRetryClient failed: %v", err) t.Fatalf("SolRetryClient failed: %v", err)
@@ -33,6 +79,8 @@ func TestSolClientHealthy(t *testing.T) {
} }
func TestSolClientGetSignaturesForAddress(t *testing.T) { func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t)
// Example wallet address (replace with actual test address) // Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
@@ -57,6 +105,8 @@ func TestSolClientGetSignaturesForAddress(t *testing.T) {
} }
func TestSolClientGetTransaction(t *testing.T) { func TestSolClientGetTransaction(t *testing.T) {
requireSolanaIntegration(t)
// Example transaction signature (replace with actual test signature) // Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -64,7 +114,6 @@ func TestSolClientGetTransaction(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SolRetryClient failed: %v", err) t.Fatalf("SolRetryClient failed: %v", err)
} }
fmt.Printf("%v\n", string(txData))
var result map[string]interface{} var result map[string]interface{}
err = json.Unmarshal(txData, &result) err = json.Unmarshal(txData, &result)
@@ -80,6 +129,15 @@ func TestSolClientGetTransaction(t *testing.T) {
t.Logf("Transaction Info for signature %s: %v", sig, txInfo) t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
} }
func requireSolanaIntegration(t *testing.T) {
t.Helper()
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
}
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
t.Cleanup(cleanup)
}
func TestFindATAAddress(t *testing.T) { func TestFindATAAddress(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -219,9 +277,7 @@ func TestAdjustAmount(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) { func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances) // SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN" sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
@@ -261,9 +317,7 @@ func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) { func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// SPL Token "transferChecked" (has mint and tokenAmount in instruction) // SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -306,9 +360,7 @@ func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) { func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// System program SOL transfer // System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ" sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
+31 -22
View File
@@ -9,16 +9,15 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/assimon/luuu/config" tron "github.com/GMWalletApp/epusdt/crypto"
tron "github.com/assimon/luuu/crypto" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/notify"
"github.com/assimon/luuu/notify" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/assimon/luuu/util/http_client" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/math"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil" "github.com/gookit/goutil/stdutil"
@@ -26,19 +25,21 @@ import (
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
const tronNodeDefault = "https://api.trongrid.io"
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node. // resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
// It reads the first healthy (or any enabled) row from the rpc_nodes table; // It reads the first healthy (or any enabled) row from the rpc_nodes table.
// if none exists it falls back to the hard-coded default URL and the func resolveTronNode() (string, string, error) {
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
func resolveTronNode() (string, string) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp) node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err == nil && node != nil && node.ID > 0 { if err != nil {
return strings.TrimRight(node.Url, "/"), node.ApiKey return "", "", err
} }
return tronNodeDefault, config.TRON_GRID_API_KEY if node == nil || node.ID == 0 {
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
if rpcURL == "" {
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, node.ApiKey, nil
} }
func Trc20CallBack(address string, wg *sync.WaitGroup) { func Trc20CallBack(address string, wg *sync.WaitGroup) {
@@ -79,7 +80,11 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli() endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode() tronBaseURL, tronAPIKey, err := resolveTronNode()
if err != nil {
log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
return
}
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address) url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{ resp, err := client.R().SetQueryParams(map[string]string{
@@ -217,7 +222,11 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli() endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode() tronBaseURL, tronAPIKey, err := resolveTronNode()
if err != nil {
log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
return
}
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address) url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{ resp, err := client.R().SetQueryParams(map[string]string{
+14 -27
View File
@@ -3,26 +3,19 @@ package service
import ( import (
"testing" "testing"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/mdb"
) )
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the // TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
// hard-coded default when the rpc_nodes table has no TRON row. // TRON row from rpc_nodes.
func TestResolveTronNode_NoRow(t *testing.T) { func TestResolveTronNode_NoRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key" if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
} }
} }
@@ -32,8 +25,6 @@ func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
// Insert an enabled TRON http node. // Insert an enabled TRON http node.
node := &mdb.RpcNode{ node := &mdb.RpcNode{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
@@ -48,7 +39,10 @@ func TestResolveTronNode_WithRow(t *testing.T) {
t.Fatalf("seed rpc_node: %v", err) t.Fatalf("seed rpc_node: %v", err)
} }
gotURL, gotKey := resolveTronNode() gotURL, gotKey, err := resolveTronNode()
if err != nil {
t.Fatalf("resolveTronNode(): %v", err)
}
if gotURL != "https://custom-tron.example.com" { if gotURL != "https://custom-tron.example.com" {
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL) t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
} }
@@ -57,14 +51,11 @@ func TestResolveTronNode_WithRow(t *testing.T) {
} }
} }
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored and // TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
// the fallback is used.
func TestResolveTronNode_DisabledRow(t *testing.T) { func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
node := &mdb.RpcNode{ node := &mdb.RpcNode{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com", Url: "https://disabled-tron.example.com",
@@ -82,11 +73,7 @@ func TestResolveTronNode_DisabledRow(t *testing.T) {
t.Fatalf("disable rpc_node: %v", err) t.Fatalf("disable rpc_node: %v", err)
} }
gotURL, gotKey := resolveTronNode() if gotURL, gotKey, err := resolveTronNode(); err == nil {
if gotURL != tronNodeDefault { t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package mq
import ( import (
"sync" "sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
) )
var ( var (
+8 -8
View File
@@ -8,14 +8,14 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
) )
// resolveOrderApiKey returns the api_keys row that signed the order. // resolveOrderApiKey returns the api_keys row that signed the order.
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
) )
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) { func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"encoding/json" "encoding/json"
"sync" "sync"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
) )
// Sender delivers an already-rendered text message via a channel row. // Sender delivers an already-rendered text message via a channel row.
+89 -3
View File
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -118,6 +118,14 @@ func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token st
return rec return rec
} }
// doGet sends an unauthenticated GET request.
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// assertOK asserts the response status is 200 and the status_code field is 200. // assertOK asserts the response status is 200 and the status_code field is 200.
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper() t.Helper()
@@ -273,6 +281,84 @@ func TestAdminChangePassword(t *testing.T) {
} }
} }
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
// and hash-based "password changed" detection flow.
func TestAdminInitPasswordFlow(t *testing.T) {
e := setupTestEnv(t)
const initPassword = "init-pass-123456"
adminHash, err := data.HashPassword(initPassword)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: adminHash,
Status: mdb.AdminUserStatusEnable,
})
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash := assertOK(t, recHash)
hashData, _ := respHash["data"].(map[string]interface{})
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
}
if got, _ := hashData["password_changed"].(bool); got {
t.Fatalf("expected password_changed=false before change, got true")
}
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{})
if fetchData["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
}
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest {
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") {
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
}
token := adminLogin(t, e, testAdminUsername, initPassword)
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{})
if got, _ := meData1["password_is_default"].(bool); !got {
t.Fatalf("expected password_is_default=true before change, got %v", got)
}
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
"old_password": initPassword,
"new_password": "new-pass-789",
}, token)
assertOK(t, recChange)
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash2 := assertOK(t, recHash2)
hashData2, _ := respHash2["data"].(map[string]interface{})
if got, _ := hashData2["password_changed"].(bool); !got {
t.Fatalf("expected password_changed=true after change, got %v", got)
}
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe2 := assertOK(t, recMe2)
meData2, _ := respMe2["data"].(map[string]interface{})
if got, _ := meData2["password_is_default"].(bool); got {
t.Fatalf("expected password_is_default=false after change, got %v", got)
}
}
// ─── API Keys ──────────────────────────────────────────────────────────────── // ─── API Keys ────────────────────────────────────────────────────────────────
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret, // TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
+9 -7
View File
@@ -9,13 +9,13 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/assimon/luuu/controller/admin" "github.com/GMWalletApp/epusdt/controller/admin"
"github.com/assimon/luuu/controller/comm" "github.com/GMWalletApp/epusdt/controller/comm"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
) )
@@ -188,6 +188,8 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT) // Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login) adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated // Authenticated
authed := adminV1.Group("", middleware.CheckAdminJWT()) authed := adminV1.Group("", middleware.CheckAdminJWT())
+15 -6
View File
@@ -10,11 +10,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -33,7 +34,6 @@ func setupTestEnv(t *testing.T) *echo.Echo {
viper.Set("app_uri", "http://localhost:8080") viper.Set("app_uri", "http://localhost:8080")
viper.Set("order_expiration_time", 10) viper.Set("order_expiration_time", 10)
viper.Set("api_rate_url", "") viper.Set("api_rate_url", "")
viper.Set("forced_usdt_rate", 7.0)
viper.Set("runtime_root_path", tmpDir) viper.Set("runtime_root_path", tmpDir)
viper.Set("log_save_path", tmpDir) viper.Set("log_save_path", tmpDir)
viper.Set("sqlite_database_filename", tmpDir+"/test.db") viper.Set("sqlite_database_filename", tmpDir+"/test.db")
@@ -68,6 +68,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()
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
t.Cleanup(func() {
config.SettingsGetString = nil
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
// seed wallet addresses // seed wallet addresses
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable}) dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
+3 -1
View File
@@ -1,7 +1,7 @@
package task package task
import ( import (
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
@@ -21,6 +21,7 @@ func Start() {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err) log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return return
} }
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)") log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
// Solana polling // Solana polling
_, err = c.AddJob("@every 5s", ListenSolJob{}) _, err = c.AddJob("@every 5s", ListenSolJob{})
@@ -28,6 +29,7 @@ func Start() {
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err) log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
return return
} }
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)") log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
// RPC node health checks // RPC node health checks
_, err = c.AddJob("@every 30s", RpcHealthJob{}) _, err = c.AddJob("@every 30s", RpcHealthJob{})
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
const bscDefaultWsURL = "wss://bsc.drpc.org"
type bscRecipientSnapshot struct { type bscRecipientSnapshot struct {
addrs map[string]struct{} addrs map[string]struct{}
} }
@@ -68,7 +66,10 @@ func runBscListener(contracts []common.Address) {
} }
}() }()
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL) wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
if !ok {
return
}
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
+18 -8
View File
@@ -6,9 +6,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
) )
@@ -90,12 +90,22 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
} }
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the // resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
// given network, falling back to the provided default (usually the // given network. If no enabled node is configured, the caller skips the
// public node URL) when no row is configured. // current listener run so admin-side disabled/deleted rows are respected.
func resolveChainWsURL(network, fallback string) string { func resolveChainWsURL(network, logPrefix string) (string, bool) {
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs) node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 { if err == nil && node != nil && node.ID > 0 {
return node.Url rpcURL := strings.TrimSpace(node.Url)
if rpcURL != "" {
return rpcURL, true
} }
return fallback log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
return "", false
}
if err != nil {
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
} else {
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
}
return "", false
} }
+67
View File
@@ -0,0 +1,67 @@
package task
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
}
}
func TestResolveChainWsURLWithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
node := &mdb.RpcNode{
Network: mdb.NetworkEthereum,
Url: " wss://ethereum.example.com ",
Type: mdb.RpcNodeTypeWs,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
if !ok {
t.Fatalf("resolveChainWsURL() ok=false, want true")
}
if got != "wss://ethereum.example.com" {
t.Fatalf("resolveChainWsURL() = %q, want wss://ethereum.example.com", got)
}
}
func TestResolveChainWsURLDisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
node := &mdb.RpcNode{
Network: mdb.NetworkEthereum,
Url: "wss://disabled.example.com",
Type: mdb.RpcNodeTypeWs,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
t.Fatalf("disable rpc_node: %v", err)
}
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
}
}
+8 -23
View File
@@ -7,11 +7,10 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@@ -22,22 +21,6 @@ import (
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain. // Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
// legacy .env value (if set) before the shared default — keeps existing
// deployments working until the admin adds an rpc_nodes row.
func resolveEthereumWsURL() string {
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 {
return node.Url
}
if u := config.GetEthereumWsUrl(); u != "" {
return u
}
return ethereumDefaultWsURL
}
type ethRecipientSnapshot struct { type ethRecipientSnapshot struct {
addrs map[string]struct{} addrs map[string]struct{}
} }
@@ -88,7 +71,10 @@ func runEthereumListener(contracts []common.Address) {
} }
}() }()
wsURL := resolveEthereumWsURL() wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
if !ok {
return
}
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
@@ -145,4 +131,3 @@ func isWatchedEthRecipient(to common.Address) bool {
_, ok := snap.addrs[strings.ToLower(to.Hex())] _, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok return ok
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
type plasmaRecipientSnapshot struct { type plasmaRecipientSnapshot struct {
addrs map[string]struct{} addrs map[string]struct{}
} }
@@ -67,7 +65,10 @@ func runPlasmaListener(contracts []common.Address) {
} }
}() }()
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL) wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
if !ok {
return
}
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
) )
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
type polygonRecipientSnapshot struct { type polygonRecipientSnapshot struct {
addrs map[string]struct{} addrs map[string]struct{}
} }
@@ -67,7 +65,10 @@ func runPolygonListener(contracts []common.Address) {
} }
}() }()
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL) wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
if !ok {
return
}
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts)) log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{ query := ethereum.FilterQuery{
+4 -4
View File
@@ -3,10 +3,10 @@ package task
import ( import (
"sync" "sync"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
) )
type ListenSolJob struct{} type ListenSolJob struct{}
+4 -4
View File
@@ -3,10 +3,10 @@ package task
import ( import (
"sync" "sync"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
) )
type ListenTrc20Job struct{} type ListenTrc20Job struct{}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
) )
const rpcProbeTimeout = 5 * time.Second const rpcProbeTimeout = 5 * time.Second
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// --------------- ParseAddress --------------- // --------------- ParseAddress ---------------
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/goutil/mathutil" "github.com/gookit/goutil/mathutil"
"github.com/gookit/goutil/strutil" "github.com/gookit/goutil/strutil"
tb "gopkg.in/telebot.v3" tb "gopkg.in/telebot.v3"
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
tb "gopkg.in/telebot.v3" tb "gopkg.in/telebot.v3"
"gopkg.in/telebot.v3/middleware" "gopkg.in/telebot.v3/middleware"
) )
+5 -5
View File
@@ -3,11 +3,11 @@ package telegram
import ( import (
"testing" "testing"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
func withTelegramEnv(token, proxy string, manage int64, fn func()) { func withTelegramEnv(token, proxy string, manage int64, fn func()) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/btcsuite/btcutil/base58" "github.com/btcsuite/btcutil/base58"
"github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go"
) )
+2 -2
View File
@@ -1,8 +1,8 @@
package http package http
import ( import (
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/page" "github.com/GMWalletApp/epusdt/util/page"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"net/http" "net/http"
) )
+19
View File
@@ -6,6 +6,25 @@ import (
"strings" "strings"
) )
var spaFallbackDenyPrefixes = []string{
"/api",
"/admin/api",
"/payments",
"/pay",
}
// ShouldSkipSPAFallback reports whether a request path should bypass
// server-side SPA fallback to index.html and continue normal backend routing.
func ShouldSkipSPAFallback(requestPath string) bool {
for _, prefix := range spaFallbackDenyPrefixes {
if requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
return true
}
}
return false
}
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot. // ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
// It strips any leading slash and blocks path traversal outside wwwRoot. // It strips any leading slash and blocks path traversal outside wwwRoot.
// The second return value indicates whether the caller should try os.Stat // The second return value indicates whether the caller should try os.Stat
+29
View File
@@ -66,3 +66,32 @@ func TestResolveSPAFilePath(t *testing.T) {
}) })
} }
} }
func TestShouldSkipSPAFallback(t *testing.T) {
tests := []struct {
name string
path string
want bool
}{
{name: "api root", path: "/api", want: true},
{name: "api sub path", path: "/api/install/defaults", want: true},
{name: "admin api root", path: "/admin/api", want: true},
{name: "admin api sub path", path: "/admin/api/v1/orders", want: true},
{name: "payments root", path: "/payments", want: true},
{name: "payments sub path", path: "/payments/gmpay/v1/order/create-transaction", want: true},
{name: "pay root", path: "/pay", want: true},
{name: "pay sub path", path: "/pay/checkout-counter/abc", want: true},
{name: "not exact prefix", path: "/apiary", want: false},
{name: "normal spa route", path: "/install", want: false},
{name: "normal asset route", path: "/assets/index.js", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldSkipSPAFallback(tt.path)
if got != tt.want {
t.Fatalf("ShouldSkipSPAFallback(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
+4 -1
View File
@@ -6,9 +6,12 @@ import (
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
) )
// ClientFactory is overridden in tests to stub outbound HTTP calls.
var ClientFactory = resty.New
// GetHttpClient 获取请求客户端 // GetHttpClient 获取请求客户端
func GetHttpClient(proxys ...string) *resty.Client { func GetHttpClient(proxys ...string) *resty.Client {
client := resty.New() client := ClientFactory()
// 如果有代理 // 如果有代理
if len(proxys) > 0 { if len(proxys) > 0 {
proxy := proxys[0] proxy := proxys[0]
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
) )
+1 -1
View File
@@ -2,7 +2,7 @@ package log
import ( import (
"fmt" "fmt"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/natefinch/lumberjack" "github.com/natefinch/lumberjack"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
+1 -1
View File
@@ -2,7 +2,7 @@ package sign
import ( import (
"errors" "errors"
"github.com/assimon/luuu/util/json" "github.com/GMWalletApp/epusdt/util/json"
"github.com/gookit/goutil/strutil" "github.com/gookit/goutil/strutil"
"reflect" "reflect"
"sort" "sort"
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./unauthorized-error-AAbXZDVX.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-CxmfHb-a.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./forbidden-Waph2URh.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./forbidden-BEz-zjy_.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./not-found-error-tF1-JLKB.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./not-found-error-toRsO1Fi.js";var t=e;export{t as component};

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