11 Commits

Author SHA1 Message Date
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
259 changed files with 1263 additions and 592 deletions
+25 -15
View File
@@ -5,6 +5,9 @@ on:
push:
branches:
- "main"
- "master"
tags:
- "v*"
env:
IMAGE_NAME: epusdt
@@ -12,21 +15,29 @@ env:
jobs:
push-docker-hub:
runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps:
- uses: actions/checkout@v6
- name: Compute Build Metadata
id: meta
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: Set Up QEMU
uses: docker/setup-qemu-action@v3
- 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: ${{ secrets.DOCKERHUB_USERNAME }}/${{ 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,suffix=-alpine
type=sha,prefix=sha-,suffix=-alpine
- name: Login To Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -38,9 +49,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ steps.meta.outputs.commit }}
DATE=${{ steps.meta.outputs.date }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
API_RATE_URL=${{ vars.API_RATE_URL }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
required: true
type: string
permissions:
contents: write
@@ -25,6 +31,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
- name: Ensure Tag Commit Is On Default Branch
env:
+6 -1
View File
@@ -5,7 +5,7 @@ ENV CGO_ENABLED=0
WORKDIR /app
RUN git clone https://github.com/GMwalletApp/epusdt.git .
COPY . /app
WORKDIR /app/src
RUN go mod download
@@ -14,10 +14,15 @@ RUN go build -o /app/epusdt .
FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata
ARG API_RATE_URL=""
WORKDIR /app
COPY --from=builder /app/src/static /app/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 .
VOLUME /app/conf
+4 -4
View File
@@ -15,10 +15,10 @@
</p>
<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://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>
---
@@ -42,7 +42,7 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
| **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名活跃用户交流使用经验,对接商业资源:
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
+2 -2
View File
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
use Illuminate\Support\Facades\Route;
+11 -11
View File
@@ -3,14 +3,14 @@ package bootstrap
import (
"sync"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/task"
"github.com/assimon/luuu/telegram"
appjwt "github.com/assimon/luuu/util/jwt"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/mq"
"github.com/GMWalletApp/epusdt/task"
"github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
)
@@ -41,15 +41,15 @@ func InitApp() {
config.RateApiUrl = config.GetRateApiUrl()
// Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent.
adminPwd, isNew, err := data.EnsureDefaultAdmin()
_, isNew, err := data.EnsureDefaultAdmin()
if err != nil {
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
}
if isNew {
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("║ Password: %-60s ║\n", adminPwd)
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
}
if _, err := appjwt.EnsureSecret(); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/gookit/color"
)
+18 -8
View File
@@ -6,16 +6,17 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/install"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route"
"github.com/assimon/luuu/util/constant"
luluHttp "github.com/assimon/luuu/util/http"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/bootstrap"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/install"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/route"
"github.com/GMWalletApp/epusdt/util/constant"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra"
@@ -72,6 +73,15 @@ func HttpServerStart() {
}
}
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,
Index: "index.html",
Root: wwwRoot,
+1 -1
View File
@@ -1,7 +1,7 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package command
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra"
)
+1 -15
View File
@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/assimon/luuu/util/http_client"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
)
@@ -36,7 +36,6 @@ var (
TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
@@ -79,7 +78,6 @@ func Init() {
TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
}
func mustMkdir(path string) {
@@ -398,15 +396,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
}
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"))
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+48 -6
View File
@@ -3,9 +3,9 @@ package admin
import (
"errors"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
appjwt "github.com/assimon/luuu/util/jwt"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
@@ -34,6 +34,12 @@ type MeResponse struct {
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.
// @Summary Admin login
// @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
// discards them. Kept for API symmetry with the documented spec.
// @Summary Admin logout
@@ -111,9 +153,9 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found"))
}
// Warn the frontend when the operator hasn't changed the default
// password so the UI can show a prominent reminder.
isDefault := data.VerifyPassword(user.PasswordHash, "admin")
// Warn the frontend when the operator hasn't changed the seeded
// initial password.
isDefault := data.IsUsingInitialAdminPassword()
return c.SucJson(ctx, MeResponse{
AdminUser: *user,
PasswordIsDefault: isDefault,
+2 -2
View File
@@ -1,8 +1,8 @@
package admin
import (
"github.com/assimon/luuu/controller"
"github.com/assimon/luuu/middleware"
"github.com/GMWalletApp/epusdt/controller"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package admin
import (
"github.com/assimon/luuu/model/data"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
@@ -4,8 +4,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
@@ -6,10 +6,10 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/notify"
"github.com/assimon/luuu/telegram"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/task"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4"
)
+4 -4
View File
@@ -3,10 +3,10 @@ package admin
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/telegram"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package controller
import (
"errors"
"github.com/assimon/luuu/util/http"
"github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/validate"
"github.com/gookit/validate/locales/zhcn"
"github.com/gookit/validate/locales/zhtw"
+1 -1
View File
@@ -1,6 +1,6 @@
package comm
import "github.com/assimon/luuu/controller"
import "github.com/GMWalletApp/epusdt/controller"
var Ctrl = &BaseCommController{}
+6 -6
View File
@@ -4,12 +4,12 @@ import (
"encoding/json"
"net/http"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"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
+4
View File
@@ -28,6 +28,7 @@ import (
"text/template"
"time"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/color"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
@@ -195,6 +196,9 @@ func RunInstallServer(listenAddr, envFilePath string) {
}
}
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: func(c echo.Context) bool {
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
},
HTML5: true,
Index: "index.html",
Root: wwwRoot,
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"path/filepath"
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
appLog "github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/libtnb/sqlite"
"github.com/spf13/viper"
"go.uber.org/zap"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os"
"path/filepath"
"github.com/assimon/luuu/command"
"github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
appjwt "github.com/assimon/luuu/util/jwt"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/json"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package dao
import (
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"sync"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color"
"gorm.io/gorm/clause"
)
+3 -3
View File
@@ -1,9 +1,9 @@
package dao
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"gorm.io/gorm"
"gorm.io/gorm/logger"
+216 -5
View File
@@ -2,16 +2,38 @@ package data
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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
// 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,
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 {
@@ -44,6 +74,141 @@ func randomAdminPassword() string {
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.
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
@@ -80,9 +245,55 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
if err != nil {
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).
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.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"gorm.io/gorm"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListChains returns all chain rows ordered by id.
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListNotificationChannels returns all rows, optionally filtered by type.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"gorm.io/gorm"
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import (
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// DailyStat is one bucket of day-level aggregation used by the
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
+14 -7
View File
@@ -4,8 +4,8 @@ import (
"math/rand"
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"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.
// Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to any enabled row when none are healthy yet (bootstrap case
// where the health check hasn't run). Returns nil if nothing is defined.
// Falls back to enabled rows with status=unknown when no health check has
// run yet. Explicitly down rows are not selected.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
@@ -62,15 +62,22 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
if len(rows) == 0 {
return nil, nil
}
healthy := rows[:0]
healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows {
if r.Status == mdb.RpcNodeStatusOk {
switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
}
}
candidates := healthy
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
}
+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"
"sync"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause"
)
@@ -151,6 +151,10 @@ func DeleteSetting(key string) error {
// sensitiveSettingKeys lists keys that must never be returned to API callers.
var sensitiveSettingKeys = []string{
mdb.SettingKeyJwtSecret,
mdb.SettingKeyInitAdminPasswordPlain,
mdb.SettingKeyInitAdminPasswordHash,
mdb.SettingKeyInitAdminPasswordFetched,
mdb.SettingKeyInitAdminPasswordChanged,
}
// ListSettingsByGroup returns all rows for a given group (empty group = all),
+3 -3
View File
@@ -3,9 +3,9 @@ package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
)
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
+4
View File
@@ -23,6 +23,10 @@ const (
const (
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"
SettingKeyBrandSiteName = "brand.site_name"
SettingKeyBrandLogoUrl = "brand.logo_url"
+9 -9
View File
@@ -8,15 +8,15 @@ import (
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
)
+6 -6
View File
@@ -6,12 +6,12 @@ import (
"sync"
"testing"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
)
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
+4 -4
View File
@@ -3,10 +3,10 @@ package service
import (
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
)
var ErrOrder = errors.New("不存在待支付订单或已过期")
+26 -8
View File
@@ -10,13 +10,13 @@ import (
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2"
"github.com/shopspring/decimal"
@@ -281,6 +281,21 @@ type solSignatureResult struct {
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 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New()
@@ -297,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false
})
rpcUrl := config.GetSolanaRpcUrl()
rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R().
SetHeader("Content-Type", "application/json").
+63 -11
View File
@@ -2,13 +2,59 @@ package service
import (
"encoding/json"
"fmt"
"os"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"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) {
requireSolanaIntegration(t)
bodyData, err := SolRetryClient("getHealth", nil)
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
@@ -33,6 +79,8 @@ func TestSolClientHealthy(t *testing.T) {
}
func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t)
// Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
@@ -57,6 +105,8 @@ func TestSolClientGetSignaturesForAddress(t *testing.T) {
}
func TestSolClientGetTransaction(t *testing.T) {
requireSolanaIntegration(t)
// Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -64,7 +114,6 @@ func TestSolClientGetTransaction(t *testing.T) {
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
fmt.Printf("%v\n", string(txData))
var result map[string]interface{}
err = json.Unmarshal(txData, &result)
@@ -80,6 +129,15 @@ func TestSolClientGetTransaction(t *testing.T) {
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) {
tests := []struct {
name string
@@ -219,9 +277,7 @@ func TestAdjustAmount(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
@@ -261,9 +317,7 @@ func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -306,9 +360,7 @@ func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
}
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
requireSolanaIntegration(t)
// System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
+31 -22
View File
@@ -9,16 +9,15 @@ import (
"strings"
"sync"
"github.com/assimon/luuu/config"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/notify"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
@@ -26,19 +25,21 @@ import (
"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.
// 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
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
func resolveTronNode() (string, string) {
// It reads the first healthy (or any enabled) row from the rpc_nodes table.
func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err == nil && node != nil && node.ID > 0 {
return strings.TrimRight(node.Url, "/"), node.ApiKey
if err != nil {
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) {
@@ -79,7 +80,11 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).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)
resp, err := client.R().SetQueryParams(map[string]string{
@@ -217,7 +222,11 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).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)
resp, err := client.R().SetQueryParams(map[string]string{
+14 -27
View File
@@ -3,26 +3,19 @@ package service
import (
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
// hard-coded default when the rpc_nodes table has no TRON row.
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
// TRON row from rpc_nodes.
func TestResolveTronNode_NoRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
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)
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
@@ -32,8 +25,6 @@ func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
// Insert an enabled TRON http node.
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
@@ -48,7 +39,10 @@ func TestResolveTronNode_WithRow(t *testing.T) {
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" {
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
// the fallback is used.
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com",
@@ -82,11 +73,7 @@ func TestResolveTronNode_DisabledRow(t *testing.T) {
t.Fatalf("disable rpc_node: %v", err)
}
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
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)
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package mq
import (
"sync"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
)
var (
+8 -8
View File
@@ -8,14 +8,14 @@ import (
"strings"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
)
// resolveOrderApiKey returns the api_keys row that signed the order.
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/sign"
)
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"encoding/json"
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
)
// Sender delivers an already-rendered text message via a channel row.
+89 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
@@ -118,6 +118,14 @@ func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token st
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.
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
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 ────────────────────────────────────────────────────────────────
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
+9 -7
View File
@@ -9,13 +9,13 @@ import (
"net/http"
"strconv"
"github.com/assimon/luuu/controller/admin"
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/controller/admin"
"github.com/GMWalletApp/epusdt/controller/comm"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
)
@@ -188,6 +188,8 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT)
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
authed := adminV1.Group("", middleware.CheckAdminJWT())
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"strings"
"testing"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
"github.com/spf13/viper"
"gorm.io/gorm/clause"
+3 -1
View File
@@ -1,7 +1,7 @@
package task
import (
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/robfig/cron/v3"
)
@@ -21,6 +21,7 @@ func Start() {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
// Solana polling
_, err = c.AddJob("@every 5s", ListenSolJob{})
@@ -28,6 +29,7 @@ func Start() {
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
return
}
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
// RPC node health checks
_, err = c.AddJob("@every 30s", RpcHealthJob{})
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const bscDefaultWsURL = "wss://bsc.drpc.org"
type bscRecipientSnapshot 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))
query := ethereum.FilterQuery{
+18 -8
View File
@@ -6,9 +6,9 @@ import (
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"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
// given network, falling back to the provided default (usually the
// public node URL) when no row is configured.
func resolveChainWsURL(network, fallback string) string {
// given network. If no enabled node is configured, the caller skips the
// current listener run so admin-side disabled/deleted rows are respected.
func resolveChainWsURL(network, logPrefix string) (string, bool) {
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
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"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -22,22 +21,6 @@ import (
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
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 {
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))
query := ethereum.FilterQuery{
@@ -145,4 +131,3 @@ func isWatchedEthRecipient(to common.Address) bool {
_, ok := snap.addrs[strings.ToLower(to.Hex())]
return ok
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types"
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
type plasmaRecipientSnapshot 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))
query := ethereum.FilterQuery{
+8 -7
View File
@@ -7,10 +7,10 @@ import (
"sync/atomic"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@@ -18,8 +18,6 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
type polygonRecipientSnapshot 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))
query := ethereum.FilterQuery{
+4 -4
View File
@@ -3,10 +3,10 @@ package task
import (
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
)
type ListenSolJob struct{}
+4 -4
View File
@@ -3,10 +3,10 @@ package task
import (
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
)
type ListenTrc20Job struct{}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
)
const rpcProbeTimeout = 5 * time.Second
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// --------------- ParseAddress ---------------
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/goutil/mathutil"
"github.com/gookit/goutil/strutil"
tb "gopkg.in/telebot.v3"
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/util/log"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/log"
tb "gopkg.in/telebot.v3"
"gopkg.in/telebot.v3/middleware"
)
+5 -5
View File
@@ -3,11 +3,11 @@ package telegram
import (
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"strings"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/btcsuite/btcutil/base58"
"github.com/gagliardetto/solana-go"
)
+2 -2
View File
@@ -1,8 +1,8 @@
package http
import (
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/page"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/page"
"github.com/labstack/echo/v4"
"net/http"
)
+19
View File
@@ -6,6 +6,25 @@ import (
"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.
// It strips any leading slash and blocks path traversal outside wwwRoot.
// 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)
}
})
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"errors"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/golang-jwt/jwt/v4"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package log
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/GMWalletApp/epusdt/config"
"github.com/natefinch/lumberjack"
"github.com/spf13/viper"
"go.uber.org/zap"
+1 -1
View File
@@ -2,7 +2,7 @@ package sign
import (
"errors"
"github.com/assimon/luuu/util/json"
"github.com/GMWalletApp/epusdt/util/json"
"github.com/gookit/goutil/strutil"
"reflect"
"sort"
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./unauthorized-error-BlVgwebo.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
@@ -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"./forbidden-C7g3z0HN.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./not-found-error-BKAx_mEr.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};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./general-error-CAIDyDhH.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./general-error-DKtbS-qh.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./maintenance-error-PO6kK4Oj.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./maintenance-error-vtpJpThI.js";var t=e;export{t as component};

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