38 Commits

Author SHA1 Message Date
line-6000 ff68279b0f Merge pull request #59 from GMWalletApp/dev
feat(pay): switch checkout counter to SPA redirect + JSON API
2026-04-29 00:36:12 +08:00
line-6000 9c533a7ff8 feat(pay): switch checkout counter to SPA redirect + JSON API
- redirect `/pay/checkout-counter/:trade_id` to `/cashier/:trade_id`
- add `/pay/checkout-counter-resp/:trade_id` for checkout JSON payload
- remove embedded `static` release and keep `www` as the web bundle source
- regenerate PWA/www assets and move chain icons to `/www/images/chains`
2026-04-29 00:34:58 +08:00
line-6000 785a162f88 Merge pull request #58 from GMWalletApp/hot_fix_0427
Hot fix 0427
2026-04-28 19:14:58 +08:00
Ginta 06933451c5 actions 2026-04-28 16:28:01 +08:00
line-6000 d17d212c24 change tron scan logic 2026-04-28 15:28:00 +08:00
Ginta 51a2accd9f 🐛 fix link 2026-04-27 15:57:26 +08:00
Ginta 96ea7481bc Revert "Revert "new trc (#54)""
This reverts commit 74c3a3a08c.
2026-04-27 15:52:50 +08:00
Ginta de9c45a944 static 2026-04-27 15:52:12 +08:00
Ginta 9e885d8b2f order notify 2026-04-27 15:43:35 +08:00
Ginta 74c3a3a08c Revert "new trc (#54)"
This reverts commit 0a19854602.
2026-04-27 00:04:05 +08:00
Ginta 0a19854602 new trc (#54)
* new trct
2026-04-26 23:32:43 +08:00
line-6000 67263da0f1 Merge pull request #51 from GMWalletApp/dev 2026-04-25 18:49:41 +08:00
line-6000 95879e33d4 refactor(routes):
- keep install-mode root redirect to /install and add tests covering install root redirect and install page serving
- change root backend handler from Any("/") to POST("/") so GET / can consistently serve the SPA after install

Co-authored-by: Copilot <copilot@github.com>
2026-04-25 18:48:28 +08:00
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
line-6000 916394337b Merge pull request #37 from GMWalletApp/dev-runtime
Dev runtime
2026-04-22 04:20:43 +08:00
line-6000 6ea5637f6b update www static files 2026-04-22 03:39:11 +08:00
line-6000 b499bc01cf Change install flag to true in .env.example 2026-04-22 03:12:34 +08:00
line-6000 5edc9dc842 feat: sync Telegram notification channels with settings updates 2026-04-22 03:04:15 +08:00
line-6000 6bb47d4b00 feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets,
  orders, RPC nodes, settings, dashboard stats, notifications
- Add JWT-based admin authentication and API key auth middleware
- Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket
- Add RPC node health check job with automatic failover
- Add Telegram notification channel for order events
- Add installer for first-run database initialization
- Add new DB models: admin_user, api_key, chain, chain_token,
  rpc_node, settings, notification_channel
- Add order statistics aggregation (daily/monthly/by-chain)
- Serve built admin SPA from embedded www/ assets
- Add comprehensive test coverage for all new features
2026-04-22 02:28:31 +08:00
Ginta efc8f29af6 issue template 2026-04-21 13:42:07 +08:00
Astra3456 9eaf27298c Add Solana support to token list in README 2026-04-17 13:00:52 +08:00
line-6000 097c716714 Merge pull request #33 from GMwalletApp/dev-payment
- Enable polygon,plasma supports
2026-04-15 18:43:46 +08:00
line-6000 f7c5f678c4 - Enable polygon,plasma supports
- Fix network select
- Fix store evm address logic
2026-04-15 18:36:50 +08:00
317 changed files with 11964 additions and 3224 deletions
+111
View File
@@ -0,0 +1,111 @@
name: Bug 反馈
description: 有一个 bug 需要解决
title: "发现bug"
labels:
- bug
body:
- type: markdown
attributes:
value: |
# 提 ISSUE 须知
请先阅读文档:
- [epusdt 文档](https://epusdt.com/)
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
- type: checkboxes
id: checks
attributes:
label: 提交前检查
options:
- label: 我已阅读文档并检查常见问题
required: true
- label: 我已搜索现有 issue,确认不是重复问题
required: true
- label: 我已尽量提供可复现信息(代码、配置、日志)
required: true
- type: textarea
id: epusdt_version
attributes:
label: 1. epusdt 版本
description: 请提供版本号或 commit hash,避免只写“最新版”
placeholder: |
例如:
- v1.2.3
- commit: abcdef1
validations:
required: true
- type: textarea
id: runtime
attributes:
label: 2. 运行环境版本
description: 请提供语言/运行时及相关依赖版本
placeholder: |
例如:
- Go/Python/Node 版本:
- 数据库及版本:
- Docker 版本(如有):
validations:
required: true
- type: textarea
id: os_info
attributes:
label: 3. 操作系统信息
description: Windows / Linux / macOS 或其他系统,请写明版本
placeholder: |
例如:
- Ubuntu 22.04
- macOS 14.6
- Windows Server 2022
validations:
required: true
- type: textarea
id: repro_code
attributes:
label: 4. 可复现代码/配置
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
render: shell
placeholder: |
# 最小复现步骤
1. ...
2. ...
3. ...
# 关键请求/配置(脱敏后)
curl -X POST https://xxx/api/...
-H "Authorization: Bearer ***"
-d '{"amount":100,"order_id":"test-001"}'
validations:
required: true
- type: textarea
id: stacktrace
attributes:
label: 5. 错误日志 / 堆栈信息
description: 请粘贴完整报错信息、状态码、关键日志
render: shell
placeholder: |
例如:
- HTTP 状态码:
- 错误码:
- 错误堆栈:
- 关键日志片段:
validations:
required: true
- type: textarea
id: extra
attributes:
label: 补充信息
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
placeholder: 其他你认为有帮助的信息(可选)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 安全漏洞报告
url: https://github.com/ginta/epusdt/security/advisories/new
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
+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
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
+5 -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>
--- ---
@@ -37,11 +37,12 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
|------|------| |------|------|
| **TRC20** (Tron) | USDT、TRX| | **TRC20** (Tron) | USDT、TRX|
| **ERC20** (Ethereum) | USDT、USDC、ETH | | **ERC20** (Ethereum) | USDT、USDC、ETH |
| **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB | | **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC | | **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… | | **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 > 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
--- ---
@@ -122,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;
+6 -18
View File
@@ -1,14 +1,14 @@
app_name=epusdt app_name=epusdt
app_uri=https://dujiaoka.com app_uri=https://epusdt.com
log_level=info log_level=info
http_access_log=false http_access_log=false
sql_debug=false sql_debug=false
http_listen=:8000 http_listen=:8000
static_path=/static static_path=/static
runtime_root_path=/runtime runtime_root_path=./runtime
log_save_path=/logs log_save_path=./logs
log_max_size=32 log_max_size=32
log_max_age=7 log_max_age=7
max_backups=3 max_backups=3
@@ -50,22 +50,10 @@ queue_concurrency=10
queue_poll_interval_ms=1000 queue_poll_interval_ms=1000
callback_retry_base_seconds=5 callback_retry_base_seconds=5
tg_bot_token=
tg_proxy=
tg_manage=
api_auth_token=
order_expiration_time=10 order_expiration_time=10
order_notice_max_retry=0 order_notice_max_retry=0
forced_usdt_rate=
api_rate_url= api_rate_url=
tron_grid_api_key=
solana_rpc_url=
ethereum_ws_url=wss://ethereum.publicnode.com # Set to true to re-run the install wizard on next startup.
install=true
# epay
epay_pid=
epay_key=
+46 -6
View File
@@ -3,12 +3,15 @@ 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/mq" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/task" "github.com/GMWalletApp/epusdt/mq"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/task"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
) )
var initOnce sync.Once var initOnce sync.Once
@@ -18,6 +21,43 @@ func InitApp() {
config.Init() config.Init()
log.Init() log.Init()
dao.Init() dao.Init()
// Wire settings-table lookups into the config package so
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
// Seed rate.api_url from .env into the settings table on first run
// so the admin UI can display and change it without a code restart.
// Only written if the key is not already present in the DB.
if data.GetSettingString("rate.api_url", "") == "" {
if envURL := config.GetRateApiUrlFromEnv(); envURL != "" {
if err := data.SetSetting("rate", "rate.api_url", envURL, "string"); err != nil {
color.Red.Printf("[bootstrap] seed rate.api_url err=%s\n", err)
}
}
}
// Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent.
_, 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. Fetch one-time password via API first!║")
color.Yellow.Printf("║ Username: admin ║\n")
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
}
if _, err := appjwt.EnsureSecret(); err != nil {
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
}
// Seed one universal default API key on fresh installs. The seeded
// key (PID=1000) works for all three gateway flows.
_, err = data.EnsureDefaultApiKey()
if err != nil {
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
}
mq.Start() mq.Start()
go telegram.BotStart() go telegram.BotStart()
go task.Start() go task.Start()
+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"
) )
+58 -9
View File
@@ -5,15 +5,18 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"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/middleware" "github.com/GMWalletApp/epusdt/install"
"github.com/assimon/luuu/route" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/route"
luluHttp "github.com/assimon/luuu/util/http" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" luluHttp "github.com/GMWalletApp/epusdt/util/http"
"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"
@@ -37,6 +40,14 @@ var startCmd = &cobra.Command{
Short: "start", Short: "start",
Long: "start http service", Long: "start http service",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// If no config file exists, or if install=true is set in the config,
// run the first-run install API on the same port as the main server.
// The wizard writes the .env (with install=false) and shuts itself
// down so bootstrap.InitApp() can read it normally on the same port.
if config.NeedsInstall() {
envPath, _ := config.ResolveConfigPath()
install.RunInstallServer(install.DefaultInstallAddr, envPath)
}
bootstrap.InitApp() bootstrap.InitApp()
printBanner() printBanner()
HttpServerStart() HttpServerStart()
@@ -53,6 +64,29 @@ func HttpServerStart() {
route.RegisterRoute(e) route.RegisterRoute(e)
e.Static(config.StaticPath, config.StaticFilePath) e.Static(config.StaticPath, config.StaticFilePath)
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
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,
}))
httpListen := viper.GetString("http_listen") httpListen := viper.GetString("http_listen")
go func() { go func() {
if err = e.Start(httpListen); err != http.ErrServerClosed { if err = e.Start(httpListen); err != http.ErrServerClosed {
@@ -86,13 +120,28 @@ func customHTTPErrorHandler(err error, e echo.Context) {
Message: msg, Message: msg,
RequestID: e.Request().Header.Get(echo.HeaderXRequestID), RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
} }
// echo.HTTPError carries a real HTTP status (401 for auth failures,
// 404 for missing routes, etc.). Propagate it instead of flattening
// everything to 200 — clients rely on the status code.
if he, ok := err.(*echo.HTTPError); ok { if he, ok := err.(*echo.HTTPError); ok {
e.String(http.StatusOK, he.Message.(string)) resp.StatusCode = he.Code
if s, ok := he.Message.(string); ok {
resp.Message = s
} else if he.Message != nil {
resp.Message = http.StatusText(he.Code)
}
_ = e.JSON(he.Code, resp)
return return
} }
// Internal RspError: propagate Code as both the JSON status_code and
// the real HTTP status when it maps to one (400/401/...); business
// codes (>=1000) map to HTTP 400 so clients get a proper 4xx while
// still reading the granular code from the body.
if he, ok := err.(*constant.RspError); ok { if he, ok := err.(*constant.RspError); ok {
resp.StatusCode = he.Code resp.StatusCode = he.Code
resp.Message = he.Msg resp.Message = he.Msg
_ = e.JSON(he.HttpStatus(), resp)
return
} }
_ = e.JSON(http.StatusOK, resp) _ = e.JSON(http.StatusInternalServerError, resp)
} }
+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"
) )
+176 -41
View File
@@ -3,18 +3,26 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"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"
) )
// parseFloat is the minimal helper used by the settings bridge; kept
// here so settings_bridge.go stays import-free.
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
var ( var (
HTTPAccessLog bool HTTPAccessLog bool
SQLDebug bool SQLDebug bool
@@ -27,9 +35,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"
@@ -58,6 +63,9 @@ func Init() {
LogLevel = normalizeLogLevel(viper.GetString("log_level")) LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path")) StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/")) StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
if err = ensureConfiguredStaticFiles(); err != nil {
panic(err)
}
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime")) RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs")) LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath) mustMkdir(RuntimePath)
@@ -70,9 +78,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) {
@@ -81,6 +86,93 @@ func mustMkdir(path string) {
} }
} }
func ensureConfiguredStaticFiles() error {
if strings.TrimSpace(StaticFilePath) == "" {
return nil
}
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return err
}
srcDir := filepath.Join(filepath.Dir(exePath), "static")
srcInfo, err := os.Stat(srcDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !srcInfo.IsDir() {
return nil
}
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return err
}
dstAbs, err := filepath.Abs(StaticFilePath)
if err != nil {
return err
}
if srcAbs == dstAbs {
return nil
}
return copyMissingStaticFiles(srcAbs, dstAbs)
}
func copyMissingStaticFiles(srcDir, dstDir string) error {
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, rel)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if _, err = os.Stat(dstPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
return copyFile(path, dstPath)
})
}
func copyFile(srcPath, dstPath string) error {
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil
}
return err
}
if _, err = io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func normalizeLogLevel(level string) string { func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) { switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error": case "debug", "info", "warn", "error":
@@ -124,6 +216,57 @@ func resolveConfigFilePath() (string, error) {
return normalizeConfiguredPath(".env") return normalizeConfiguredPath(".env")
} }
// NeedsInstall returns true when the first-run install wizard should run.
// It returns true if the config file does not exist yet, or if the file
// exists and contains install=true (an explicit reset flag).
// Existing installs that have no install key are NOT affected.
func NeedsInstall() bool {
envPath, exists := ResolveConfigPath()
if !exists {
return true
}
// Read just the install key without triggering full Init().
v := viper.New()
v.SetConfigFile(envPath)
if err := v.ReadInConfig(); err != nil {
// Unreadable config → treat as needing install.
return true
}
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
}
// ResolveConfigPath returns the absolute path to the .env file and whether
// it currently exists on disk. Unlike resolveConfigFilePath it does not
// return an error when the file is absent — callers (e.g. the install
// wizard) need the target path even before the file is written.
func ResolveConfigPath() (path string, exists bool) {
candidate := explicitConfigPath
if candidate == "" {
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
candidate = e
} else {
candidate = ".env"
}
}
// Resolve relative paths against cwd.
p := strings.TrimSpace(candidate)
if !filepath.IsAbs(p) {
cwd, err := os.Getwd()
if err == nil {
p = filepath.Join(cwd, p)
}
}
info, err := os.Stat(p)
if err == nil && info.IsDir() {
p = filepath.Join(p, ".env")
info, err = os.Stat(p)
}
if err != nil {
return p, false
}
return p, true
}
func normalizeConfiguredPath(input string) (string, error) { func normalizeConfiguredPath(input string) (string, error) {
path := strings.TrimSpace(input) path := strings.TrimSpace(input)
if path == "" { if path == "" {
@@ -178,11 +321,19 @@ func GetAppUri() string {
return viper.GetString("app_uri") return viper.GetString("app_uri")
} }
func GetApiAuthToken() string { func GetRateApiUrl() string {
return viper.GetString("api_auth_token") // settings table wins (admin-configurable); .env and env var remain
// as fallbacks for smooth migration from the old layout.
if db := settingsRateApiUrl(); db != "" {
return db
}
return GetRateApiUrlFromEnv()
} }
func GetRateApiUrl() string { // GetRateApiUrlFromEnv returns the rate API URL read only from the
// .env / environment variables, bypassing the settings table. Used
// by bootstrap to seed the settings table from the initial .env value.
func GetRateApiUrlFromEnv() string {
rateURL := viper.GetString("api_rate_url") rateURL := viper.GetString("api_rate_url")
if rateURL == "" { if rateURL == "" {
rateURL = os.Getenv("API_RATE_URL") rateURL = os.Getenv("API_RATE_URL")
@@ -190,7 +341,6 @@ func GetRateApiUrl() string {
if rateURL == "" { if rateURL == "" {
log.Println("api_rate_url is empty") log.Println("api_rate_url is empty")
} }
RateApiUrl = rateURL
return rateURL return rateURL
} }
@@ -212,13 +362,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
@@ -250,14 +401,18 @@ func GetRateForCoin(coin string, base string) float64 {
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate") // Only the admin setting can force the USDT/CNY rate. When the
if forcedUsdtRate > 0 { // setting is unset, zero, or negative, fall back to the rate API.
return forcedUsdtRate if forced := settingsForcedUsdtRate(); forced > 0 {
return forced
} }
if UsdtRate <= 0 {
return 6.4 apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 {
return 1 / apiRate
} }
return UsdtRate log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
return 0
} }
func GetOrderExpirationTime() int { func GetOrderExpirationTime() int {
@@ -328,23 +483,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"))
}
func GetEpayPid() int {
return viper.GetInt("epay_pid")
}
func GetEpayKey() string {
return viper.GetString("epay_key")
}
+219
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,181 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Fatalf("config path = %s, want %s", got, flagPath) t.Fatalf("config path = %s, want %s", got, flagPath)
} }
} }
func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "app-static")
dst := filepath.Join(root, "data-static")
if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
t.Fatalf("write source index: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
t.Fatalf("write source payment: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
t.Fatalf("write source logo: %v", err)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatalf("mkdir destination: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
t.Fatalf("write existing destination index: %v", err)
}
if err := copyMissingStaticFiles(src, dst); err != nil {
t.Fatalf("copy missing static files: %v", err)
}
index, err := os.ReadFile(filepath.Join(dst, "index.html"))
if err != nil {
t.Fatalf("read destination index: %v", err)
}
if string(index) != "custom-index" {
t.Fatalf("existing index was overwritten: %q", string(index))
}
payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
if err != nil {
t.Fatalf("read copied payment: %v", err)
}
if string(payment) != "source-payment" {
t.Fatalf("payment.js = %q, want source-payment", string(payment))
}
logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
if err != nil {
t.Fatalf("read copied nested asset: %v", err)
}
if string(logo) != "source-logo" {
t.Fatalf("logo.png = %q, want source-logo", string(logo))
}
}
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)
}
}
+34
View File
@@ -0,0 +1,34 @@
package config
// Settings lookups are injected at runtime by bootstrap (after DB init)
// to keep the config package free of a `model/data` import, which would
// create a cycle via `model/dao -> config`.
//
// If unset (e.g. during early startup or tests) the getters return the
// zero string / 0 and callers apply their own fallback behavior.
// SettingsGetString is installed by bootstrap.Init with a closure that
// reads from the settings table. Runtime-only.
var SettingsGetString func(key string) string
func settingsRateApiUrl() string {
if SettingsGetString == nil {
return ""
}
return SettingsGetString("rate.api_url")
}
func settingsForcedUsdtRate() float64 {
if SettingsGetString == nil {
return 0
}
raw := SettingsGetString("rate.forced_usdt_rate")
if raw == "" {
return 0
}
f, err := parseFloat(raw)
if err != nil {
return 0
}
return f
}
+310
View File
@@ -0,0 +1,310 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// CreateApiKeyRequest is the payload for creating an API key.
// A single key is valid for both gateway flows (epay/gmpay); there is
// no gateway_type. PID is auto-generated (incrementing from 1000);
// no manual override.
type CreateApiKeyRequest struct {
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
IpWhitelist string `json:"ip_whitelist" example:""`
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
}
// CreateApiKeyResponse is the response for a newly created API key.
type CreateApiKeyResponse struct {
ID uint64 `json:"id" example:"1"`
Name string `json:"name" example:"My API Key"`
Pid string `json:"pid" example:"1003"`
// SecretKey is returned ONCE on creation. After that, fetch via
// GET /api-keys/:id/secret or rotate to generate a new one.
SecretKey string `json:"secret_key" example:"secret123abc456"`
}
// UpdateApiKeyRequest is the payload for updating an API key.
type UpdateApiKeyRequest struct {
Name *string `json:"name" example:"Updated Key Name"`
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
}
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
type ChangeApiKeyStatusRequest struct {
// 状态 1=启用 2=禁用
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
}
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
// mdb.ApiKey json tag (`json:"-"`).
// @Summary List API keys
// @Description Returns all API keys
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys [get]
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
rows, err := data.ListApiKeys()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateApiKey mints a new universal key. PID is auto-incremented from
// the highest existing numeric PID (starting at 1000). Secret is
// randomly generated and returned once.
// @Summary Create API key
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateApiKeyRequest true "API key payload"
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys [post]
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
req := new(CreateApiKeyRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
// Retry on unique-index violation: two concurrent creates could
// both see the same max PID from NextPid() and race on INSERT.
// The loser re-reads and retries with the now-higher max.
const maxAttempts = 5
secret := randomHex(32)
var row *mdb.ApiKey
for attempt := 0; attempt < maxAttempts; attempt++ {
pid, err := data.NextPid()
if err != nil {
return c.FailJson(ctx, err)
}
row = &mdb.ApiKey{
Name: req.Name,
Pid: strconv.Itoa(pid),
SecretKey: secret,
IpWhitelist: req.IpWhitelist,
NotifyUrl: req.NotifyUrl,
Status: mdb.ApiKeyStatusEnable,
}
err = data.CreateApiKey(row)
if err == nil {
break
}
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
return c.FailJson(ctx, err)
}
}
return c.SucJson(ctx, CreateApiKeyResponse{
ID: row.ID,
Name: row.Name,
Pid: row.Pid,
SecretKey: secret,
})
}
// isUniqueViolation matches the driver-specific strings for a unique-
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
// the PID retry loop; a cleaner approach would be a typed error but
// GORM doesn't surface one portably.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") ||
strings.Contains(msg, "duplicate") ||
strings.Contains(msg, "constraint failed")
}
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
// and enable/disable have their own endpoints.
// @Summary Update API key
// @Description Patch API key name / ip_whitelist / notify_url
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "API key ID"
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id} [patch]
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateApiKeyRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = *req.Name
}
if req.IpWhitelist != nil {
fields["ip_whitelist"] = *req.IpWhitelist
}
if req.NotifyUrl != nil {
fields["notify_url"] = *req.NotifyUrl
}
if err := data.UpdateApiKeyFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ChangeApiKeyStatus toggles enable/disable.
// @Summary Change API key status
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "API key ID"
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/status [post]
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(ChangeApiKeyStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
// @Summary Rotate API key secret
// @Description Generate a new secret_key. Returned only once.
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
secret := randomHex(32)
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]string{"secret_key": secret})
}
// DeleteApiKey soft-deletes a row. Orders created with this key will
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
// disabling via /status over deletion if there are live orders.
// @Summary Delete API key
// @Description Soft-delete an API key
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id} [delete]
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteApiKeyByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// GetApiKeyStats returns usage stats for one key. Currently thin — just
// the stored call_count and last_used_at — since we don't persist per-
// call telemetry. Expanded later if/when we add a request log table.
// @Summary Get API key stats
// @Description Returns usage stats (call_count, last_used_at) for an API key
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/stats [get]
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetApiKeyByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found"))
}
return c.SucJson(ctx, map[string]interface{}{
"call_count": row.CallCount,
"last_used_at": row.LastUsedAt,
})
}
// GetApiKeySecret returns the secret_key for one API key. Requires
// admin JWT authentication.
// @Summary View API key secret
// @Description Returns the secret_key for an API key (admin only)
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=map[string]string}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/secret [get]
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetApiKeyByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found"))
}
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+199
View File
@@ -0,0 +1,199 @@
package admin
import (
"errors"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
// LoginRequest is the payload for admin login.
type LoginRequest struct {
Username string `json:"username" validate:"required" example:"admin"`
Password string `json:"password" validate:"required" example:"password123"`
}
// LoginResponse is the response for a successful login.
type LoginResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
Username string `json:"username" example:"admin"`
UserID uint64 `json:"user_id" example:"1"`
}
// ChangePasswordRequest is the payload for changing admin password.
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" validate:"required" example:"old_pass"`
NewPassword string `json:"new_password" validate:"required|minLen:6" example:"new_pass123"`
}
// MeResponse wraps AdminUser with a default-password flag.
type MeResponse struct {
mdb.AdminUser
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
// @Tags Admin Auth
// @Accept json
// @Produce json
// @Param request body admin.LoginRequest true "Login credentials"
// @Success 200 {object} response.ApiResponse{data=admin.LoginResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/login [post]
func (c *BaseAdminController) Login(ctx echo.Context) error {
req := new(LoginRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
user, err := data.GetAdminUserByUsername(req.Username)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
return c.FailJson(ctx, errors.New("invalid username or password"))
}
if user.Status != mdb.AdminUserStatusEnable {
return c.FailJson(ctx, errors.New("user disabled"))
}
token, err := appjwt.Sign(user.ID, user.Username)
if err != nil {
return c.FailJson(ctx, err)
}
err = data.TouchAdminUserLastLogin(user.ID)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, LoginResponse{
Token: token,
Username: user.Username,
UserID: user.ID,
})
}
// 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
// @Description Logout (no-op, frontend discards token)
// @Tags Admin Auth
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse
// @Router /admin/api/v1/auth/logout [post]
func (c *BaseAdminController) Logout(ctx echo.Context) error {
return c.SucJson(ctx, nil)
}
// Me returns the currently authenticated admin user profile.
// @Summary Get current admin profile
// @Description Returns the currently authenticated admin user
// @Tags Admin Auth
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.MeResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/me [get]
func (c *BaseAdminController) Me(ctx echo.Context) error {
uid := currentAdminUserID(ctx)
if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized"))
}
user, err := data.GetAdminUserByID(uid)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found"))
}
// 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,
})
}
// ChangePassword requires the old password, updates to the new one.
// @Summary Change admin password
// @Description Change the current admin user's password
// @Tags Admin Auth
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.ChangePasswordRequest true "Password payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/password [post]
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
uid := currentAdminUserID(ctx)
if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized"))
}
req := new(ChangePasswordRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
user, err := data.GetAdminUserByID(uid)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
return c.FailJson(ctx, errors.New("old password incorrect"))
}
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+26
View File
@@ -0,0 +1,26 @@
package admin
import (
"github.com/GMWalletApp/epusdt/controller"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4"
)
// Ctrl is the singleton admin controller. Keeps parity with comm.Ctrl.
var Ctrl = &BaseAdminController{}
// BaseAdminController aggregates admin-domain handlers. Split across
// *_controller.go files in this package; all receive methods hang off
// this type so a single router registration is enough.
type BaseAdminController struct {
controller.BaseController
}
// currentAdminUserID returns the admin user id injected by CheckAdminJWT.
// Returns 0 if the middleware is missing (treat as auth bug upstream).
func currentAdminUserID(ctx echo.Context) uint64 {
if v, ok := ctx.Get(middleware.AdminUserIDKey).(uint64); ok {
return v
}
return 0
}
+71
View File
@@ -0,0 +1,71 @@
package admin
import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
// UpdateChainRequest is the payload for updating chain settings.
type UpdateChainRequest struct {
Enabled *bool `json:"enabled" example:"true"`
MinConfirmations *int `json:"min_confirmations" example:"20"`
ScanIntervalSec *int `json:"scan_interval_sec" example:"5"`
DisplayName *string `json:"display_name" example:"Tron"`
}
// ListChains returns the chains table (tron/ethereum/solana seeded; new
// networks like bsc/polygon inserted manually once supported).
// @Summary List chains
// @Description Returns all supported blockchain networks
// @Tags Admin Chains
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=[]mdb.Chain}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chains [get]
func (c *BaseAdminController) ListChains(ctx echo.Context) error {
rows, err := data.ListChains()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// UpdateChain toggles enabled / tweaks scan params. Scanners re-read the
// chains table on every poll tick, so changes take effect within one
// cycle without a restart.
// @Summary Update chain
// @Description Toggle enabled / tweak scan params for a chain
// @Tags Admin Chains
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
// @Param request body admin.UpdateChainRequest true "Chain settings"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chains/{network} [patch]
func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
network := ctx.Param("network")
req := new(UpdateChainRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if req.MinConfirmations != nil {
fields["min_confirmations"] = *req.MinConfirmations
}
if req.ScanIntervalSec != nil {
fields["scan_interval_sec"] = *req.ScanIntervalSec
}
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if err := data.UpdateChainFields(network, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
@@ -0,0 +1,181 @@
package admin
import (
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// CreateChainTokenRequest is the payload for creating a chain token.
type CreateChainTokenRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
MinAmount float64 `json:"min_amount" example:"1.0"`
}
// UpdateChainTokenRequest is the payload for updating a chain token.
type UpdateChainTokenRequest struct {
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals *int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
MinAmount *float64 `json:"min_amount" example:"1.0"`
}
// ChangeChainTokenStatusRequest is the payload for toggling chain token status.
type ChangeChainTokenStatusRequest struct {
Enabled bool `json:"enabled" example:"true"`
}
// ListChainTokens returns rows, optionally filtered by network.
// @Summary List chain tokens
// @Description Returns chain tokens, optionally filtered by network
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens [get]
func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
rows, err := data.ListChainTokens(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
// @Summary Create chain token
// @Description Create a new chain token entry
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateChainTokenRequest true "Chain token payload"
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens [post]
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
req := new(CreateChainTokenRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
decimals := req.Decimals
if decimals == 0 {
decimals = 6
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.ChainToken{
Network: strings.ToLower(strings.TrimSpace(req.Network)),
Symbol: strings.ToUpper(strings.TrimSpace(req.Symbol)),
ContractAddress: strings.TrimSpace(req.ContractAddress),
Decimals: decimals,
Enabled: enabled,
MinAmount: req.MinAmount,
}
if err := data.CreateChainToken(row); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, row)
}
// UpdateChainToken patches mutable columns.
// @Summary Update chain token
// @Description Patch chain token fields
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Chain token ID"
// @Param request body admin.UpdateChainTokenRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id} [patch]
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateChainTokenRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.ContractAddress != nil {
fields["contract_address"] = *req.ContractAddress
}
if req.Decimals != nil {
fields["decimals"] = *req.Decimals
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if req.MinAmount != nil {
fields["min_amount"] = *req.MinAmount
}
if err := data.UpdateChainTokenFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ChangeChainTokenStatus toggles enable/disable.
// @Summary Change chain token status
// @Description Toggle enable/disable for a chain token
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Chain token ID"
// @Param request body admin.ChangeChainTokenStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id}/status [post]
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(ChangeChainTokenStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteChainToken soft-deletes the row.
// @Summary Delete chain token
// @Description Soft-delete a chain token
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Produce json
// @Param id path int true "Chain token ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id} [delete]
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteChainTokenByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
@@ -0,0 +1,288 @@
package admin
import (
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
// OverviewResponse is the dashboard top-card response.
type OverviewResponse struct {
TotalAsset float64 `json:"total_asset" example:"12345.6789"`
Volume float64 `json:"volume" example:"100.5"`
OrderCount int64 `json:"order_count" example:"42"`
SuccessRate float64 `json:"success_rate" example:"0.85"`
SevenDayVolume float64 `json:"seven_day_volume" example:"700.25"`
ActiveAddresses int64 `json:"active_addresses" example:"5"`
OnlineChains int64 `json:"online_chains" example:"3"`
}
// OrderStatsResponse is the order statistics response.
type OrderStatsResponse struct {
OrderCount int64 `json:"order_count" example:"100"`
SuccessCount int64 `json:"success_count" example:"85"`
SuccessRate float64 `json:"success_rate" example:"0.85"`
AvgPaymentSeconds float64 `json:"avg_payment_seconds" example:"120.5"`
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
}
// RpcHealthCheckResponse is the response for a health check probe.
type RpcHealthCheckResponse struct {
// 健康状态 unknown=未知 ok=正常 down=异常
Status string `json:"status" example:"ok" enums:"unknown,ok,down"`
LastLatencyMs int `json:"last_latency_ms" example:"120"`
}
// ApiKeyStatsResponse is the response for API key usage stats.
type ApiKeyStatsResponse struct {
CallCount int64 `json:"call_count" example:"342"`
LastUsedAt string `json:"last_used_at" example:"2026-04-14T08:30:00+00:00"`
}
// RotateSecretResponse is returned when rotating an API key secret.
type RotateSecretResponse struct {
SecretKey string `json:"secret_key" example:"newSecret123abc456"`
}
// BatchImportResult is one row in the batch import response.
type BatchImportResult struct {
Address string `json:"address" example:"TTestTronAddress001"`
OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""`
}
// SettingsUpsertResult is one row in the settings upsert response.
type SettingsUpsertResult struct {
Key string `json:"key" example:"rate.api_url"`
OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""`
}
// Overview returns the top-card numbers for the dashboard landing page.
// All values are computed with on-demand SQL — no cache — per the design.
// The selected time range (range / start_at / end_at) is applied to
// volume, order_count, success_rate and active_addresses.
// seven_day_volume always covers the rolling past 7 days.
// total_asset and online_chains are time-independent.
// @Summary Dashboard overview
// @Description Returns top-card numbers: total asset, volume, success rate, active addresses, etc. Supports ?range= for filtering.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=admin.OverviewResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/overview [get]
func (c *BaseAdminController) Overview(ctx echo.Context) error {
start, end := parseRange(ctx)
now := time.Now()
sevenDaysAgo := startOfDay(now).AddDate(0, 0, -6)
totalActual, _ := data.SumPaidActualAmount()
orderCount, successCount, volume, _ := data.PaidStatsInRange(start, end)
var successRate float64
if orderCount > 0 {
successRate = float64(successCount) / float64(orderCount)
}
_, _, sevenDayActual, _ := data.PaidStatsInRange(sevenDaysAgo, now)
activeAddr, _ := data.ActiveAddressCountInRange(start, end)
onlineChains, _ := data.CountEnabledChains()
return c.SucJson(ctx, map[string]interface{}{
"total_asset": totalActual,
"volume": volume,
"order_count": orderCount,
"success_rate": successRate,
"seven_day_volume": sevenDayActual,
"active_addresses": activeAddr,
"online_chains": onlineChains,
})
}
// isHourlyRange returns true when the time window spans ≤ 1 day,
// so callers can choose hourly vs daily granularity.
func isHourlyRange(start, end time.Time) bool {
return end.Sub(start) <= 24*time.Hour
}
// AssetTrend returns per-day actual_amount for paid orders. group_by=
// "total" yields one series; "address" yields rows keyed by
// receive_address for the stacked chart.
// Granularity: hourly when range ≤ 1 day, daily otherwise.
// Missing buckets are always zero-filled.
// @Summary Asset trend
// @Description Returns per-period actual_amount for paid orders. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Param group_by query string false "Group by: total or address" default(total)
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/asset-trend [get]
func (c *BaseAdminController) AssetTrend(ctx echo.Context) error {
start, end := parseRange(ctx)
groupBy := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group_by")))
hourly := isHourlyRange(start, end)
if groupBy == "address" {
var rows interface{}
var err error
if hourly {
rows, err = data.HourlyAssetByAddress(start, end)
} else {
rows, err = data.DailyAssetByAddress(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
var rows interface{}
var err error
if hourly {
rows, err = data.HourlyOrderStats(start, end)
} else {
rows, err = data.DailyOrderStats(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// RevenueTrend mirrors the daily stats view used for revenue charts.
// Same data shape as AssetTrend(group_by=total) but kept as its own
// endpoint so the frontend can evolve each chart independently.
// Granularity: hourly when range ≤ 1 day, daily otherwise.
// Missing buckets are always zero-filled.
// @Summary Revenue trend
// @Description Returns per-period revenue stats for charts. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/revenue-trend [get]
func (c *BaseAdminController) RevenueTrend(ctx echo.Context) error {
start, end := parseRange(ctx)
var rows interface{}
var err error
if isHourlyRange(start, end) {
rows, err = data.HourlyOrderStats(start, end)
} else {
rows, err = data.DailyOrderStats(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// OrderStats returns aggregate order figures for the selected window:
// total count, success rate, average payment duration (seconds), and
// expired-unpaid count.
// @Summary Order statistics
// @Description Returns aggregate order figures: count, success rate, avg payment time, expired count
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=admin.OrderStatsResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/order-stats [get]
func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
start, end := parseRange(ctx)
total, success, _, err := data.PaidStatsInRange(start, end)
if err != nil {
return c.FailJson(ctx, err)
}
avg, _ := data.AveragePaymentDurationSeconds(start, end)
expired, _ := data.CountExpiredInRange(start, end)
var rate float64
if total > 0 {
rate = float64(success) / float64(total)
}
return c.SucJson(ctx, map[string]interface{}{
"order_count": total,
"success_count": success,
"success_rate": rate,
"avg_payment_seconds": avg,
"expired_unpaid_count": expired,
})
}
// RecentOrders returns the last N orders for the dashboard list card.
// @Summary Recent orders
// @Description Returns the last N orders for the dashboard
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param limit query int false "Number of orders to return" default(20)
// @Success 200 {object} response.ApiResponse{data=[]mdb.Orders}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/recent-orders [get]
func (c *BaseAdminController) RecentOrders(ctx echo.Context) error {
limit := 20
if s := ctx.QueryParam("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
limit = n
}
}
rows, err := data.RecentOrders(limit)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// parseRange maps the ?range=... shorthand to a concrete [start, end].
// Accepted: today / 7d / 30d / custom (with start_at/end_at Unix secs).
// Defaults to today.
func parseRange(ctx echo.Context) (time.Time, time.Time) {
rng := strings.ToLower(strings.TrimSpace(ctx.QueryParam("range")))
now := time.Now()
todayStart := startOfDay(now)
switch rng {
case "7d":
return todayStart.AddDate(0, 0, -6), now
case "30d":
return todayStart.AddDate(0, 0, -29), now
case "custom":
start := todayStart
end := now
if s := ctx.QueryParam("start_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
start = time.Unix(n, 0)
}
}
if s := ctx.QueryParam("end_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
end = time.Unix(n, 0)
}
}
return start, end
default:
return todayStart, now
}
}
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
@@ -0,0 +1,314 @@
package admin
import (
"encoding/json"
"errors"
"strconv"
"strings"
"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"
)
// CreateNotificationChannelRequest is the payload for creating a notification channel.
type CreateNotificationChannelRequest struct {
Type string `json:"type" validate:"required|in:telegram,webhook,email" enums:"telegram,webhook,email" example:"telegram"`
Name string `json:"name" validate:"required|maxLen:128" example:"主通知群"`
Config map[string]interface{} `json:"config" validate:"required"`
Events interface{} `json:"events"`
Enabled *bool `json:"enabled" example:"true"`
}
// UpdateNotificationChannelRequest is the payload for updating a notification channel.
type UpdateNotificationChannelRequest struct {
Name *string `json:"name" example:"更新后的名称"`
Config map[string]interface{} `json:"config"`
Events interface{} `json:"events"`
Enabled *bool `json:"enabled" example:"true"`
}
// ChangeChannelStatusRequest is the payload for toggling notification channel status.
type ChangeChannelStatusRequest struct {
Enabled bool `json:"enabled" example:"true"`
}
// ListNotificationChannels lists rows, optionally filtered by type.
// @Summary List notification channels
// @Description Returns notification channels, optionally filtered by type
// @Tags Admin Notifications
// @Security AdminJWT
// @Produce json
// @Param type query string false "Channel type: telegram, webhook, email"
// @Success 200 {object} response.ApiResponse{data=[]mdb.NotificationChannel}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels [get]
func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
channelType := strings.ToLower(strings.TrimSpace(ctx.QueryParam("type")))
rows, err := data.ListNotificationChannels(channelType)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateNotificationChannel validates Config shape per type (only
// telegram is strongly validated today) and inserts a row.
// @Summary Create notification channel
// @Description Create a new notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateNotificationChannelRequest true "Channel payload"
// @Success 200 {object} response.ApiResponse{data=mdb.NotificationChannel}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels [post]
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
req := new(CreateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := validateChannelConfig(req.Type, req.Config); err != nil {
return c.FailJson(ctx, err)
}
eventsMap, err := normalizeChannelEvents(req.Events)
if err != nil {
return c.FailJson(ctx, err)
}
configJSON, _ := json.Marshal(req.Config)
eventsJSON, _ := json.Marshal(eventsMap)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.NotificationChannel{
Type: req.Type,
Name: req.Name,
Config: string(configJSON),
Events: string(eventsJSON),
Enabled: enabled,
}
if err := data.CreateNotificationChannel(row); err != nil {
return c.FailJson(ctx, err)
}
if strings.ToLower(strings.TrimSpace(row.Type)) == mdb.NotificationTypeTelegram {
telegram.ReloadBotAsync("admin create telegram channel")
}
return c.SucJson(ctx, row)
}
// UpdateNotificationChannel patches name/config/events/enabled.
// @Summary Update notification channel
// @Description Patch notification channel fields
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Channel ID"
// @Param request body admin.UpdateNotificationChannelRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id} [patch]
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
req := new(UpdateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = *req.Name
}
if req.Config != nil {
configJSON, _ := json.Marshal(req.Config)
fields["config"] = string(configJSON)
}
if req.Events != nil {
eventsMap, err := normalizeChannelEvents(req.Events)
if err != nil {
return c.FailJson(ctx, err)
}
eventsJSON, _ := json.Marshal(eventsMap)
fields["events"] = string(eventsJSON)
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if err := data.UpdateNotificationChannelFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin update telegram channel")
}
return c.SucJson(ctx, nil)
}
// ChangeNotificationChannelStatus toggles enabled.
// @Summary Change notification channel status
// @Description Toggle enable/disable for a notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Channel ID"
// @Param request body admin.ChangeChannelStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id}/status [post]
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
req := new(ChangeChannelStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin change telegram channel status")
}
return c.SucJson(ctx, nil)
}
// DeleteNotificationChannel soft-deletes the row.
// @Summary Delete notification channel
// @Description Soft-delete a notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Produce json
// @Param id path int true "Channel ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id} [delete]
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
if err := data.DeleteNotificationChannelByID(id); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin delete telegram channel")
}
return c.SucJson(ctx, nil)
}
// validateChannelConfig enforces minimum shape. Telegram needs bot_token
// + chat_id; webhook/email kept loose until those senders exist.
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
if cfg == nil {
return errors.New("config required")
}
if channelType == mdb.NotificationTypeTelegram {
configJSON, _ := json.Marshal(cfg)
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
return err
}
}
return nil
}
func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
if raw == nil {
return map[string]bool{}, nil
}
toFlag := func(v interface{}) (bool, error) {
switch t := v.(type) {
case bool:
return t, nil
case float64:
return t != 0, nil
case string:
s := strings.ToLower(strings.TrimSpace(t))
switch s {
case "", "0", "false", "off", "no":
return false, nil
case "1", "true", "on", "yes":
return true, nil
default:
return false, errors.New("invalid boolean string")
}
default:
return false, errors.New("invalid event value type")
}
}
trimKey := func(s string) string {
return strings.TrimSpace(s)
}
out := make(map[string]bool)
switch t := raw.(type) {
case map[string]bool:
for k, v := range t {
k = trimKey(k)
if k == "" {
continue
}
out[k] = v
}
return out, nil
case map[string]interface{}:
for k, v := range t {
k = trimKey(k)
if k == "" {
continue
}
flag, err := toFlag(v)
if err != nil {
return nil, err
}
out[k] = flag
}
return out, nil
case []interface{}:
for _, v := range t {
s, ok := v.(string)
if !ok {
return nil, errors.New("events array must contain strings")
}
s = trimKey(s)
if s == "" {
continue
}
out[s] = true
}
return out, nil
default:
return nil, errors.New("events must be object or string array")
}
}
+393
View File
@@ -0,0 +1,393 @@
package admin
import (
"encoding/csv"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"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"
)
// MarkPaidRequest is the payload for manually marking an order as paid.
type MarkPaidRequest struct {
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
}
// OrderListResponse wraps the paginated order list.
type OrderListResponse struct {
List []mdb.Orders `json:"list"`
Total int64 `json:"total" example:"100"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"20"`
}
// OrderWithSub embeds a parent order together with its child orders.
type OrderWithSub struct {
mdb.Orders
SubOrders []mdb.Orders `json:"sub_orders"`
}
// OrderWithSubListResponse wraps the paginated list-with-sub result.
type OrderWithSubListResponse struct {
List []OrderWithSub `json:"list"`
Total int64 `json:"total" example:"100"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"20"`
}
// ListOrders handles the admin order list with filters + pagination.
// @Summary List orders
// @Description Returns paginated orders with optional filters
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search (trade_id or order_id)"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders [get]
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
f := parseOrderFilter(ctx)
rows, total, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]interface{}{
"list": rows,
"total": total,
"page": f.Page,
"page_size": f.PageSize,
})
}
// GetOrder returns full order detail by trade_id.
// @Summary Get order
// @Description Returns full order detail by trade_id
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id} [get]
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
return c.SucJson(ctx, order)
}
// CloseOrder flips a waiting order to expired and releases its lock.
// @Summary Close order
// @Description Manually close a waiting order (mark as expired)
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/close [post]
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment"))
}
ok, err := data.CloseOrderManually(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if !ok {
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
}
// Release the transaction lock so the amount slot becomes reusable.
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
return c.SucJson(ctx, nil)
}
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
// same OrderProcessing path as on-chain confirmation so downstream
// callback + notification fire normally.
// @Summary Mark order paid
// @Description Manually mark a waiting order as paid (operator补单)
// @Tags Admin Orders
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
req := new(MarkPaidRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment"))
}
err = service.OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: req.BlockTransactionId,
})
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ResendCallback requeues the order callback by flipping
// callback_confirm back to NO. The mq worker will grab it on the
// next poll tick.
// @Summary Resend callback
// @Description Re-queue the order callback notification
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
ok, err := data.ReopenOrderCallback(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if !ok {
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
}
return c.SucJson(ctx, nil)
}
// ExportOrders streams matching orders as CSV. No pagination — the
// caller drives what rows to include via filter params. Capped at
// 10k rows to keep the response bounded.
// @Summary Export orders as CSV
// @Description Stream matching orders as a CSV file (max 10k rows)
// @Tags Admin Orders
// @Security AdminJWT
// @Produce text/csv
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Success 200 {string} string "CSV file"
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/export [get]
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
f := parseOrderFilter(ctx)
f.Page = 1
f.PageSize = 10000
rows, _, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
resp := ctx.Response()
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
resp.WriteHeader(http.StatusOK)
w := csv.NewWriter(resp.Writer)
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
for _, r := range rows {
_ = w.Write([]string{
r.TradeId,
r.OrderId,
fmt.Sprintf("%.4f", r.Amount),
fmt.Sprintf("%.4f", r.ActualAmount),
r.Currency,
r.Token,
r.Network,
r.ReceiveAddress,
strconv.Itoa(r.Status),
r.BlockTransactionId,
r.CreatedAt.ToDateTimeString(),
})
}
w.Flush()
return nil
}
// parseOrderFilter extracts filter + pagination values from the query
// string. Time params are Unix seconds.
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
f := data.OrderListFilter{
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
Address: strings.TrimSpace(ctx.QueryParam("address")),
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
}
if s := ctx.QueryParam("status"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Status = n
}
}
if s := ctx.QueryParam("start_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
t := time.Unix(n, 0)
f.StartAt = &t
}
}
if s := ctx.QueryParam("end_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
t := time.Unix(n, 0)
f.EndAt = &t
}
}
if s := ctx.QueryParam("page"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Page = n
}
}
if s := ctx.QueryParam("page_size"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.PageSize = n
}
}
if f.Page < 1 {
f.Page = 1
}
if f.PageSize < 1 {
f.PageSize = 20
}
return f
}
// ListOrdersWithSub returns the same paginated order list as ListOrders but
// each row has a sub_orders field containing all child orders under it.
// @Summary List orders with sub-orders
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search (trade_id or order_id)"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/list-with-sub [get]
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
f := parseOrderFilter(ctx)
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
f.ParentOnly = true
rows, total, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
// Batch-fetch all sub-orders for the current page in one query.
tradeIds := make([]string, 0, len(rows))
for _, r := range rows {
tradeIds = append(tradeIds, r.TradeId)
}
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
if err != nil {
return c.FailJson(ctx, err)
}
// Group sub-orders by parent_trade_id.
subMap := make(map[string][]mdb.Orders, len(subs))
for _, s := range subs {
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
}
list := make([]OrderWithSub, 0, len(rows))
for _, r := range rows {
children := subMap[r.TradeId]
if children == nil {
children = []mdb.Orders{}
}
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
}
return c.SucJson(ctx, OrderWithSubListResponse{
List: list,
Total: total,
Page: f.Page,
PageSize: f.PageSize,
})
}
// GetOrderWithSub returns a single parent order together with all its child
// orders (any status), regardless of whether the caller passes a parent or
// child trade_id — if it is a child, the parent is resolved first.
// @Summary Get order with sub-orders
// @Description Returns an order and its child orders embedded in sub_orders
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
subOrders, err := data.GetAllSubOrders(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if subOrders == nil {
subOrders = []mdb.Orders{}
}
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
}
+190
View File
@@ -0,0 +1,190 @@
package admin
import (
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4"
)
// CreateRpcNodeRequest is the payload for creating an RPC node.
type CreateRpcNodeRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
// 连接类型 http=HTTP请求 ws=WebSocket长连接
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
Weight int `json:"weight" example:"1"`
ApiKey string `json:"api_key" example:""`
Enabled *bool `json:"enabled" example:"true"`
}
// UpdateRpcNodeRequest is the payload for updating an RPC node.
type UpdateRpcNodeRequest struct {
Url *string `json:"url" example:"https://api.trongrid.io"`
Weight *int `json:"weight" example:"1"`
ApiKey *string `json:"api_key" example:"your-api-key"`
Enabled *bool `json:"enabled" example:"true"`
}
// ListRpcNodes returns rows optionally filtered by network.
// @Summary List RPC nodes
// @Description Returns RPC nodes, optionally filtered by network
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Success 200 {object} response.ApiResponse{data=[]mdb.RpcNode}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes [get]
func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
rows, err := data.ListRpcNodes(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateRpcNode inserts a row. Status starts as "unknown" until the
// health-check task runs.
// @Summary Create RPC node
// @Description Create a new RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateRpcNodeRequest true "RPC node payload"
// @Success 200 {object} response.ApiResponse{data=mdb.RpcNode}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes [post]
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
req := new(CreateRpcNodeRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
weight := req.Weight
if weight < 1 {
weight = 1
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.RpcNode{
Network: strings.ToLower(strings.TrimSpace(req.Network)),
Url: strings.TrimSpace(req.Url),
Type: strings.ToLower(strings.TrimSpace(req.Type)),
Weight: weight,
ApiKey: req.ApiKey,
Enabled: enabled,
Status: mdb.RpcNodeStatusUnknown,
LastLatencyMs: -1,
}
if err := data.CreateRpcNode(row); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, row)
}
// UpdateRpcNode patches url/weight/api_key/enabled.
// @Summary Update RPC node
// @Description Patch RPC node fields
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "RPC node ID"
// @Param request body admin.UpdateRpcNodeRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id} [patch]
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateRpcNodeRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Url != nil {
fields["url"] = *req.Url
}
if req.Weight != nil {
fields["weight"] = *req.Weight
}
if req.ApiKey != nil {
fields["api_key"] = *req.ApiKey
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteRpcNode soft-deletes the row.
// @Summary Delete RPC node
// @Description Soft-delete an RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param id path int true "RPC node ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id} [delete]
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteRpcNodeByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// HealthCheckRpcNode performs an on-demand probe and writes the result.
// For HTTP endpoints this is a GET to the URL with a short timeout; for
// WS we just attempt a TCP-level check via the same HTTP client (the
// handshake URL resolves the host identically).
// @Summary Health check RPC node
// @Description Perform an on-demand health probe on an RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param id path int true "RPC node ID"
// @Success 200 {object} response.ApiResponse{data=admin.RpcHealthCheckResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id}/health-check [post]
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetRpcNodeByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("node not found"))
}
status, latency := task.ProbeNode(row.Url)
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]interface{}{
"status": status,
"last_latency_ms": latency,
})
}
+159
View File
@@ -0,0 +1,159 @@
package admin
import (
"strings"
"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"
)
// SettingUpsertItem is a single setting entry for batch upsert.
// Supported groups and keys:
//
// - group=rate:
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses 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.okx_c2c_enabled (bool) — use OKX C2C rate feed
//
// - group=epay:
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
//
// - group=brand:
// brand.site_name (string) — site display name
// brand.logo_url (string) — logo image URL
// brand.page_title (string) — payment page title
// brand.pay_success_text (string) — text shown on payment success
// brand.support_url (string) — support / help URL
//
// - group=system:
// system.order_expiration_time (int) — order expiry in minutes
type SettingUpsertItem struct {
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"`
Key string `json:"key" example:"epay.default_network"`
Value string `json:"value" example:"tron"`
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
}
// SettingsUpsertRequest is the payload for batch upserting settings.
type SettingsUpsertRequest struct {
Items []SettingUpsertItem `json:"items" validate:"required"`
}
// ListSettings returns all rows, optionally filtered by group.
// @Summary List settings
// @Description Returns all settings, optionally filtered by group.
// @Description Available groups: brand, rate, system, epay.
// @Description See SettingUpsertItem for the full list of supported keys per group.
// @Tags Admin Settings
// @Security AdminJWT
// @Produce json
// @Param group query string false "Group filter (brand|rate|system|epay)"
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings [get]
func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
group := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group")))
rows, err := data.ListSettingsByGroup(group)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// UpsertSettings batch-inserts / updates rows. Each item is treated
// independently so a malformed row in the middle doesn't drop earlier
// ones. Errors are returned per-key so the UI can surface them.
// @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status.
// @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 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 system group keys: system.order_expiration_time.
// @Tags Admin Settings
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.SettingsUpsertRequest true "Settings payload"
// @Success 200 {object} response.ApiResponse{data=[]admin.SettingsUpsertResult}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings [put]
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
req := new(SettingsUpsertRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
type result struct {
Key string `json:"key"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
out := make([]result, 0, len(req.Items))
for _, item := range req.Items {
key := strings.TrimSpace(item.Key)
if key == "" {
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
continue
}
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue
}
out = append(out, result{Key: key, OK: true})
}
// When telegram credentials are updated via settings, reload the
// command bot so operators don't need to restart the process, and
// sync the notification_channels row so the notify dispatcher picks
// up the new values immediately.
telegramKeys := map[string]bool{
"system.telegram_bot_token": true,
"system.telegram_chat_id": true,
"system.telegram_payment_notice_enabled": true,
"system.telegram_abnormal_notice_enabled": true,
}
for _, item := range req.Items {
if telegramKeys[strings.TrimSpace(item.Key)] {
telegram.ReloadBotAsync("settings upsert")
go dao.SyncTelegramChannelFromSettings()
break
}
}
return c.SucJson(ctx, out)
}
// DeleteSetting removes one row. The next read of that key will fall
// back to the hardcoded default (see settings_data.GetSetting*).
// @Summary Delete setting
// @Description Remove a setting by key (falls back to default)
// @Tags Admin Settings
// @Security AdminJWT
// @Produce json
// @Param key path string true "Setting key"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings/{key} [delete]
func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
key := strings.TrimSpace(ctx.Param("key"))
if key == "" {
return c.SucJson(ctx, nil)
}
if err := data.DeleteSetting(key); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// Public helper for the rate/usdt overrides — used by config package to
// read settings-backed values without importing the controller package.
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
+279
View File
@@ -0,0 +1,279 @@
package admin
import (
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// AdminAddWalletRequest is the payload for adding a wallet via admin.
type AdminAddWalletRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
Remark string `json:"remark" example:"主钱包"`
}
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
type AdminUpdateWalletRequest struct {
Remark *string `json:"remark" example:"已更新的备注"`
}
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
type AdminChangeWalletStatusRequest struct {
// 状态 1=启用 2=禁用
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
}
// AdminBatchImportRequest is the payload for batch importing wallets.
type AdminBatchImportRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
// Addresses listed as observation-only wallets. Private-key import
// and API-managed modes are out of scope for v1.
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
}
// WalletListItem extends WalletAddress with order count.
type WalletListItem struct {
mdb.WalletAddress
OrderCount int64 `json:"order_count" example:"5"`
}
// AdminListWallets extends the read model with per-wallet order counts
// so the UI can render the "订单数" column without an N+1 follow-up.
// @Summary List wallets (admin)
// @Description Returns wallets with per-wallet order counts
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
// @Param keyword query string false "Search by address or remark"
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets [get]
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
status := strings.TrimSpace(ctx.QueryParam("status"))
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
tx := dao.Mdb.Model(&mdb.WalletAddress{})
if network != "" {
tx = tx.Where("network = ?", network)
}
if status != "" {
if s, err := strconv.Atoi(status); err == nil {
tx = tx.Where("status = ?", s)
}
}
if keyword != "" {
kw := "%" + keyword + "%"
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
}
var rows []mdb.WalletAddress
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
return c.FailJson(ctx, err)
}
counts, err := data.CountOrdersByAddress()
if err != nil {
return c.FailJson(ctx, err)
}
out := make([]WalletListItem, 0, len(rows))
for _, r := range rows {
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
}
return c.SucJson(ctx, out)
}
// AdminAddWallet wraps the existing create with admin-exposed Remark +
// Source=manual so audit fields are populated.
// @Summary Add wallet (admin)
// @Description Create a wallet with admin-specific fields (remark, source=manual)
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets [post]
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
req := new(AdminAddWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
if err != nil {
return c.FailJson(ctx, err)
}
// Patch remark/source separately to avoid changing the existing
// package-level AddWalletAddressWithNetwork signature (used by
// Telegram bot + legacy endpoint).
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
if req.Remark != "" {
fields["remark"] = req.Remark
}
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
row.Remark = req.Remark
row.Source = mdb.WalletSourceManual
return c.SucJson(ctx, row)
}
// AdminGetWallet returns detail including order count for one wallet.
// @Summary Get wallet (admin)
// @Description Returns wallet detail with order count
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param id path int true "Wallet ID"
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [get]
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
wallet, err := data.GetWalletAddressById(id)
if err != nil {
return c.FailJson(ctx, err)
}
if wallet.ID == 0 {
return c.FailJson(ctx, errors.New("wallet not found"))
}
counts, _ := data.CountOrdersByAddress()
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
}
// AdminUpdateWallet only patches remark (status has its own endpoint).
// @Summary Update wallet (admin)
// @Description Patch wallet remark
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Wallet ID"
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [patch]
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(AdminUpdateWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Remark != nil {
fields["remark"] = *req.Remark
}
if len(fields) == 0 {
return c.SucJson(ctx, nil)
}
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminChangeWalletStatus reuses existing helper.
// @Summary Change wallet status (admin)
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Wallet ID"
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id}/status [post]
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(AdminChangeWalletStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminDeleteWallet soft-deletes the wallet.
// @Summary Delete wallet (admin)
// @Description Soft-delete a wallet
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param id path int true "Wallet ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [delete]
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteWalletAddressById(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminBatchImportWallets accepts a list of addresses and creates
// them all as observation wallets. Per-row failures are collected and
// returned so the UI can surface which addresses already existed.
// @Summary Batch import wallets
// @Description Import multiple wallet addresses at once. Per-row status is returned.
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/batch-import [post]
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
req := new(AdminBatchImportRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
type result struct {
Address string `json:"address"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
out := make([]result, 0, len(req.Addresses))
for _, addr := range req.Addresses {
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
if err != nil {
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
continue
}
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
Update("source", mdb.WalletSourceImport).Error
out = append(out, result{Address: addr, OK: true})
}
return c.SucJson(ctx, out)
}
+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{}
+84 -17
View File
@@ -2,16 +2,47 @@ package comm
import ( import (
"encoding/json" "encoding/json"
"fmt" "net/http"
"log"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/constant" "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" "github.com/labstack/echo/v4"
) )
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
// Returns nil when the middleware didn't run (should not happen on authed routes).
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok {
return v
}
return nil
}
// CreateTransaction 创建交易 // CreateTransaction 创建交易
// @Summary Create transaction
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
// @Tags Payment
// @Accept json
// @Accept x-www-form-urlencoded
// @Produce json
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
// @Param order_id formData string false "Merchant order ID"
// @Param currency formData string false "Fiat currency (e.g. cny)"
// @Param token formData string false "Crypto token (e.g. usdt)"
// @Param network formData string false "Network (e.g. tron)"
// @Param amount formData number false "Amount"
// @Param notify_url formData string false "Callback URL"
// @Param signature formData string false "MD5 signature"
// @Param redirect_url formData string false "Redirect URL"
// @Param name formData string false "Order name"
// @Param payment_type formData string false "Payment type"
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/order/create-transaction [post]
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest) req := new(request.CreateTransactionRequest)
if err = ctx.Bind(req); err != nil { if err = ctx.Bind(req); err != nil {
@@ -20,7 +51,7 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
if err = c.ValidateStruct(ctx, req); err != nil { if err = c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
resp, err := service.CreateTransaction(req) resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -28,6 +59,15 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
} }
// SwitchNetwork 切换支付网络,创建或返回子订单 // SwitchNetwork 切换支付网络,创建或返回子订单
// @Summary Switch payment network
// @Description Switch to a different payment network, creating or returning a sub-order
// @Tags Payment
// @Accept json
// @Produce json
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /pay/switch-network [post]
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
req := new(request.SwitchNetworkRequest) req := new(request.SwitchNetworkRequest)
if err = ctx.Bind(req); err != nil { if err = ctx.Bind(req); err != nil {
@@ -46,33 +86,60 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
fmt.Printf("switch network response: \n%s", string(jsonBytes)) log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
return c.SucJson(ctx, resp) return c.SucJson(ctx, resp)
} }
// CreateTransactionAndRedirect creates a transaction and redirects to
// the checkout counter. The route accepts BOTH GET (query string) and
// POST (form) per the legacy EPAY protocol; swagger documents POST as
// the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @Tags Payment
// @Accept x-www-form-urlencoded
// @Produce html
// @Param pid query integer false "API key PID (GET query)"
// @Param money query number false "Amount (fiat, GET query)"
// @Param out_trade_no query string false "Merchant order ID (GET query)"
// @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (GET query)"
// @Param type query string false "Payment type (e.g. alipay, GET query)"
// @Param sign query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID"
// @Param money formData number true "Amount (fiat)"
// @Param out_trade_no formData string true "Merchant order ID"
// @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name"
// @Param type formData string false "Payment type (e.g. alipay)"
// @Param sign formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter"
// @Failure 400 {object} response.ApiResponse
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest) req := new(request.CreateTransactionRequest)
if err = ctx.Bind(req); err != nil { if err = ctx.Bind(req); err != nil {
log.Println("bind request error:", err) log.Sugar.Errorf("bind request error: %v", err)
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
if err = c.ValidateStruct(ctx, req); err != nil { if err = c.ValidateStruct(ctx, req); err != nil {
log.Println("validate request error:", err) log.Sugar.Errorf("validate request error: %v", err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
resp, err := service.CreateTransaction(req) resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
if err != nil { if err != nil {
log.Println("create transaction error:", err) log.Sugar.Errorf("create transaction error: %v", err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
fmt.Printf("create transaction response: %+v\n", resp) log.Sugar.Debugf("create transaction response: %+v", resp)
tradeID := resp.TradeId tradeID := resp.TradeId
return ctx.Redirect(http.StatusFound, "/pay/checkout-counter/"+tradeID)
ctx.Redirect(302, "/pay/checkout-counter/"+tradeID)
return nil
} }
+22 -24
View File
@@ -1,48 +1,46 @@
package comm package comm
import ( import (
"encoding/json"
"fmt"
"html/template"
"net/http" "net/http"
"path/filepath"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// CheckoutCounter 收银台 // CheckoutCounter 收银台
// @Summary Checkout counter page
// @Description Render the payment checkout counter JSON response for a given trade
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.CheckoutCounterResponse
// @Router /pay/checkout-counter-resp/{trade_id} [get]
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id") tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId) resp, err := service.GetCheckoutCounterByTradeId(tradeId)
if err != nil { if err != nil {
if err == service.ErrOrder { if err == service.ErrOrder {
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html")) // Unknown trade id: render the page with empty payload
if err != nil { // (client side shows a friendly "order not found" screen).
return ctx.String(http.StatusOK, err.Error())
}
emptyResp := response.CheckoutCounterResponse{} emptyResp := response.CheckoutCounterResponse{}
return tmpl.Execute(ctx.Response(), emptyResp) return c.SucJson(ctx, emptyResp)
} }
return ctx.String(http.StatusOK, err.Error()) return ctx.String(http.StatusInternalServerError, err.Error())
}
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
} }
jsonByte, err := json.MarshalIndent(resp, "", " ") return c.SucJson(ctx, resp)
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
fmt.Printf("%v\n", string(jsonByte))
return tmpl.Execute(ctx.Response(), resp)
} }
// CheckStatus 支付状态检测 // CheckStatus 支付状态检测
// @Summary Check payment status
// @Description Check the payment status of an order by trade ID
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /pay/check-status/{trade_id} [get]
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) { func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id") tradeId := ctx.Param("trade_id")
order, err := service.GetOrderInfoByTradeId(tradeId) order, err := service.GetOrderInfoByTradeId(tradeId)
@@ -3,28 +3,29 @@ package comm
import ( import (
"sort" "sort"
"strconv" "strconv"
"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"
) )
type addSupportedAssetRequest struct { // GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台
Network string `json:"network" validate:"required"` // 的 chains + chain_tokens + wallet_address 三张表的实时查询,admin
Token string `json:"token" validate:"required"` // 启用/禁用链或代币后,这个接口在下次调用时就会反映新状态(无缓存)。
Status int64 `json:"status" validate:"required|in:1,2"` // 仅返回:链 enabled=true、代币 enabled=true、且该链上至少有一个可用
} // 钱包地址的组合。
// @Summary List supported (network, tokens) groupings
type updateSupportedAssetRequest struct { // @Description Public endpoint, Auth required in /admin/api/v1/supported-assets
Network string `json:"network" validate:"required"` // @Tags Supported Assets
Token string `json:"token" validate:"required"` // @Produce json
Status int64 `json:"status" validate:"required|in:1,2"` // @Success 200 {object} response.ApiResponse{data=response.SupportedAssetsResponse}
} // @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets [get]
// GetSupportedAssets 对外公开可用链与 token 列表(无需鉴权,仅返回已启用项)。 // @Router /admin/api/v1/supported-assets [get]
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error { func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
list, err := data.ListEnabledSupportedAssets() chains, err := data.ListEnabledChains()
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -33,32 +34,42 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
networkSet := make(map[string]struct{}) // A chain only qualifies if it has at least one wallet available —
// without a recipient address, payments on that chain can't be
// processed even if the chain + token config says "enabled".
networkHasWallet := make(map[string]struct{})
for _, w := range wallets { for _, w := range wallets {
networkSet[w.Network] = struct{}{} networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
} }
grouped := make(map[string][]string) supports := make([]response.NetworkTokenSupport, 0, len(chains))
for _, item := range list { for _, ch := range chains {
if _, ok := networkSet[item.Network]; !ok { network := strings.ToLower(ch.Network)
if _, ok := networkHasWallet[network]; !ok {
continue continue
} }
grouped[item.Network] = append(grouped[item.Network], item.Token) tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
return c.FailJson(ctx, err)
} }
if len(tokens) == 0 {
networks := make([]string, 0, len(grouped)) continue
for network := range grouped {
networks = append(networks, network)
} }
sort.Strings(networks) symbols := make([]string, 0, len(tokens))
for _, t := range tokens {
supports := make([]response.NetworkTokenSupport, 0, len(networks)) sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
for _, network := range networks { if sym == "" {
tokens := grouped[network] continue
sort.Strings(tokens) }
symbols = append(symbols, sym)
}
if len(symbols) == 0 {
continue
}
sort.Strings(symbols)
supports = append(supports, response.NetworkTokenSupport{ supports = append(supports, response.NetworkTokenSupport{
Network: network, Network: network,
Tokens: tokens, Tokens: symbols,
}) })
} }
@@ -66,9 +77,17 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
} }
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。 // ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
// @Summary List supported asset records
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network.
// @Tags Supported Assets
// @Produce json
// @Param network query string false "Network filter (e.g. tron, ethereum)"
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets/records [get]
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error { func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
network := ctx.QueryParam("network") network := ctx.QueryParam("network")
list, err := data.ListSupportedAssets(network) list, err := data.ListEnabledChainTokens(network)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
@@ -76,72 +95,25 @@ func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
} }
// GetSupportedAsset 查询单条支持项(无需鉴权)。 // GetSupportedAsset 查询单条支持项(无需鉴权)。
// @Summary Get a supported asset
// @Description Public endpoint. Returns one chain_token row by ID. Returns 404 if the token is disabled.
// @Tags Supported Assets
// @Produce json
// @Param id path int true "ChainToken ID"
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets/{id} [get]
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error { func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr) return c.FailJson(ctx, constant.ParamsMarshalErr)
} }
asset, err := data.GetSupportedAssetByID(id) token, err := data.GetChainTokenByID(id)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
if asset.ID <= 0 { if token.ID <= 0 || !token.Enabled {
return c.FailJson(ctx, constant.SupportedAssetNotFound) return c.FailJson(ctx, constant.SupportedAssetNotFound)
} }
return c.SucJson(ctx, asset) return c.SucJson(ctx, token)
}
// AddSupportedAsset 新增支持项(鉴权)。
func (c *BaseCommController) AddSupportedAsset(ctx echo.Context) error {
req := new(addSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.AddSupportedAsset(req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// UpdateSupportedAsset 修改支持项(鉴权)。
func (c *BaseCommController) UpdateSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req := new(updateSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.UpdateSupportedAsset(id, req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// DeleteSupportedAsset 删除支持项(鉴权)。
func (c *BaseCommController) DeleteSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
asset, err := data.GetSupportedAssetByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if asset.ID <= 0 {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
if err := data.DeleteSupportedAssetByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
} }
-98
View File
@@ -1,98 +0,0 @@
package comm
import (
"strconv"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
type addWalletRequest struct {
Network string `json:"network" validate:"required"`
Address string `json:"address" validate:"required"`
}
type changeStatusRequest struct {
Status int `json:"status" validate:"required|in:1,2"`
}
// AddWallet 添加钱包地址
func (c *BaseCommController) AddWallet(ctx echo.Context) error {
req := new(addWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
wallet, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, wallet)
}
// ListWallets 获取钱包列表
func (c *BaseCommController) ListWallets(ctx echo.Context) error {
network := ctx.QueryParam("network")
var wallets []mdb.WalletAddress
var err error
if network != "" {
wallets, err = data.GetAllWalletAddressByNetwork(network)
} else {
wallets, err = data.GetAllWalletAddress()
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, wallets)
}
// GetWallet 获取单个钱包
func (c *BaseCommController) GetWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
wallet, err := data.GetWalletAddressById(id)
if err != nil {
return c.FailJson(ctx, err)
}
if wallet.ID <= 0 {
return c.FailJson(ctx, constant.NotAvailableWalletAddress)
}
return c.SucJson(ctx, wallet)
}
// ChangeWalletStatus 启用/禁用钱包
func (c *BaseCommController) ChangeWalletStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req := new(changeStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteWallet 删除钱包
func (c *BaseCommController) DeleteWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := data.DeleteWalletAddressById(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+6 -5
View File
@@ -1,4 +1,4 @@
module github.com/assimon/luuu module github.com/GMWalletApp/epusdt
go 1.25.0 go 1.25.0
@@ -8,6 +8,7 @@ require (
github.com/ethereum/go-ethereum v1.15.11 github.com/ethereum/go-ethereum v1.15.11
github.com/gagliardetto/solana-go v1.16.0 github.com/gagliardetto/solana-go v1.16.0
github.com/go-resty/resty/v2 v2.11.0 github.com/go-resty/resty/v2 v2.11.0
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/gookit/color v1.5.0 github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6 github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1 github.com/gookit/validate v1.3.1
@@ -23,6 +24,7 @@ require (
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
gopkg.in/telebot.v3 v3.0.0 gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.5.1 gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2 gorm.io/driver/postgres v1.5.2
@@ -99,12 +101,11 @@ require (
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.39.0 // indirect golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
+8 -8
View File
@@ -375,8 +375,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -397,8 +397,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -440,8 +440,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -450,8 +450,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+364
View File
@@ -0,0 +1,364 @@
// Package install provides a first-run setup REST API.
//
// When the .env config file is absent (or has install=true) the HTTP start
// command calls RunInstallServer, which listens on the same address the main
// server will eventually use (default :8000) and mounts two JSON endpoints
// under /install consumed by the frontend install UI:
//
// GET /install/defaults — default field values for the form
// POST /install — validate + write .env, then shut down
//
// The HTTP listen address is submitted as two separate fields (http_bind_addr
// and http_bind_port) and combined internally as "ADDR:PORT" before writing
// the http_listen key in .env. This makes the form easier for users who only
// want to change the port without touching the bind address.
//
// Once the .env is written the install server stops and normal bootstrap
// proceeds on the same port without a restart.
package install
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"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"
)
// DefaultInstallAddr is the listen address used by the install API.
// Matches the default http_listen so no extra port is needed.
const DefaultInstallAddr = ":8000"
// InstallRequest is the payload submitted by the install form.
// All fields are optional except AppURI; omitted fields fall back to InstallDefaults().
type InstallRequest struct {
// Application display name (default: epusdt)
AppName string `json:"app_name" form:"app_name" example:"epusdt"`
// Public base URL of the service, e.g. https://pay.example.com (required)
AppURI string `json:"app_uri" form:"app_uri" example:"https://pay.example.com"`
// Bind address for the HTTP server (default: 127.0.0.1)
HttpBindAddr string `json:"http_bind_addr" form:"http_bind_addr" example:"127.0.0.1"`
// Bind port for the HTTP server (default: 8000)
HttpBindPort int `json:"http_bind_port" form:"http_bind_port" example:"8000"`
// Runtime directory for SQLite DB and temp files (default: ./runtime)
RuntimeRootPath string `json:"runtime_root_path" form:"runtime_root_path" example:"./runtime"`
// Directory for application log files (default: ./logs)
LogSavePath string `json:"log_save_path" form:"log_save_path" example:"./logs"`
// Minutes before an unpaid order expires (default: 10)
OrderExpirationTime int `json:"order_expiration_time" form:"order_expiration_time" example:"10"`
// Maximum webhook retry attempts (default: 1)
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
}
// InstallDefaults returns sensible default values for the install form.
func InstallDefaults() InstallRequest {
return InstallRequest{
AppName: "epusdt",
AppURI: "",
HttpBindAddr: "127.0.0.1",
HttpBindPort: 8000,
RuntimeRootPath: "./runtime",
LogSavePath: "./logs",
OrderExpirationTime: 10,
OrderNoticeMaxRetry: 1,
}
}
// installHandler holds the per-invocation state shared between handlers.
type installHandler struct {
envFilePath string
done chan struct{}
}
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
return c.Redirect(http.StatusFound, "/install")
}
return next(c)
}
}
func resolveInstallWWWRoot() string {
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
return wwwRoot
}
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
h := &installHandler{
envFilePath: envFilePath,
done: make(chan struct{}),
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
// api routes for the install frontend
api := e.Group("/api")
api.GET("/install/defaults", h.GetDefaults)
api.POST("/install", h.Submit)
// Redirect browser visits on root to /install so first-run users land
// on the wizard directly. This must run before the static middleware,
// otherwise "/" is intercepted by the SPA index.html fallback.
e.Use(installRootRedirectMiddleware)
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,
}))
return e, h
}
// GetDefaults returns default values for the install form.
//
// @Summary Install — get default values
// @Description Returns sensible default field values for the first-run install form.
//
// Available only before the .env config file has been written.
// After installation completes this route is no longer served.
//
// @Tags Install
// @Produce json
// @Success 200 {object} InstallRequest "Default install values"
// @Router /api/install/defaults [get]
func (h *installHandler) GetDefaults(c echo.Context) error {
return c.JSON(http.StatusOK, InstallDefaults())
}
// Submit validates the install payload, writes the .env file, and signals
// the install server to shut down so the main bootstrap can proceed.
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
// the http_listen config key (e.g. 0.0.0.0:8000).
//
// @Summary Install — submit configuration
// @Description Validates the submitted configuration and writes the .env file.
//
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
// http_bind_port must be in the range 165535 if provided.
// app_uri is required. All other fields are optional and fall back to
// the defaults returned by GET /api/install/defaults.
// Sets install=false in the written .env, then shuts down the install
// server so that normal application bootstrap starts on the same port.
// After installation completes this route is no longer served.
//
// @Tags Install
// @Accept json
// @Produce json
// @Param body body InstallRequest true "Install configuration"
// @Success 200 {object} map[string]string "message"
// @Failure 400 {object} map[string]string "error"
// @Failure 500 {object} map[string]string "error"
// @Router /api/install [post]
func (h *installHandler) Submit(c echo.Context) error {
req := new(InstallRequest)
if err := c.Bind(req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
req.AppURI = strings.TrimSpace(req.AppURI)
if req.AppURI == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "app_uri is required"})
}
if req.HttpBindPort != 0 && (req.HttpBindPort < 1 || req.HttpBindPort > 65535) {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "http_bind_port must be between 1 and 65535"})
}
d := InstallDefaults()
if strings.TrimSpace(req.AppName) == "" {
req.AppName = d.AppName
}
if strings.TrimSpace(req.HttpBindAddr) == "" {
req.HttpBindAddr = d.HttpBindAddr
}
if req.HttpBindPort <= 0 {
req.HttpBindPort = d.HttpBindPort
}
if strings.TrimSpace(req.RuntimeRootPath) == "" {
req.RuntimeRootPath = d.RuntimeRootPath
}
if strings.TrimSpace(req.LogSavePath) == "" {
req.LogSavePath = d.LogSavePath
}
if req.OrderExpirationTime <= 0 {
req.OrderExpirationTime = d.OrderExpirationTime
}
if req.OrderNoticeMaxRetry < 0 {
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
}
if err := writeEnvFile(h.envFilePath, req); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
go func() { close(h.done) }()
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"})
}
// RunInstallServer starts the install REST API on listenAddr (default :8000)
// under the /install path and blocks until the .env file has been written.
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
func RunInstallServer(listenAddr, envFilePath string) {
if listenAddr == "" {
listenAddr = DefaultInstallAddr
}
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
// Build a human-readable URL for the console hint.
installHost := listenAddr
if strings.HasPrefix(installHost, ":") {
installHost = "localhost" + installHost
}
color.Green.Printf("[install] no config found — install API available at http://%s/install\n", installHost)
go func() {
<-h.done
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = e.Shutdown(ctx)
}()
if err := e.Start(listenAddr); err != nil && err != http.ErrServerClosed {
color.Red.Printf("[install] server error: %s\n", err)
os.Exit(1)
}
color.Green.Printf("[install] configuration saved to %s, starting…\n", envFilePath)
}
// formControlledKeys are keys whose values always come from the install form
// (or are set unconditionally by the template). Existing config values for
// these keys must NOT be preserved — the form submission takes precedence.
var formControlledKeys = map[string]bool{
"app_name": true,
"app_uri": true,
"http_listen": true,
"runtime_root_path": true,
"log_save_path": true,
"order_expiration_time": true,
"order_notice_max_retry": true,
"install": true,
}
// writeEnvFile renders and writes a minimal .env file.
// If the file already exists, values for keys that are NOT controlled by the
// install form are preserved from the existing file so that operator-specific
// settings (tg_bot_token, db_type, etc.) survive a re-install.
// Keys that the form controls (app_uri, http_listen, …) always use the
// submitted values.
func writeEnvFile(path string, r *InstallRequest) error {
// Collect existing non-empty key→value pairs for non-form keys.
existingValues := map[string]string{}
if data, err := os.ReadFile(path); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if idx := strings.IndexByte(line, '='); idx >= 0 {
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
if v != "" && !formControlledKeys[k] {
existingValues[k] = v
}
}
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
// Render the template into a buffer first.
var buf bytes.Buffer
if err := envTemplate.Execute(&buf, r); err != nil {
return fmt.Errorf("render env template: %w", err)
}
// For non-form keys that already had a value, substitute the existing value
// so the template default does not clobber operator configuration.
lines := strings.Split(buf.String(), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if idx := strings.IndexByte(trimmed, '='); idx >= 0 {
k := strings.TrimSpace(trimmed[:idx])
if existing, ok := existingValues[k]; ok {
lines[i] = k + "=" + existing
}
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open config file: %w", err)
}
defer f.Close()
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
return err
}
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
app_uri={{.AppURI}}
log_level=info
http_access_log=false
sql_debug=false
http_listen={{.HttpBindAddr}}:{{.HttpBindPort}}
static_path=/static
runtime_root_path={{.RuntimeRootPath}}
log_save_path={{.LogSavePath}}
log_max_size=32
log_max_age=7
max_backups=3
# supported values: postgres,mysql,sqlite
db_type=sqlite
# sqlite primary database config
sqlite_database_filename=
sqlite_table_prefix=
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# background scheduler config
queue_concurrency=10
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
order_expiration_time={{.OrderExpirationTime}}
order_notice_max_retry={{.OrderNoticeMaxRetry}}
api_rate_url=
# Set to true to re-run the install wizard on next startup.
install=false
`))
+224
View File
@@ -0,0 +1,224 @@
package install
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
func TestInstallDefaults(t *testing.T) {
d := InstallDefaults()
if d.AppName != "epusdt" {
t.Errorf("AppName = %q, want epusdt", d.AppName)
}
if d.HttpBindAddr != "127.0.0.1" {
t.Errorf("HttpBindAddr = %q, want 127.0.0.1", d.HttpBindAddr)
}
if d.HttpBindPort != 8000 {
t.Errorf("HttpBindPort = %d, want 8000", d.HttpBindPort)
}
if d.OrderExpirationTime != 10 {
t.Errorf("OrderExpirationTime = %d, want 10", d.OrderExpirationTime)
}
}
func TestWriteEnvFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
req := &InstallRequest{
AppName: "myapp",
AppURI: "http://1.2.3.4:8000",
HttpBindAddr: "0.0.0.0",
HttpBindPort: 9000,
RuntimeRootPath: "./runtime",
LogSavePath: "./logs",
OrderExpirationTime: 15,
OrderNoticeMaxRetry: 3,
}
if err := writeEnvFile(path, req); err != nil {
t.Fatalf("writeEnvFile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read env: %v", err)
}
content := string(data)
for _, want := range []string{
"app_name=myapp",
"app_uri=http://1.2.3.4:8000",
"http_listen=0.0.0.0:9000",
"order_expiration_time=15",
"order_notice_max_retry=3",
"db_type=sqlite",
"install=false",
} {
if !strings.Contains(content, want) {
t.Errorf("env file missing %q\ncontent:\n%s", want, content)
}
}
}
func TestInstallAPIDefaults(t *testing.T) {
h := &installHandler{done: make(chan struct{})}
e := echo.New()
e.GET("/install/defaults", h.GetDefaults)
req := httptest.NewRequest(http.MethodGet, "/install/defaults", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["app_name"] != "epusdt" {
t.Errorf("app_name = %v, want epusdt", body["app_name"])
}
if body["http_bind_addr"] != "127.0.0.1" {
t.Errorf("http_bind_addr = %v, want 127.0.0.1", body["http_bind_addr"])
}
if body["http_bind_port"] != float64(8000) {
t.Errorf("http_bind_port = %v, want 8000", body["http_bind_port"])
}
}
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/install" {
t.Fatalf("Location = %q, want /install", got)
}
}
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
const wantBody = "install-ui"
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/install", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
}
func TestInstallAPISubmit(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
// done channel should be closed after successful submit
select {
case <-h.done:
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
}
data, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("env file not written: %v", err)
}
content := string(data)
if !strings.Contains(content, "app_uri=http://10.0.0.1:8000") {
t.Errorf("env file missing app_uri; content:\n%s", content)
}
if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
t.Errorf("env file missing http_listen; content:\n%s", content)
}
}
func TestInstallAPISubmitMissingURI(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if _, err := os.Stat(envPath); err == nil {
t.Error("env file should not have been written for invalid request")
}
}
func TestInstallAPISubmitInvalidPort(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(envPath); err == nil {
t.Error("env file should not have been written for invalid port")
}
}
+56 -8
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,35 +20,83 @@ 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)
viper.Set("callback_retry_base_seconds", 1) viper.Set("callback_retry_base_seconds", 1)
viper.Set("queue_concurrency", 4) viper.Set("queue_concurrency", 4)
viper.Set("queue_poll_interval_ms", 50) viper.Set("queue_poll_interval_ms", 50)
viper.Set("api_auth_token", "test-token")
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"))
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db")) runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{}) mustMigrate(t, mainDB,
&mdb.Orders{},
&mdb.WalletAddress{},
&mdb.ApiKey{},
&mdb.Setting{},
&mdb.NotificationChannel{},
&mdb.Chain{},
&mdb.ChainToken{},
&mdb.RpcNode{},
&mdb.AdminUser{},
)
mustMigrate(t, runtimeDB, &mdb.TransactionLock{}) mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
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.
for _, network := range []string{
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
} {
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
}
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
// flows; the numeric PID 1001 row lets legacy tests that submit
// `pid=1001` still match.
mainDB.Create(&mdb.ApiKey{
Name: "test-default",
Pid: "test-token", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable,
})
mainDB.Create(&mdb.ApiKey{
Name: "test-pid-1001",
Pid: "1001", SecretKey: "test-token",
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()
} }
} }
+91 -1
View File
@@ -1,11 +1,101 @@
package main package main
import ( import (
"github.com/assimon/luuu/command" "embed"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color" "github.com/gookit/color"
) )
//go:embed all:www
var wwwDir embed.FS
func executableDir() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", err
}
return filepath.Dir(exePath), nil
}
func extractEmbeddedDir(src embed.FS, embeddedRoot, dstRoot string) error {
sub, err := fs.Sub(src, embeddedRoot)
if err != nil {
return err
}
return fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dstPath := filepath.Join(dstRoot, path)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
in, err := sub.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
func releaseStatic(fs embed.FS, target string) (string, error) {
baseDir, err := executableDir()
if err != nil {
return "", err
}
targetDir := filepath.Join(baseDir, target)
if err := os.RemoveAll(targetDir); err != nil {
return "", err
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return "", err
}
if err := extractEmbeddedDir(fs, target, targetDir); err != nil {
return "", err
}
return targetDir, nil
}
func main() { func main() {
wwwwPath, err := releaseStatic(wwwDir, "www")
if err != nil {
panic(err)
}
fmt.Println("www released to:", wwwwPath)
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
color.Error.Println("[Start Server Err!!!] ", err) color.Error.Println("[Start Server Err!!!] ", err)
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"net/http"
"strings"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
const (
// AdminUserIDKey is the echo.Context key holding the authenticated
// admin user's ID after successful JWT validation.
AdminUserIDKey = "admin_user_id"
// AdminUsernameKey mirrors the username for convenience.
AdminUsernameKey = "admin_username"
)
// CheckAdminJWT validates an Authorization: Bearer <jwt> header against
// the HS256 secret stored in system.jwt_secret. On success it injects
// the admin user ID and username into the echo context.
//
// Accepts both "Authorization: Bearer <jwt>" and a bare "<jwt>" for
// developer convenience (curl / postman). The token itself is always
// cryptographically validated — the relaxed header parsing doesn't
// widen the trust surface.
func CheckAdminJWT() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
if raw == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
}
if !strings.HasPrefix(raw, "Bearer ") {
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
}
token := strings.TrimSpace(raw[len("Bearer "):])
if token == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
}
claims, err := appjwt.Parse(token)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
}
ctx.Set(AdminUserIDKey, claims.AdminUserID)
ctx.Set(AdminUsernameKey, claims.Username)
return next(ctx)
}
}
}
+115 -10
View File
@@ -2,37 +2,142 @@ package middleware
import ( import (
"bytes" "bytes"
"io/ioutil" "crypto/subtle"
"io"
"net"
"net/url"
"strconv"
"strings"
"github.com/assimon/luuu/config" "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"
) )
// Context keys populated by CheckApiSign after successful verification.
// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id.
const (
ApiKeyIDKey = "api_key_id"
ApiKeyRowKey = "api_key_row"
)
// CheckApiSign validates the body signature against the secret_key of
// the api_keys row matching the submitted "pid" field. A single row is
// valid for all gateway flows — identification is always by pid.
//
// Flow:
// 1. Extract the pid from the request body.
// 2. Look up the enabled row by pid; if missing, return signature error.
// 3. Verify signature == MD5(sorted_params + secret_key).
// 4. Enforce IP whitelist (empty = allow any).
// 5. Bump call_count / last_used_at (best-effort).
// 6. Stash api_key_id + row in context and rewind the body.
func CheckApiSign() echo.MiddlewareFunc { func CheckApiSign() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
params, err := ioutil.ReadAll(ctx.Request().Body) params, err := io.ReadAll(ctx.Request().Body)
if err != nil { if err != nil {
return constant.SignatureErr return constant.SignatureErr
} }
// Rewind the body for downstream bindings regardless of outcome.
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(params))
m := make(map[string]interface{}) m := make(map[string]interface{})
err = json.Cjson.Unmarshal(params, &m) contentType := ctx.Request().Header.Get("Content-Type")
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
values, parseErr := url.ParseQuery(string(params))
if parseErr != nil {
return constant.SignatureErr
}
for k, vs := range values {
if len(vs) > 0 {
m[k] = vs[0]
}
}
} else {
if err = json.Cjson.Unmarshal(params, &m); err != nil {
return constant.SignatureErr
}
}
signature, ok := m["signature"] signature, ok := m["signature"]
if !ok { if !ok {
return constant.SignatureErr return constant.SignatureErr
} }
checkSignature, err := sign.Get(m, config.GetApiAuthToken()) identifier := extractPid(m)
if identifier == "" {
return constant.SignatureErr
}
row, err := data.GetEnabledApiKey(identifier)
if err != nil || row.ID == 0 {
return constant.SignatureErr
}
checkSignature, err := sign.Get(m, row.SecretKey)
if err != nil { if err != nil {
return constant.SignatureErr return constant.SignatureErr
} }
if checkSignature != signature { signatureStr, _ := signature.(string)
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 {
return constant.SignatureErr return constant.SignatureErr
} }
ctx.Request().Body = ioutil.NopCloser(bytes.NewBuffer(params))
if !IsIPWhitelisted(row.IpWhitelist, ctx.RealIP()) {
return constant.SignatureErr
}
_ = data.TouchApiKeyUsage(row.ID)
ctx.Set(ApiKeyIDKey, row.ID)
ctx.Set(ApiKeyRowKey, row)
return next(ctx) return next(ctx)
} }
} }
} }
// extractPid reads the "pid" field from the request body. JSON numbers
// unmarshal to float64 (EPAY merchants typically send numeric pid); we
// format with -1 precision to drop trailing zeros so "1000" matches
// regardless of whether the client sent a string or a number.
func extractPid(m map[string]interface{}) string {
v, ok := m["pid"]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
}
return ""
}
// IsIPWhitelisted checks the CSV IP list. Empty list = open.
// Entries may be single IPs or CIDR blocks.
func IsIPWhitelisted(csv, remote string) bool {
csv = strings.TrimSpace(csv)
if csv == "" {
return true
}
remoteIP := net.ParseIP(remote)
for _, raw := range strings.Split(csv, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
_, cidr, err := net.ParseCIDR(entry)
if err == nil && remoteIP != nil && cidr.Contains(remoteIP) {
return true
}
continue
}
if entry == remote {
return true
}
}
return false
}
-24
View File
@@ -1,24 +0,0 @@
package middleware
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
// CheckApiToken validates the Authorization header against the configured api_auth_token.
// Use this for management APIs (GET/POST) where body-based signature is not practical.
func CheckApiToken() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
token := ctx.Request().Header.Get("Authorization")
if token == "" {
token = ctx.QueryParam("api_token")
}
if token == "" || token != config.GetApiAuthToken() {
return constant.SignatureErr
}
return next(ctx)
}
}
}
+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"
+224 -34
View File
@@ -1,60 +1,250 @@
package dao package dao
import ( import (
"encoding/json"
"strconv"
"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"
) )
var once sync.Once var once sync.Once
// 自动建表 // MdbTableInit performs AutoMigrate for all primary DB tables and seeds
// the minimum static rows: chains, chain tokens, default settings, and
// a Telegram notification channel migrated from legacy settings keys.
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
// established. The install wizard (see install/installer.go) writes the
// .env file before bootstrap runs, so this function always finds the DB
// available.
func MdbTableInit() { func MdbTableInit() {
once.Do(func() { once.Do(func() {
if err := Mdb.AutoMigrate(&mdb.Orders{}); err != nil { migrations := []struct {
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err) name string
// panic(err) model interface{}
}{
{"Orders", &mdb.Orders{}},
{"WalletAddress", &mdb.WalletAddress{}},
{"AdminUser", &mdb.AdminUser{}},
{"ApiKey", &mdb.ApiKey{}},
{"Setting", &mdb.Setting{}},
{"NotificationChannel", &mdb.NotificationChannel{}},
{"Chain", &mdb.Chain{}},
{"ChainToken", &mdb.ChainToken{}},
{"RpcNode", &mdb.RpcNode{}},
}
for _, m := range migrations {
if err := Mdb.AutoMigrate(m.model); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
return return
} }
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
// panic(err)
return
}
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
return
} }
seedSupportedAssets() seedChains()
seedRpcNodes()
seedChainTokens()
seedDefaultSettings()
seedTelegramChannelFromSettings()
}) })
} }
func seedSupportedAssets() { // seedChains inserts the built-in networks as enabled rows. Uses
var cnt int64 // ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist.
if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil { func seedChains() {
color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err) defaults := []mdb.Chain{
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chains err=%s\n", err)
}
}
// seedChainTokens inserts default token configurations for each chain.
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
// re-runs are no-ops and admin edits to contract_address / decimals /
// min_amount persist across restarts.
func seedChainTokens() {
// Contract-address rows are matched by scanners via exact contract
// lookup. Native-token rows (empty contract_address) are matched by
// symbol — these act as admin toggles for the native payment path.
defaults := []mdb.ChainToken{
// TRON
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
// Ethereum
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
// Solana
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
// Polygon
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
// Plasma
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
}
}
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
// (network, url) so re-runs are idempotent and admin edits persist.
func seedRpcNodes() {
defaults := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
}
for _, d := range defaults {
var count int64
if err := Mdb.Model(&mdb.RpcNode{}).
Where("network = ? AND url = ?", d.Network, d.Url).
Count(&count).Error; err != nil {
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
continue
}
if count > 0 {
continue
}
if err := Mdb.Create(&d).Error; err != nil {
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
}
}
}
// seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() {
defaults := []mdb.Setting{
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
}
}
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
// Telegram config into a notification_channels row so the notify dispatcher
// can use it. Idempotent: no-op when any telegram channel already exists.
//
// Legacy keys read from settings:
//
// system.telegram_bot_token → TelegramConfig.BotToken
// system.telegram_chat_id → TelegramConfig.ChatID
// system.telegram_payment_notice_enabled → events.pay_success
// system.telegram_abnormal_notice_enabled → events.order_expired
func seedTelegramChannelFromSettings() {
var count int64
if err := Mdb.Model(&mdb.NotificationChannel{}).
Where("type = ?", mdb.NotificationTypeTelegram).
Count(&count).Error; err != nil {
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
return return
} }
if cnt > 0 { if count > 0 {
return return
} }
defaults := []mdb.SupportedAsset{ SyncTelegramChannelFromSettings()
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
} }
if err := Mdb.Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err) // SyncTelegramChannelFromSettings reads the four Telegram keys from the
// settings table and upserts the matching notification_channels row:
// - creates a new row when none exists yet
// - updates config+events on the first existing row when settings change
//
// This is called at startup (via seedTelegramChannelFromSettings) and by
// the settings controller whenever any Telegram key is changed at runtime.
func SyncTelegramChannelFromSettings() {
keys := []string{
"system.telegram_bot_token",
"system.telegram_chat_id",
"system.telegram_payment_notice_enabled",
"system.telegram_abnormal_notice_enabled",
}
var rows []mdb.Setting
if err := Mdb.Where("key IN ?", keys).Find(&rows).Error; err != nil {
color.Red.Printf("[store_db] sync telegram channel: read settings err=%s\n", err)
return
}
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.Key] = r.Value
}
botToken := strings.TrimSpace(m["system.telegram_bot_token"])
chatIDStr := strings.TrimSpace(m["system.telegram_chat_id"])
if botToken == "" || chatIDStr == "" {
return // No telegram config to sync.
}
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
if err != nil {
color.Red.Printf("[store_db] sync telegram channel: invalid chat_id=%q\n", chatIDStr)
return
}
paymentEnabled, _ := strconv.ParseBool(m["system.telegram_payment_notice_enabled"])
abnormalEnabled, _ := strconv.ParseBool(m["system.telegram_abnormal_notice_enabled"])
configJSON, _ := json.Marshal(map[string]interface{}{
"bot_token": botToken,
"chat_id": chatID,
"proxy": "",
})
eventsJSON, _ := json.Marshal(map[string]bool{
mdb.NotifyEventPaySuccess: paymentEnabled,
mdb.NotifyEventOrderExpired: abnormalEnabled,
mdb.NotifyEventDailyReport: false,
})
// Try to update an existing row first.
// Use Find (not First) to avoid GORM printing a spurious
// "record not found" log when the table is empty on first boot.
var existing mdb.NotificationChannel
Mdb.Model(&mdb.NotificationChannel{}).
Where("type = ?", mdb.NotificationTypeTelegram).
Order("id ASC").Limit(1).Find(&existing)
if existing.ID != 0 {
// Row exists — patch config and events.
if err2 := Mdb.Model(&existing).Updates(map[string]interface{}{
"config": string(configJSON),
"events": string(eventsJSON),
}).Error; err2 != nil {
color.Red.Printf("[store_db] sync telegram channel update err=%s\n", err2)
}
return
}
// No row yet — create one.
ch := mdb.NotificationChannel{
Type: mdb.NotificationTypeTelegram,
Name: "Telegram",
Config: string(configJSON),
Events: string(eventsJSON),
Enabled: true,
}
if err := Mdb.Create(&ch).Error; err != nil {
color.Red.Printf("[store_db] sync telegram channel create err=%s\n", err)
} else {
color.Green.Printf("[store_db] created telegram notification_channel from settings (id=%d)\n", ch.ID)
} }
} }
+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"
+304
View File
@@ -0,0 +1,304 @@
package data
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"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
// can print it to the console. Idempotent — subsequent calls return
// ("", false, nil).
func EnsureDefaultAdmin() (password string, created bool, err error) {
var count int64
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
return "", false, err
}
if count > 0 {
return "", false, nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return "", false, err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
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 {
b := make([]byte, 16)
_, _ = rand.Read(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.
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// VerifyPassword compares a plaintext password against a bcrypt hash.
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// GetAdminUserByUsername returns the row for a username (case-insensitive).
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
u := new(mdb.AdminUser)
err := dao.Mdb.Model(u).
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
Limit(1).Find(u).Error
return u, err
}
// GetAdminUserByID returns the row for an ID.
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
u := new(mdb.AdminUser)
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
return u, err
}
// UpdateAdminUserPassword rehashes and persists a new password.
func UpdateAdminUserPassword(id uint64, newPlain string) error {
hash, err := HashPassword(newPlain)
if err != nil {
return err
}
changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}).
Where("id = ?", id).
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.
func TouchAdminUserLastLogin(id uint64) error {
return dao.Mdb.Model(&mdb.AdminUser{}).
Where("id = ?", id).
Update("last_login_at", carbon.Now().StdTime()).Error
}
+137
View File
@@ -0,0 +1,137 @@
package data
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"gorm.io/gorm"
)
// GetEnabledApiKey looks up an enabled merchant credential row by PID.
// A single row is valid for both EPAY and GMPAY flows; the route
// determines the flow. Returns (row, nil) on success; (zero row, nil)
// when no match; error only on true DB failure.
func GetEnabledApiKey(pid string) (*mdb.ApiKey, error) {
row := new(mdb.ApiKey)
err := dao.Mdb.Model(row).
Where("pid = ?", strings.TrimSpace(pid)).
Where("status = ?", mdb.ApiKeyStatusEnable).
Limit(1).Find(row).Error
return row, err
}
// GetApiKeyByID fetches a row by primary key (including disabled).
func GetApiKeyByID(id uint64) (*mdb.ApiKey, error) {
row := new(mdb.ApiKey)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// ListApiKeys returns all rows ordered by ID descending.
func ListApiKeys() ([]mdb.ApiKey, error) {
var rows []mdb.ApiKey
err := dao.Mdb.Model(&mdb.ApiKey{}).Order("id DESC").Find(&rows).Error
return rows, err
}
// CreateApiKey persists a new row.
func CreateApiKey(row *mdb.ApiKey) error {
return dao.Mdb.Create(row).Error
}
// UpdateApiKeyFields updates a whitelist of mutable fields on a row.
func UpdateApiKeyFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.ApiKey{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteApiKeyByID removes (soft-deletes) a row.
func DeleteApiKeyByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ApiKey{}).Error
}
// TouchApiKeyUsage increments call_count and stamps last_used_at.
// Called from the signature middleware after a successful verify.
func TouchApiKeyUsage(id uint64) error {
return dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"call_count": gorm.Expr("call_count + 1"),
"last_used_at": carbon.Now().StdTime(),
}).Error
}
// basePid is the starting PID value for api_keys.
const basePid = 1000
// NextPid returns the next available numeric PID for api_keys. Scans
// ALL rows (including soft-deleted) to avoid reusing a PID whose row
// is still in the table with a non-null deleted_at — the unique index
// on pid doesn't know about soft deletes, so reuse would trigger a
// DB-level unique violation on Create.
func NextPid() (int, error) {
var rows []mdb.ApiKey
err := dao.Mdb.Unscoped().Model(&mdb.ApiKey{}).Select("pid").Find(&rows).Error
if err != nil {
return basePid, err
}
maxPid := basePid - 1
for _, r := range rows {
if pid, err := strconv.Atoi(strings.TrimSpace(r.Pid)); err == nil && pid > maxPid {
maxPid = pid
}
}
return maxPid + 1, nil
}
// SeededApiKey carries the details of a newly created default key so
// the caller can print them to the console (secret is never surfaced
// again via the admin API — it has to be viewed via /api-keys/:id/secret
// after login).
type SeededApiKey struct {
Pid string
Name string
SecretKey string
}
// EnsureDefaultApiKey seeds ONE universal API key on a fresh install.
// The seeded key is valid for all three gateway flows — the admin can
// create additional keys later, each also universal. Idempotent: if
// any api_keys row already exists, returns nil without seeding.
func EnsureDefaultApiKey() (*SeededApiKey, error) {
var count int64
if err := dao.Mdb.Model(&mdb.ApiKey{}).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, nil
}
secret := generateHex(32)
row := &mdb.ApiKey{
Name: "default",
Pid: strconv.Itoa(basePid),
SecretKey: secret,
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
return nil, err
}
return &SeededApiKey{
Pid: row.Pid,
Name: row.Name,
SecretKey: secret,
}, nil
}
func generateHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+54
View File
@@ -0,0 +1,54 @@
package data
import (
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListChains returns all chain rows ordered by id.
func ListChains() ([]mdb.Chain, error) {
var rows []mdb.Chain
err := dao.Mdb.Model(&mdb.Chain{}).Order("id ASC").Find(&rows).Error
return rows, err
}
// ListEnabledChains returns only rows with Enabled=true.
func ListEnabledChains() ([]mdb.Chain, error) {
var rows []mdb.Chain
err := dao.Mdb.Model(&mdb.Chain{}).
Where("enabled = ?", true).
Order("id ASC").Find(&rows).Error
return rows, err
}
// GetChainByNetwork fetches one row by network key.
func GetChainByNetwork(network string) (*mdb.Chain, error) {
row := new(mdb.Chain)
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Limit(1).Find(row).Error
return row, err
}
// IsChainEnabled returns whether the row exists and Enabled=true. A
// missing row is treated as disabled — scanners must be able to
// short-circuit even before the admin creates the chain entry.
func IsChainEnabled(network string) bool {
row, err := GetChainByNetwork(network)
if err != nil || row.ID == 0 {
return false
}
return row.Enabled
}
// UpdateChainFields patches mutable columns.
func UpdateChainFields(network string, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.Chain{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Updates(fields).Error
}
+125
View File
@@ -0,0 +1,125 @@
package data
import (
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
// An empty network returns all enabled tokens across all networks.
func ListEnabledChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{}).Where("enabled = ?", true)
if n := strings.ToLower(strings.TrimSpace(network)); n != "" {
tx = tx.Where("network = ?", n)
}
err := tx.Order("network ASC, id ASC").Find(&rows).Error
return rows, err
}
// ListChainTokens returns rows optionally filtered by network.
func ListChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{})
if network != "" {
tx = tx.Where("network = ?", strings.ToLower(network))
}
err := tx.Order("id ASC").Find(&rows).Error
return rows, err
}
// ListEnabledChainTokensByNetwork returns the enabled tokens for a network.
// Scanners call this to learn which contract addresses to subscribe to.
func ListEnabledChainTokensByNetwork(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
err := dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ?", strings.ToLower(network)).
Where("enabled = ?", true).
Find(&rows).Error
return rows, err
}
// GetEnabledChainTokenByContract finds one enabled token by (network,
// contract_address). Used by EVM/TRON scanners to resolve the symbol
// and decimals of an incoming transfer. Returns (zero row, nil) if no
// match. The contract address match is case-insensitive.
func GetEnabledChainTokenByContract(network, contractAddress string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
addr := strings.TrimSpace(contractAddress)
if addr == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("LOWER(contract_address) = ?", strings.ToLower(addr)).
Limit(1).Find(row).Error
return row, err
}
// GetEnabledChainTokenBySymbol finds one enabled token by (network, symbol).
// Used by Solana native-SOL path where there's no contract address.
func GetEnabledChainTokenBySymbol(network, symbol string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
sym := strings.TrimSpace(symbol)
if sym == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("UPPER(symbol) = ?", strings.ToUpper(sym)).
Limit(1).Find(row).Error
return row, err
}
// GetChainTokenByID fetches by primary key.
func GetChainTokenByID(id uint64) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateChainToken inserts a row.
// If a soft-deleted row with the same (network, symbol) exists it is
// restored with the incoming field values rather than creating a duplicate
// that would violate the unique index.
func CreateChainToken(row *mdb.ChainToken) error {
deleted := new(mdb.ChainToken)
err := dao.Mdb.Unscoped().
Where("network = ? AND symbol = ? AND deleted_at IS NOT NULL", row.Network, row.Symbol).
Limit(1).Find(deleted).Error
if err != nil {
return err
}
if deleted.ID > 0 {
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
"deleted_at": nil,
"contract_address": row.ContractAddress,
"decimals": row.Decimals,
"enabled": row.Enabled,
"min_amount": row.MinAmount,
}).Error
if err != nil {
return err
}
row.ID = deleted.ID
return nil
}
return dao.Mdb.Create(row).Error
}
// UpdateChainTokenFields patches mutable columns.
func UpdateChainTokenFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.ChainToken{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteChainTokenByID soft-deletes the row.
func DeleteChainTokenByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ChainToken{}).Error
}
@@ -0,0 +1,81 @@
package data
import (
"encoding/json"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListNotificationChannels returns all rows, optionally filtered by type.
func ListNotificationChannels(channelType string) ([]mdb.NotificationChannel, error) {
var rows []mdb.NotificationChannel
tx := dao.Mdb.Model(&mdb.NotificationChannel{})
if channelType != "" {
tx = tx.Where("type = ?", strings.ToLower(channelType))
}
err := tx.Order("id DESC").Find(&rows).Error
return rows, err
}
// ListEnabledChannelsByEvent returns enabled rows whose Events JSON has
// the named event key set to true. Done in Go (rather than JSON_EXTRACT)
// because the runtime DB is DB-agnostic (MySQL/Postgres/SQLite).
func ListEnabledChannelsByEvent(event string) ([]mdb.NotificationChannel, error) {
var rows []mdb.NotificationChannel
if err := dao.Mdb.Model(&mdb.NotificationChannel{}).
Where("enabled = ?", true).
Find(&rows).Error; err != nil {
return nil, err
}
out := rows[:0]
for _, r := range rows {
events := map[string]bool{}
if r.Events != "" {
_ = json.Unmarshal([]byte(r.Events), &events)
}
if events[event] {
out = append(out, r)
}
}
return out, nil
}
// GetFirstEnabledTelegramChannel returns the oldest enabled telegram
// row, or nil ID if none. Telegram bot startup uses this as the primary
// instance backing /start and command handlers.
func GetFirstEnabledTelegramChannel() (*mdb.NotificationChannel, error) {
row := new(mdb.NotificationChannel)
err := dao.Mdb.Model(row).
Where("type = ?", mdb.NotificationTypeTelegram).
Where("enabled = ?", true).
Order("id ASC").
Limit(1).Find(row).Error
return row, err
}
// GetNotificationChannelByID fetches by PK.
func GetNotificationChannelByID(id uint64) (*mdb.NotificationChannel, error) {
row := new(mdb.NotificationChannel)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateNotificationChannel inserts a new row.
func CreateNotificationChannel(row *mdb.NotificationChannel) error {
return dao.Mdb.Create(row).Error
}
// UpdateNotificationChannelFields patches mutable columns.
func UpdateNotificationChannelFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.NotificationChannel{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteNotificationChannelByID soft-deletes a row.
func DeleteNotificationChannelByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.NotificationChannel{}).Error
}
+151
View File
@@ -0,0 +1,151 @@
package data
import (
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
// OrderListFilter bundles every filter supported by the admin orders
// list page. Zero values are ignored so callers can pass only what the
// user actually filtered on.
type OrderListFilter struct {
Status int
Network string
Token string
Address string
Keyword string // matches trade_id / order_id / block_transaction_id
StartAt *time.Time
EndAt *time.Time
Page int
PageSize int
// ParentOnly restricts the result to top-level orders only
// (parent_trade_id = ''). Sub-orders are excluded from the listing.
ParentOnly bool
}
// ListOrders returns a paginated order slice plus the total count under
// the same filter (for the UI pagination bar).
func ListOrders(f OrderListFilter) ([]mdb.Orders, int64, error) {
tx := buildOrderListQuery(f)
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
page := f.Page
if page < 1 {
page = 1
}
size := f.PageSize
if size < 1 {
size = 20
}
if size > 200 {
size = 200
}
var rows []mdb.Orders
err := tx.Order("id DESC").
Offset((page - 1) * size).Limit(size).
Find(&rows).Error
return rows, total, err
}
func buildOrderListQuery(f OrderListFilter) *gorm.DB {
tx := dao.Mdb.Model(&mdb.Orders{})
if f.ParentOnly {
tx = tx.Where("parent_trade_id = ?", "")
}
if f.Status > 0 {
tx = tx.Where("status = ?", f.Status)
}
if f.Network != "" {
tx = tx.Where("network = ?", strings.ToLower(f.Network))
}
if f.Token != "" {
tx = tx.Where("token = ?", strings.ToUpper(f.Token))
}
if f.Address != "" {
tx = tx.Where("receive_address = ?", f.Address)
}
if f.Keyword != "" {
kw := "%" + strings.TrimSpace(f.Keyword) + "%"
tx = tx.Where("trade_id LIKE ? OR order_id LIKE ? OR block_transaction_id LIKE ?", kw, kw, kw)
}
if f.StartAt != nil {
tx = tx.Where("created_at >= ?", *f.StartAt)
}
if f.EndAt != nil {
tx = tx.Where("created_at <= ?", *f.EndAt)
}
return tx
}
// CountOrdersByStatus returns how many orders exist in each status.
// Used by the dashboard overview card.
func CountOrdersByStatus() (map[int]int64, error) {
type row struct {
Status int
Total int64
}
var rows []row
err := dao.Mdb.Model(&mdb.Orders{}).
Select("status, COUNT(*) AS total").
Group("status").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[int]int64{}
for _, r := range rows {
out[r.Status] = r.Total
}
return out, nil
}
// CloseOrderManually transitions a pending order to expired. Only
// touches rows currently in StatusWaitPay so idempotent / safe.
func CloseOrderManually(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitPay).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
// ReopenOrderCallback flips callback_confirm back to NO so the mq
// worker picks it up on the next tick. Used by "resend callback".
func ReopenOrderCallback(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusPaySuccess).
Updates(map[string]interface{}{
"callback_confirm": mdb.CallBackConfirmNo,
"callback_num": 0,
})
return result.RowsAffected > 0, result.Error
}
// CountOrdersByAddress returns order counts grouped by receive_address.
// The admin wallet list annotates each wallet row with this number.
func CountOrdersByAddress() (map[string]int64, error) {
type row struct {
Address string `gorm:"column:receive_address"`
Total int64
}
var rows []row
err := dao.Mdb.Model(&mdb.Orders{}).
Select("receive_address, COUNT(*) AS total").
Group("receive_address").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, r := range rows {
out[r.Address] = r.Total
}
return out, nil
}
+90 -22
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"
@@ -28,10 +28,31 @@ func normalizeLockAmount(amount float64) (int64, string) {
return value.Shift(2).IntPart(), value.StringFixed(2) return value.Shift(2).IntPart(), value.StringFixed(2)
} }
func normalizeLockNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}
func normalizeLockAddress(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeLockNetwork(network)) {
return strings.ToLower(address)
}
return address
}
func normalizeLockToken(token string) string { func normalizeLockToken(token string) string {
return strings.ToUpper(strings.TrimSpace(token)) return strings.ToUpper(strings.TrimSpace(token))
} }
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
if isEVMNetwork(network) {
return tx.Where("lower(address) = ?", address)
}
return tx.Where("address = ?", address)
}
// GetOrderInfoByOrderId fetches an order by merchant order id. // GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
@@ -142,22 +163,38 @@ func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Ord
return orders, err return orders, err
} }
// MarkParentOrderSuccess updates the parent order with the sub-order's payment details. // MarkParentOrderSuccess marks the parent order as paid and records which sub-order
// Token and network are NOT overwritten — the parent keeps its original values. // settled it. Only the status, callback_confirm, and pay_by_sub_id fields are updated;
// the parent's own block_transaction_id, actual_amount, and receive_address are
// intentionally left unchanged because the parent was not directly paid.
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) { func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}). return MarkParentOrderSuccessWithTransaction(dao.Mdb, parentTradeId, sub)
}
// MarkParentOrderSuccessWithTransaction is the transactional variant of
// MarkParentOrderSuccess.
func MarkParentOrderSuccessWithTransaction(tx *gorm.DB, parentTradeId string, sub *mdb.Orders) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", parentTradeId). Where("trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay). Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": mdb.StatusPaySuccess, "status": mdb.StatusPaySuccess,
"block_transaction_id": sub.BlockTransactionId,
"callback_confirm": mdb.CallBackConfirmNo, "callback_confirm": mdb.CallBackConfirmNo,
"actual_amount": sub.ActualAmount, "pay_by_sub_id": sub.ID,
"receive_address": sub.ReceiveAddress,
}) })
return result.RowsAffected > 0, result.Error return result.RowsAffected > 0, result.Error
} }
// ExpireSiblingSubOrdersWithTransaction expires all waiting sibling
// sub-orders under the same parent in one statement.
func ExpireSiblingSubOrdersWithTransaction(tx *gorm.DB, parentTradeId string, excludeTradeId string) error {
return tx.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("trade_id != ?", excludeTradeId).
Where("status = ?", mdb.StatusWaitPay).
Update("status", mdb.StatusExpired).Error
}
// MarkOrderSelected sets is_selected=true for the given trade_id. // MarkOrderSelected sets is_selected=true for the given trade_id.
func MarkOrderSelected(tradeId string) error { func MarkOrderSelected(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}). return dao.Mdb.Model(&mdb.Orders{}).
@@ -191,6 +228,32 @@ func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
return orders, err return orders, err
} }
// GetAllSubOrders returns all sub-orders (any status) under a parent order,
// ordered by creation time ascending.
func GetAllSubOrders(parentTradeId string) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Order("created_at ASC").
Find(&orders).Error
return orders, err
}
// GetSubOrdersByParentTradeIds batch-fetches all sub-orders (any status)
// whose parent_trade_id is in the given set. Ordered by parent_trade_id
// then created_at ASC so callers can build a grouped map in one pass.
func GetSubOrdersByParentTradeIds(parentTradeIds []string) ([]mdb.Orders, error) {
if len(parentTradeIds) == 0 {
return nil, nil
}
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id IN ?", parentTradeIds).
Order("parent_trade_id ASC, created_at ASC").
Find(&orders).Error
return orders, err
}
// ExpireOrderByTradeId marks a single order as expired if still waiting. // ExpireOrderByTradeId marks a single order as expired if still waiting.
func ExpireOrderByTradeId(tradeId string) error { func ExpireOrderByTradeId(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}). return dao.Mdb.Model(&mdb.Orders{}).
@@ -201,16 +264,17 @@ func ExpireOrderByTradeId(tradeId string) error {
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount. // GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) { func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount) scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("network = ?", network). Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)). Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount). Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()). Where("expires_at > ?", time.Now())
Limit(1). query = applyLockAddressFilter(query, network, address)
Find(&lock).Error err := query.Limit(1).Find(&lock).Error
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -222,6 +286,8 @@ func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string,
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration. // LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error { func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, amountText := normalizeLockAmount(amount) scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token) normalizedToken := normalizeLockToken(token)
now := time.Now() now := time.Now()
@@ -236,12 +302,12 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
} }
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("network = ?", network). expiredQuery := tx.Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizedToken). Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount). Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now). Where("expires_at <= ?", now)
Delete(&mdb.TransactionLock{}).Error; err != nil { expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
return err return err
} }
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil { if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
@@ -261,13 +327,15 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
// UnLockTransaction releases the reservation for network+address+token+amount. // UnLockTransaction releases the reservation for network+address+token+amount.
func UnLockTransaction(network string, address string, token string, amount float64) error { func UnLockTransaction(network string, address string, token string, amount float64) error {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount) scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB. query := dao.RuntimeDB.
Where("network = ?", network). Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)). Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount). Where("amount_scaled = ?", scaledAmount)
Delete(&mdb.TransactionLock{}).Error query = applyLockAddressFilter(query, network, address)
return query.Delete(&mdb.TransactionLock{}).Error
} }
func UnLockTransactionByTradeId(tradeID string) error { func UnLockTransactionByTradeId(tradeID string) error {
+319
View File
@@ -0,0 +1,319 @@
package data
import (
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// DailyStat is one bucket of day-level aggregation used by the
// dashboard trend charts.
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
// "YYYY-MM-DD HH:00" for hourly buckets.
type DailyStat struct {
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
OrderCount int64 `json:"order_count"`
SuccessCount int64 `json:"success_count"`
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
}
// AddressDailyStat buckets actual_amount by (day, receive_address) for
// the stacked asset-trend chart.
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
type AddressDailyStat struct {
Day string `json:"day"`
Address string `json:"address"`
ActualAmount float64 `json:"actual_amount"`
}
// DailyOrderStats returns per-day counts/amounts within [start, end],
// zero-filling any days in the range that have no orders.
// Only orders in StatusPaySuccess contribute to Amount sums — that's
// what the user means by "流水". Pending orders count toward OrderCount
// to keep成交率 computable.
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`DATE(created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillDailyStats(start, end, rows), nil
}
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
// zero-filling any hours that have no orders.
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillHourlyStats(start, end, rows), nil
}
// fillDailyStats generates a zero-filled slice covering every calendar
// day in [start, end]. Days present in rows overwrite the zero entries.
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
byDay := make(map[string]DailyStat, len(rows))
for _, r := range rows {
byDay[r.Day] = r
}
var out []DailyStat
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
for !cur.After(last) {
key := cur.Format("2006-01-02")
if r, ok := byDay[key]; ok {
out = append(out, r)
} else {
out = append(out, DailyStat{Day: key})
}
cur = cur.AddDate(0, 0, 1)
}
return out
}
// fillHourlyStats generates a zero-filled slice covering every whole
// hour in [start, end]. Hours present in rows overwrite the zero entries.
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
byHour := make(map[string]DailyStat, len(rows))
for _, r := range rows {
byHour[r.Day] = r
}
var out []DailyStat
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
for !cur.After(endHour) {
key := cur.Format("2006-01-02 15:00")
if r, ok := byHour[key]; ok {
out = append(out, r)
} else {
out = append(out, DailyStat{Day: key})
}
cur = cur.Add(time.Hour)
}
return out
}
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
// for the per-address stacked chart, zero-filling missing days.
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillAddressDailyStats(start, end, rows), nil
}
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
// for the per-address stacked chart, zero-filling missing hours.
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillAddressHourlyStats(start, end, rows), nil
}
// fillAddressDailyStats zero-fills missing day×address combinations.
// All addresses that appear in rows are represented for every day in range.
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
return fillAddressStats(start, end, rows, false)
}
// fillAddressHourlyStats zero-fills missing hour×address combinations.
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
return fillAddressStats(start, end, rows, true)
}
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
type key struct{ day, addr string }
byKey := make(map[key]float64, len(rows))
addrSet := make(map[string]struct{}, 4)
for _, r := range rows {
byKey[key{r.Day, r.Address}] = r.ActualAmount
addrSet[r.Address] = struct{}{}
}
if len(addrSet) == 0 {
return rows
}
var periods []string
if hourly {
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
for !cur.After(endH) {
periods = append(periods, cur.Format("2006-01-02 15:00"))
cur = cur.Add(time.Hour)
}
} else {
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
for !cur.After(last) {
periods = append(periods, cur.Format("2006-01-02"))
cur = cur.AddDate(0, 0, 1)
}
}
var out []AddressDailyStat
for _, day := range periods {
for addr := range addrSet {
out = append(out, AddressDailyStat{
Day: day,
Address: addr,
ActualAmount: byKey[key{day, addr}],
})
}
}
return out
}
// SumPaidActualAmount returns the total paid actual_amount across all
// time. Used by the overview "total asset" card.
func SumPaidActualAmount() (float64, error) {
var sum float64
err := dao.Mdb.Model(&mdb.Orders{}).
Select("COALESCE(SUM(actual_amount), 0)").
Where("status = ?", mdb.StatusPaySuccess).
Scan(&sum).Error
return sum, err
}
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
// a given time range. Caller chooses the range (today / last 7 days).
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
type row struct {
OrderCount int64
SuccessCount int64
ActualSum float64
}
var r row
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Scan(&r).Error
return r.OrderCount, r.SuccessCount, r.ActualSum, err
}
// ActiveAddressCountInRange returns the number of distinct wallet addresses
// that appear in at least one order within [start, end].
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Distinct("receive_address").
Count(&count).Error
return count, err
}
// AveragePaymentDurationSeconds returns the mean elapsed time between
// created_at and updated_at for orders that reached StatusPaySuccess
// in the given range. Zero-safe when no rows match.
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
type row struct {
Avg float64
}
var r row
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
// columns and compute in Go — trend windows are small enough.
type pair struct {
CreatedAt time.Time
UpdatedAt time.Time
}
var pairs []pair
err := dao.Mdb.Model(&mdb.Orders{}).
Select("created_at, updated_at").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Scan(&pairs).Error
if err != nil {
return 0, err
}
if len(pairs) == 0 {
return 0, nil
}
total := 0.0
for _, p := range pairs {
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
}
r.Avg = total / float64(len(pairs))
return r.Avg, nil
}
// CountExpiredInRange returns the number of StatusExpired orders in range.
func CountExpiredInRange(start, end time.Time) (int64, error) {
var n int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("status = ?", mdb.StatusExpired).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Count(&n).Error
return n, err
}
// RecentOrders returns the latest N orders regardless of status.
func RecentOrders(limit int) ([]mdb.Orders, error) {
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
var rows []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Order("id DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
// CountEnabledChains counts chains where Enabled=true.
func CountEnabledChains() (int64, error) {
var n int64
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
return n, err
}
+62
View File
@@ -0,0 +1,62 @@
package data
import (
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tradeID := "trade-evm-case"
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
if err != nil {
t.Fatalf("lookup transaction lock: %v", err)
}
if gotTradeID != tradeID {
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
}
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
t.Fatalf("unlock transaction: %v", err)
}
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
if err != nil {
t.Fatalf("lookup after unlock: %v", err)
}
if gotTradeID != "" {
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
}
}
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tradeID := "trade-tron-case"
address := "TCaseSensitiveAddress001"
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
if err != nil {
t.Fatalf("lookup transaction lock: %v", err)
}
if gotTradeID != "" {
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
}
}
+124
View File
@@ -0,0 +1,124 @@
package data
import (
"math/rand"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
)
// ListRpcNodes returns rows optionally filtered by network.
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
tx := dao.Mdb.Model(&mdb.RpcNode{})
if network != "" {
tx = tx.Where("network = ?", strings.ToLower(network))
}
err := tx.Order("id ASC").Find(&rows).Error
return rows, err
}
// GetRpcNodeByID fetches one row.
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
row := new(mdb.RpcNode)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateRpcNode inserts a row.
func CreateRpcNode(row *mdb.RpcNode) error {
return dao.Mdb.Create(row).Error
}
// UpdateRpcNodeFields patches mutable columns.
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteRpcNodeByID soft-deletes the row.
func DeleteRpcNodeByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).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 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{}).
Where("network = ?", strings.ToLower(network)).
Where("type = ?", strings.ToLower(nodeType)).
Where("enabled = ?", true).
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows {
switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
}
}
candidates := healthy
if len(candidates) == 0 {
candidates = bootstrap
}
if len(candidates) == 0 {
return nil, nil
}
return pickWeighted(candidates), nil
}
// pickWeighted chooses one row using the Weight column. Weights < 1 are
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
// to truly disable).
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
total := 0
for i := range rows {
w := rows[i].Weight
if w < 1 {
w = 1
}
total += w
}
if total <= 0 {
return &rows[0]
}
pick := rand.Intn(total)
acc := 0
for i := range rows {
w := rows[i].Weight
if w < 1 {
w = 1
}
acc += w
if pick < acc {
return &rows[i]
}
}
return &rows[len(rows)-1]
}
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
return dao.Mdb.Model(&mdb.RpcNode{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"last_latency_ms": latencyMs,
"last_checked_at": carbon.Now().StdTime(),
}).Error
}
+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)
}
}
+170
View File
@@ -0,0 +1,170 @@
package data
import (
"strconv"
"strings"
"sync"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause"
)
// Simple in-process cache. Settings are read frequently (on every order
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
// is invalidated explicitly via SetString/Delete and expires on process
// restart — that's enough for admin-edit semantics without TTL complexity.
var (
settingsCache = map[string]string{}
settingsCacheMu sync.RWMutex
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
settingsLoaded bool
)
func loadAllSettings() error {
var rows []mdb.Setting
if err := dao.Mdb.Find(&rows).Error; err != nil {
return err
}
settingsCacheMu.Lock()
defer settingsCacheMu.Unlock()
settingsCache = make(map[string]string, len(rows))
for _, r := range rows {
settingsCache[r.Key] = r.Value
}
settingsLoaded = true
return nil
}
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
// a dedicated mutex + double-checked flag so two concurrent callers
// don't both hit the DB. Can't use sync.Once because ReloadSettings
// needs to be able to rebuild after a failed initial load.
func ensureLoaded() {
settingsCacheMu.RLock()
loaded := settingsLoaded
settingsCacheMu.RUnlock()
if loaded {
return
}
settingsLoadMu.Lock()
defer settingsLoadMu.Unlock()
// Recheck under the load mutex — another goroutine may have loaded
// while we were blocked.
settingsCacheMu.RLock()
loaded = settingsLoaded
settingsCacheMu.RUnlock()
if loaded {
return
}
_ = loadAllSettings()
}
// ReloadSettings forces a refresh from DB. Call after external writes.
func ReloadSettings() error {
settingsLoadMu.Lock()
defer settingsLoadMu.Unlock()
return loadAllSettings()
}
// GetSettingString returns the string value for key, or fallback if unset.
func GetSettingString(key, fallback string) string {
ensureLoaded()
settingsCacheMu.RLock()
defer settingsCacheMu.RUnlock()
v, ok := settingsCache[key]
if !ok || strings.TrimSpace(v) == "" {
return fallback
}
return v
}
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
func GetSettingInt(key string, fallback int) int {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
func GetSettingFloat(key string, fallback float64) float64 {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return fallback
}
return f
}
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
func GetSettingBool(key string, fallback bool) bool {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
// SetSetting upserts a setting row and refreshes the cache entry.
func SetSetting(group, key, value, valueType string) error {
if valueType == "" {
valueType = mdb.SettingTypeString
}
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
err := dao.Mdb.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
}).Create(&row).Error
if err != nil {
return err
}
settingsCacheMu.Lock()
settingsCache[key] = value
settingsCacheMu.Unlock()
return nil
}
// DeleteSetting removes a setting row and drops the cache entry.
func DeleteSetting(key string) error {
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
return err
}
settingsCacheMu.Lock()
delete(settingsCache, key)
settingsCacheMu.Unlock()
return nil
}
// 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),
// excluding any keys in sensitiveSettingKeys.
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
var rows []mdb.Setting
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
if group != "" {
tx = tx.Where("`group` = ?", group)
}
err := tx.Order("`key` ASC").Find(&rows).Error
return rows, err
}
-123
View File
@@ -1,123 +0,0 @@
package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
)
func normalizeNetworkToken(network, token string) (string, string) {
return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token))
}
func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 {
return nil, constant.SupportedAssetAlreadyExists
}
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
if err != nil {
return nil, err
}
if deleted.ID > 0 && deleted.DeletedAt.Valid {
deleted.Status = status
deleted.DeletedAt.Valid = false
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
return nil, err
}
return deleted, nil
}
asset := &mdb.SupportedAsset{
Network: network,
Token: token,
Status: status,
}
err = dao.Mdb.Create(asset).Error
return asset, err
}
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Unscoped().Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) {
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error
return asset, err
}
func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset, err := GetSupportedAssetByID(id)
if err != nil {
return nil, err
}
if asset.ID <= 0 {
return nil, constant.SupportedAssetNotFound
}
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 && exist.ID != id {
return nil, constant.SupportedAssetAlreadyExists
}
asset.Network = network
asset.Token = token
asset.Status = status
if err := dao.Mdb.Save(asset).Error; err != nil {
return nil, err
}
return asset, nil
}
func DeleteSupportedAssetByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error
}
func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) {
network = strings.ToLower(strings.TrimSpace(network))
var list []mdb.SupportedAsset
tx := dao.Mdb.Model(&mdb.SupportedAsset{})
if network != "" {
tx = tx.Where("network = ?", network)
}
err := tx.Order("network asc, token asc").Find(&list).Error
return list, err
}
func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) {
var list []mdb.SupportedAsset
err := dao.Mdb.Model(&mdb.SupportedAsset{}).
Where("status = ?", mdb.TokenStatusEnable).
Order("network asc, token asc").
Find(&list).Error
return list, err
}
+64 -5
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 添加)
@@ -13,10 +13,32 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return AddWalletAddressWithNetwork(mdb.NetworkTron, address) return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
} }
// isEVMNetwork 判断是否是 EVM 网络
func isEVMNetwork(network string) bool {
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
}
return false
}
func normalizeWalletNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}
func normalizeWalletAddressByNetwork(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeWalletNetwork(network)) {
return strings.ToLower(address)
}
return address
}
// AddWalletAddressWithNetwork 创建指定网络的钱包地址 // AddWalletAddressWithNetwork 创建指定网络的钱包地址
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) { func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
network = strings.ToLower(strings.TrimSpace(network)) network = normalizeWalletNetwork(network)
address = strings.TrimSpace(address) address = normalizeWalletAddressByNetwork(network, address)
exist, err := GetWalletAddressByNetworkAndAddress(network, address) exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -24,6 +46,23 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
if exist.ID > 0 { if exist.ID > 0 {
return nil, constant.WalletAddressAlreadyExists return nil, constant.WalletAddressAlreadyExists
} }
// Check for a soft-deleted record with the same (network, address) and restore it.
deleted := new(mdb.WalletAddress)
err = dao.Mdb.Unscoped().
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
Limit(1).Find(deleted).Error
if err != nil {
return nil, err
}
if deleted.ID > 0 {
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
"deleted_at": nil,
"status": mdb.TokenStatusEnable,
}).Error
return deleted, err
}
walletAddress := &mdb.WalletAddress{ walletAddress := &mdb.WalletAddress{
Network: network, Network: network,
Address: address, Address: address,
@@ -35,6 +74,8 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询 // GetWalletAddressByNetworkAndAddress 通过网络和地址查询
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) { func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
address = normalizeWalletAddressByNetwork(network, address)
walletAddress := new(mdb.WalletAddress) walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress). err := dao.Mdb.Model(walletAddress).
Where("network = ?", network). Where("network = ?", network).
@@ -72,11 +113,20 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址 // GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) { func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
var list []mdb.WalletAddress var list []mdb.WalletAddress
err := dao.Mdb.Model(list). err := dao.Mdb.Model(list).
Where("status = ?", mdb.TokenStatusEnable). Where("status = ?", mdb.TokenStatusEnable).
Where("network = ?", network). Where("network = ?", network).
Find(&list).Error Find(&list).Error
if err != nil {
return nil, err
}
if isEVMNetwork(network) {
for i := range list {
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
return list, err return list, err
} }
@@ -89,8 +139,17 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址 // GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) { func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
var list []mdb.WalletAddress var list []mdb.WalletAddress
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
if err != nil {
return nil, err
}
if isEVMNetwork(network) {
for i := range list {
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
return list, err return list, err
} }
@@ -0,0 +1,83 @@
package data
import (
"strings"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
if err != nil {
t.Fatalf("add wallet: %v", err)
}
if row.Address != strings.ToLower(input) {
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
}
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
if err != nil {
t.Fatalf("load wallet by mixed-case address: %v", err)
}
if loaded.ID == 0 {
t.Fatal("expected to find wallet by mixed-case query")
}
if loaded.Address != strings.ToLower(input) {
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
}
}
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: mixed,
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed mixed-case wallet: %v", err)
}
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
if err != nil {
t.Fatalf("list wallets: %v", err)
}
if len(rows) != 1 {
t.Fatalf("wallet count = %d, want 1", len(rows))
}
if rows[0].Address != strings.ToLower(mixed) {
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
}
}
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tronAddress := "TCaseSensitiveTronAddress001"
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
if err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if tronRow.Address != tronAddress {
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
}
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
if err != nil {
t.Fatalf("add solana wallet: %v", err)
}
if solRow.Address != solAddress {
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
}
}
+21
View File
@@ -0,0 +1,21 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
AdminUserStatusEnable = 1
AdminUserStatusDisable = 2
)
type AdminUser struct {
Username string `gorm:"column:username;uniqueIndex:admin_users_username_uindex;size:64" json:"username" example:"admin"`
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
// 状态 1=启用 2=禁用
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
LastLoginAt carbon.Time `gorm:"column:last_login_at" json:"last_login_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (a *AdminUser) TableName() string {
return "admin_users"
}
+29
View File
@@ -0,0 +1,29 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
ApiKeyStatusEnable = 1
ApiKeyStatusDisable = 2
)
// ApiKey stores a universal merchant credential (PID + secret) that is
// valid for both EPAY and GMPAY payment flows. Identification at
// request time is always by PID; the gateway flow is chosen by the
// route (not by any property of the row).
type ApiKey struct {
Name string `gorm:"column:name;size:128" json:"name" example:"My API Key"`
Pid string `gorm:"column:pid;size:128;uniqueIndex:api_keys_pid_uindex" json:"pid" example:"1000"`
SecretKey string `gorm:"column:secret_key;size:255" json:"-"`
IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"`
NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"`
// 状态 1=启用 2=禁用
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"`
LastUsedAt carbon.Time `gorm:"column:last_used_at" json:"last_used_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (a *ApiKey) TableName() string {
return "api_keys"
}
+3 -3
View File
@@ -6,8 +6,8 @@ import (
) )
type BaseModel struct { type BaseModel struct {
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"` ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"` CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"` UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
} }
+19
View File
@@ -0,0 +1,19 @@
package mdb
// Chain represents an enabled blockchain network.
// Scanner tasks read this table on each polling tick to decide whether to
// process the network. When Enabled=false the scanner skips the chain
// entirely without restarting.
type Chain struct {
Network string `gorm:"column:network;uniqueIndex:chains_network_uindex;size:32" json:"network" example:"tron"`
DisplayName string `gorm:"column:display_name;size:64" json:"display_name" example:"Tron"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
MinConfirmations int `gorm:"column:min_confirmations;default:1" json:"min_confirmations" example:"20"`
ScanIntervalSec int `gorm:"column:scan_interval_sec;default:5" json:"scan_interval_sec" example:"5"`
Extra string `gorm:"column:extra;type:text" json:"extra" example:"{}"`
BaseModel
}
func (c *Chain) TableName() string {
return "chains"
}
+24
View File
@@ -0,0 +1,24 @@
package mdb
const (
ChainTokenStatusEnable = 1
ChainTokenStatusDisable = 2
)
// ChainToken describes a token to watch on a given chain. Replaces the
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
// load this table on startup (and refresh periodically) to learn which
// contracts to subscribe to.
type ChainToken struct {
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
ContractAddress string `gorm:"column:contract_address;size:128" json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `gorm:"column:decimals;default:6" json:"decimals" example:"6"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
MinAmount float64 `gorm:"column:min_amount;default:0" json:"min_amount" example:"1.0"`
BaseModel
}
func (c *ChainToken) TableName() string {
return "chain_tokens"
}
+36
View File
@@ -0,0 +1,36 @@
package mdb
const (
NotificationTypeTelegram = "telegram"
NotificationTypeWebhook = "webhook"
NotificationTypeEmail = "email"
)
const (
NotificationStatusEnable = 1
NotificationStatusDisable = 2
)
// Notification events (JSON keys in Events column).
const (
NotifyEventPaySuccess = "pay_success"
NotifyEventOrderExpired = "order_expired"
NotifyEventDailyReport = "daily_report"
)
// NotificationChannel represents one push target instance.
// Config/Events are JSON strings consumed by the notify dispatcher.
// For telegram, Config = {"bot_token":"...","chat_id":"...","proxy":"..."}.
// For webhook, Config = {"url":"...","headers":{...},"secret":"..."}.
type NotificationChannel struct {
Type string `gorm:"column:type;size:32;index:notification_channels_type_enabled_index,priority:1" json:"type" enums:"telegram,webhook,email" example:"telegram"`
Name string `gorm:"column:name;size:128" json:"name" example:"主通知群"`
Config string `gorm:"column:config;type:text" json:"config" example:"{\"bot_token\":\"123:ABC\",\"chat_id\":\"456\"}"`
Events string `gorm:"column:events;type:text" json:"events" example:"{\"pay_success\":true,\"order_expired\":true}"`
Enabled bool `gorm:"column:enabled;default:true;index:notification_channels_type_enabled_index,priority:2" json:"enabled" example:"true"`
BaseModel
}
func (n *NotificationChannel) TableName() string {
return "notification_channels"
}
+23 -17
View File
@@ -13,24 +13,30 @@ const (
) )
type Orders struct { type Orders struct {
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"` ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
Amount float64 `gorm:"column:amount" json:"amount"` Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
Currency string `gorm:"column:currency" json:"currency"` Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"` ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
Token string `gorm:"column:token" json:"token"` Token string `gorm:"column:token" json:"token" example:"USDT"`
Network string `gorm:"column:network" json:"network"` Network string `gorm:"column:network" json:"network" example:"tron"`
Status int `gorm:"column:status;default:1" json:"status"` // 订单状态 1=等待支付 2=支付成功 3=已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
Name string `gorm:"column:name" json:"name"` RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"` // 回调确认状态 1=回调成功 2=未回调/回调失败
PaymentType string `gorm:"column:payment_type" json:"payment_type"` CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
// Zero when the parent order was paid directly (no sub-order involved).
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
BaseModel BaseModel
} }
+36
View File
@@ -0,0 +1,36 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
RpcNodeTypeHttp = "http"
RpcNodeTypeWs = "ws"
)
const (
RpcNodeStatusUnknown = "unknown"
RpcNodeStatusOk = "ok"
RpcNodeStatusDown = "down"
)
// RpcNode records one RPC endpoint for a network. Clients select a node
// by weight among rows where Enabled=true AND Status=ok. A periodic
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
type RpcNode struct {
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
// 连接类型 http=HTTP请求 ws=WebSocket长连接
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
// 健康状态 unknown=未知 ok=正常 down=异常
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (r *RpcNode) TableName() string {
return "rpc_nodes"
}
+58
View File
@@ -0,0 +1,58 @@
package mdb
// Setting stores non-credential runtime configuration as key/value pairs.
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
// live in the api_keys table; notification configs live in NotificationChannel.
// Default rows for the epay group are seeded on first startup (see
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
// empty and fall back to hardcoded defaults until an admin sets them.
// Only exception: system.jwt_secret is auto-generated on first startup.
const (
SettingGroupBrand = "brand"
SettingGroupRate = "rate"
SettingGroupSystem = "system"
SettingGroupEpay = "epay"
)
const (
SettingTypeString = "string"
SettingTypeInt = "int"
SettingTypeBool = "bool"
SettingTypeJSON = "json"
)
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"
SettingKeyBrandPageTitle = "brand.page_title"
SettingKeyBrandPaySuccess = "brand.pay_success_text"
SettingKeyBrandSupportUrl = "brand.support_url"
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
SettingKeyRateAdjustPercent = "rate.adjust_percent"
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
SettingKeyRateApiUrl = "rate.api_url"
// EPAY route defaults — can be overridden via admin settings.
SettingKeyEpayDefaultToken = "epay.default_token"
SettingKeyEpayDefaultCurrency = "epay.default_currency"
SettingKeyEpayDefaultNetwork = "epay.default_network"
)
type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
BaseModel
}
func (s *Setting) TableName() string {
return "settings"
}
-12
View File
@@ -1,12 +0,0 @@
package mdb
type SupportedAsset struct {
Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"`
Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel
}
func (s *SupportedAsset) TableName() string {
return "supported_asset"
}
+11 -3
View File
@@ -14,10 +14,18 @@ const (
NetworkPlasma = "plasma" NetworkPlasma = "plasma"
) )
const (
WalletSourceManual = "manual"
WalletSourceImport = "import"
)
type WalletAddress struct { type WalletAddress struct {
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"` Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"` Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
Status int64 `gorm:"column:status;default:1" json:"status"` // 状态 1=启用 2=禁用
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
BaseModel BaseModel
} }
+4 -4
View File
@@ -8,8 +8,8 @@ const (
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc} var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
type BaseRequest struct { type BaseRequest struct {
Page int `json:"page"` // 页数 Page int `json:"page" example:"1"` // 页数
PageSize int `json:"page_size"` // 每页条数 PageSize int `json:"page_size" example:"20"` // 每页条数
OrderField string `json:"order_field"` // 排序字段 OrderField string `json:"order_field" example:"created_at"` // 排序字段
OrderFunc string `json:"order_func"` // 排序方法 OrderFunc string `json:"order_func" enums:"DESC,ASC" example:"DESC"` // 排序方法
} }
+13 -13
View File
@@ -4,16 +4,16 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求 // CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct { type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"` OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
Currency string `json:"currency" validate:"required"` // 法币 如:cny Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
Token string `json:"token" validate:"required"` // 币种 如:usdt Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
Network string `json:"network" validate:"required"` // 网络 如:TRON Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"` Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
NotifyUrl string `json:"notify_url" validate:"required"` NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
Signature string `json:"signature" validate:"required"` Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
RedirectUrl string `json:"redirect_url"` RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
Name string `json:"name"` Name string `json:"name" form:"name" example:"VIP月卡"`
PaymentType string `json:"payment_type"` PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
} }
func (r CreateTransactionRequest) Translates() map[string]string { func (r CreateTransactionRequest) Translates() map[string]string {
@@ -41,9 +41,9 @@ type OrderProcessingRequest struct {
// SwitchNetworkRequest 切换支付网络 // SwitchNetworkRequest 切换支付网络
type SwitchNetworkRequest struct { type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required"` TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
Token string `json:"token" validate:"required"` Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required"` Network string `json:"network" validate:"required" example:"ethereum"`
} }
func (r SwitchNetworkRequest) Translates() map[string]string { func (r SwitchNetworkRequest) Translates() map[string]string {
+11
View File
@@ -0,0 +1,11 @@
package response
// ApiResponse is the standard JSON envelope used by all API endpoints.
// Used for swagger documentation only — the actual response is built
// by util/http.Resp.
type ApiResponse struct {
StatusCode int `json:"status_code" example:"200"`
Message string `json:"message" example:"success"`
Data interface{} `json:"data"`
RequestID string `json:"request_id" example:"req-123456"`
}
+29 -27
View File
@@ -2,39 +2,41 @@ package response
// CreateTransactionResponse 创建订单成功返回 // CreateTransactionResponse 创建订单成功返回
type CreateTransactionResponse struct { type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD...... Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
ReceiveAddress string `json:"receive_address"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址 PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
} }
// OrderNotifyResponse 订单异步回调结构体 // OrderNotifyResponse 订单异步回调结构体
type OrderNotifyResponse struct { type OrderNotifyResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
OrderId string `json:"order_id"` // 客户交易id TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount"` // 订单金额,保留4位小数 OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
ReceiveAddress string `json:"receive_address"` // 收款钱包地址 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 所属币种 TRX USDT...... ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
BlockTransactionId string `json:"block_transaction_id"` // 区块id Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Signature string `json:"signature"` // 签名 BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"2"`
} }
// OrderNotifyResponseEpay epay订单异步回调结构体 // OrderNotifyResponseEpay epay订单异步回调结构体
type OrderNotifyResponseEpay struct { type OrderNotifyResponseEpay struct {
PID int `json:"pid"` // 商户ID PID int `json:"pid" example:"1001"` // 商户ID
TradeNo string `json:"trade_no"` // 平台订单号 TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
OutTradeNo string `json:"out_trade_no"` // 商户订单号 OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
Type string `json:"type"` // 订单类型 Type string `json:"type" example:"usdt"` // 订单类型
Name string `json:"name"` // 商品名称 Name string `json:"name" example:"VIP月卡"` // 商品名称
Money string `json:"money"` // 订单金额,保留4位小数 Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
Sign string `json:"sign"` // 签名 Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
SignType string `json:"sign_type"` // 签名类型 // MD5 SignType string `json:"sign_type" example:"MD5"` // 签名类型
TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
} }
+14 -13
View File
@@ -1,20 +1,21 @@
package response package response
type CheckoutCounterResponse struct { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency"` // 法币币种 CNY USD ... Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Network string `json:"network"` // 网络 TRON ETH ... Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"` RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected"` IsSelected bool `json:"is_selected" example:"false"`
} }
type CheckStatusResponse struct { type CheckStatusResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Status int `json:"status"` // 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"1"`
} }
+2 -2
View File
@@ -1,8 +1,8 @@
package response package response
type NetworkTokenSupport struct { type NetworkTokenSupport struct {
Network string `json:"network"` Network string `json:"network" example:"tron"`
Tokens []string `json:"tokens"` Tokens []string `json:"tokens" example:"USDT,USDC"`
} }
type SupportedAssetsResponse struct { type SupportedAssetsResponse struct {
+80 -33
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"
) )
@@ -33,8 +33,28 @@ var (
gOrderProcessingLock sync.Mutex gOrderProcessingLock sync.Mutex
) )
// apiKeyID safely extracts the primary key from an ApiKey row.
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
if apiKey == nil {
return 0
}
return apiKey.ID
}
func normalizeOrderAddressByNetwork(network, address string) string {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return strings.ToLower(address)
default:
return address
}
}
// CreateTransaction creates a new payment order. // CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
@@ -64,6 +84,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.OrderAlreadyExists return nil, constant.OrderAlreadyExists
} }
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -97,6 +120,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
PaymentType: req.PaymentType, PaymentType: req.PaymentType,
ApiKeyID: apiKeyID(apiKey),
} }
if err = data.CreateOrderWithTransaction(tx, order); err != nil { if err = data.CreateOrderWithTransaction(tx, order); err != nil {
tx.Rollback() tx.Rollback()
@@ -161,7 +185,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
// Load order to check parent-child relationship // Load order to check parent-child relationship
order, err := data.GetOrderInfoByTradeId(req.TradeId) order, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil { if err != nil {
return nil return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
} }
// Parent order paid directly: expire all sub-orders and release their locks // Parent order paid directly: expire all sub-orders and release their locks
@@ -169,7 +193,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
subs, subErr := data.GetActiveSubOrders(order.TradeId) subs, subErr := data.GetActiveSubOrders(order.TradeId)
if subErr != nil { if subErr != nil {
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr) log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
return nil return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
} }
for _, sub := range subs { for _, sub := range subs {
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil { if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
@@ -182,22 +206,48 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return nil return nil
} }
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it here to prevent the callback worker from retrying an empty URL.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
}
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId) parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
if err != nil { if err != nil {
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err) log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
return nil return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
} }
// Snapshot siblings for lock release after DB state transition commits.
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
finalizeTx := dao.Mdb.Begin()
// Mark parent as paid with sub-order's payment details // Mark parent as paid with sub-order's payment details
if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil { updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err) if markErr != nil {
finalizeTx.Rollback()
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
}
if !updatedParent {
finalizeTx.Rollback()
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
}
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
finalizeTx.Rollback()
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
if err = finalizeTx.Commit().Error; err != nil {
finalizeTx.Rollback()
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it only after the parent order is successfully finalized.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
} }
// Release parent's own wallet lock // Release parent's own wallet lock
@@ -205,16 +255,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err) log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
} }
// Expire sibling sub-orders and release their locks // Release sibling locks after their status transitions commit.
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return nil
}
for _, sib := range siblings { for _, sib := range siblings {
if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err)
}
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err) log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
} }
@@ -230,9 +272,10 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
tryLockWalletFunc := func(targetAmount float64) (string, error) { tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress { for _, address := range walletAddress {
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration()) normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil { if err == nil {
return address.Address, nil return normalizedAddress, nil
} }
if errors.Is(err, data.ErrTransactionLocked) { if errors.Is(err, data.ErrTransactionLocked) {
continue continue
@@ -344,6 +387,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
} }
// 6. Find and lock wallet // 6. Find and lock wallet
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -381,6 +427,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
Name: parent.Name, Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
} }
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
tx.Rollback() tx.Rollback()
+417 -13
View File
@@ -2,15 +2,21 @@ package service
import ( import (
"fmt" "fmt"
"io"
"net/http"
"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 {
@@ -24,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()
@@ -32,11 +58,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create first transaction: %v", err) t.Fatalf("create first transaction: %v", err)
} }
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1)) resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create second transaction: %v", err) t.Fatalf("create second transaction: %v", err)
} }
@@ -71,6 +97,102 @@ 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) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: mixedAddress,
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed mixed-case wallet: %v", err)
}
req := newCreateTransactionRequest("order_evm_1", 1)
req.Network = mdb.NetworkEthereum
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
expectedAddress := strings.ToLower(mixedAddress)
if resp.ReceiveAddress != expectedAddress {
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("load order: %v", err)
}
if order.ReceiveAddress != expectedAddress {
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("lookup runtime lock: %v", err)
}
if tradeID != resp.TradeId {
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
}
}
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -79,7 +201,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
@@ -127,7 +249,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
@@ -169,7 +291,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
@@ -215,11 +337,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil { if err != nil {
t.Fatalf("create first transaction: %v", err) t.Fatalf("create first transaction: %v", err)
} }
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2)) resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
if err != nil { if err != nil {
t.Fatalf("create second transaction: %v", err) t.Fatalf("create second transaction: %v", err)
} }
@@ -303,3 +425,285 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount) t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
} }
} }
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", parentResp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force parent expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_not_wait",
})
if err == nil {
t.Fatal("expected error when parent order is not wait-pay")
}
}
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
t.Fatalf("delete parent order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_missing",
})
if err == nil {
t.Fatal("expected error when parent order is missing")
}
}
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
// when a sub-order is paid, the parent order is marked as paid but its own
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Snapshot the parent's original fields before any payment.
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("load original parent: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
// Load the sub-order to get its DB primary-key ID.
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("load sub-order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_paid",
})
if err != nil {
t.Fatalf("order processing sub-order: %v", err)
}
// Sub-order must be paid with the block hash and no pending callback.
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("reload sub-order: %v", err)
}
if sub.Status != mdb.StatusPaySuccess {
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
}
if sub.BlockTransactionId != "block_sub_paid" {
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
}
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
}
// Parent must be paid but its own fields must be unchanged.
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("reload parent order: %v", err)
}
if parent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
}
if parent.BlockTransactionId != "" {
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
}
if parent.ReceiveAddress != originalParent.ReceiveAddress {
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
}
if parent.ActualAmount != originalParent.ActualAmount {
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
}
if parent.PayBySubId != subOrder.ID {
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
}
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
}
}
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
// well as the parent's lock) are released.
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
t.Fatalf("add bsc wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Create two sub-orders on different networks.
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch to ethereum sub-order: %v", err)
}
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkBsc,
})
if err != nil {
t.Fatalf("switch to bsc sub-order: %v", err)
}
// Pay the Ethereum sub-order.
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subEthResp.ReceiveAddress,
Token: strings.ToUpper(subEthResp.Token),
Network: strings.ToLower(subEthResp.Network),
TradeId: subEthResp.TradeId,
Amount: subEthResp.ActualAmount,
BlockTransactionId: "block_sib_eth",
})
if err != nil {
t.Fatalf("order processing eth sub-order: %v", err)
}
// BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
if err != nil {
t.Fatalf("reload bsc sub-order: %v", err)
}
if subBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
}
// Parent runtime lock must be released.
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
if err != nil {
t.Fatalf("check parent runtime lock: %v", err)
}
if parentLock != "" {
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
}
// BSC sibling runtime lock must be released.
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
if err != nil {
t.Fatalf("check bsc sibling runtime lock: %v", err)
}
if sibLock != "" {
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
}
// Ethereum sub-order runtime lock must also be released.
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
if err != nil {
t.Fatalf("check eth sub-order runtime lock: %v", err)
}
if ethLock != "" {
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
}
}
+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("不存在待支付订单或已过期")
+94 -44
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"
@@ -45,6 +45,33 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
} }
}() }()
// Load enabled Solana tokens from chain_tokens. The contract_address
// column holds the mint for SPL tokens; a special entry with
// symbol=SOL and empty contract_address enables native SOL payments.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
return
}
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
mint := strings.TrimSpace(tokens[i].ContractAddress)
if sym == "SOL" && mint == "" {
nativeSolToken = &tokens[i]
continue
}
if mint == "" {
continue
}
mintTokens[mint] = &tokens[i]
}
// Clean up old entries from processed cache (older than 1 hour) // Clean up old entries from processed cache (older than 1 hour)
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix() cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
gProcessedSignatures.Range(func(key, value interface{}) bool { gProcessedSignatures.Range(func(key, value interface{}) bool {
@@ -56,17 +83,15 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
limit := 1000 limit := 1000
// 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名 // 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
queryAddrs := []string{address} queryAddrs := []string{address}
if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil { for mint := range mintTokens {
queryAddrs = append(queryAddrs, usdtAta) ata, err := FindATAAddress(address, mint)
} else { if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err) log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
continue
} }
if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil { queryAddrs = append(queryAddrs, ata)
queryAddrs = append(queryAddrs, usdcAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err)
} }
// 拉取签名并去重 // 拉取签名并去重
@@ -177,7 +202,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
continue continue
} }
token, amount := getTokenTypeAndAmount(transferInfo) token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if token == "" || amount <= 0 { if token == "" || amount <= 0 {
continue continue
} }
@@ -256,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()
@@ -272,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").
@@ -363,35 +406,42 @@ func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination) return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
} }
// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额 // resolveSolTokenAndAmount identifies the token from a transfer using
func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) { // the admin-configured chain_tokens list. mintTokens maps SPL mint
// addresses to their ChainToken config; nativeSolToken is non-nil when a
// native SOL entry exists in chain_tokens. Returns ("", 0) for
// transfers whose mint is not configured or below min_amount.
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
mint := transfer.Mint mint := transfer.Mint
// Native SOL // Native SOL
if mint == "SOL" { if mint == "SOL" {
return "SOL", transfer.Amount if nativeSolToken == nil {
}
// SPL Tokens
switch mint {
case USDT_Mint:
decimals := USDT_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDT", ADJustAmount(transfer.RawAmount, decimals)
case USDC_Mint:
decimals := USDC_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDC", ADJustAmount(transfer.RawAmount, decimals)
default:
// Unsupported token
return "", 0 return "", 0
} }
amount := transfer.Amount
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
}
cfg, ok := mintTokens[mint]
if !ok || cfg == nil {
return "", 0
}
decimals := cfg.Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
if decimals <= 0 {
decimals = 6
}
amount := ADJustAmount(transfer.RawAmount, decimals)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
} }
const ( const (
@@ -437,7 +487,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
func MatchUsdtAtaAddress(address string, ataTo string) bool { func MatchUsdtAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDT_Mint) ata, err := FindATAAddress(address, USDT_Mint)
if err != nil { if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err) log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false return false
} }
@@ -447,7 +497,7 @@ func MatchUsdtAtaAddress(address string, ataTo string) bool {
func MatchUsdcAtaAddress(address string, ataTo string) bool { func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint) ata, err := FindATAAddress(address, USDC_Mint)
if err != nil { if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err) log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false return false
} }
@@ -457,7 +507,7 @@ func MatchUsdcAtaAddress(address string, ataTo string) bool {
func MatchAtaAddress(address string, mint string, ataTo string) bool { func MatchAtaAddress(address string, mint string, ataTo string) bool {
ata, err := FindATAAddress(address, mint) ata, err := FindATAAddress(address, mint)
if err != nil { if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err) log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false return false
} }
+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"
+143 -213
View File
@@ -1,261 +1,159 @@
package service package service
import ( import (
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"net/http"
"strings" "strings"
"sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/model/data"
tron "github.com/assimon/luuu/crypto" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/notify"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/math"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tidwall/gjson"
) )
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil { if err != nil {
panic(err) return "", "", err
} }
if resp.StatusCode() != http.StatusOK { if node == nil || node.ID == 0 {
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode())) 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
} }
success := gjson.GetBytes(resp.Body(), "success").Bool() func ResolveTronNode() (string, string, error) {
if !success { return resolveTronNode()
panic("TRX API response indicates failure")
} }
transfers := gjson.GetBytes(resp.Body(), "data").Array() func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
if len(transfers) == 0 { defer func() {
log.Sugar.Debugf("[TRX][%s] no transfer records found", address) if err := recover(); err != nil {
log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
}
}()
addr := strings.TrimSpace(toAddr)
if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
return return
} }
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers { decimalQuant := decimal.NewFromBigInt(rawValue, 0)
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" { amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
continue
}
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue
}
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
}
if tron.EncodeCheck(toBytes) != address {
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
if amount <= 0 { if amount <= 0 {
continue return
} }
txID := transfer.Get("txID").String() tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
if err != nil { if err != nil {
panic(err) log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
return
} }
if tradeID == "" { if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount) log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
continue return
} }
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID) order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { if err != nil {
panic(err) log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
}
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRC20 API response indicates failure")
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
return return
} }
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers)) if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
for i, transfer := range transfers { return
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
continue
}
if transfer.Get("to").String() != address {
continue
}
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
if err != nil {
panic(err)
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
}
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
} }
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: address, ReceiveAddress: addr,
Token: "USDT", Token: "USDT",
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
TradeId: tradeID, TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: txID, BlockTransactionId: txHash,
} }
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err) log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
continue return
} }
panic(err) log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
} }
sendPaymentNotification(order) sendPaymentNotification(order)
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID) log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
} }
func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
}
}()
addr := strings.TrimSpace(toAddr)
if addr == "" || rawSun <= 0 {
return
}
decimalQuant := decimal.NewFromInt(rawSun)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
return
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
if err != nil {
log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
return
}
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
return
}
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: addr,
Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
} }
func evmChainLogLabel(chainNetwork string) string { func evmChainLogLabel(chainNetwork string) string {
@@ -273,7 +171,6 @@ func evmChainLogLabel(chainNetwork string) string {
} }
} }
// TryProcessEvmERC20Transfer 处理各 EVM 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
@@ -319,8 +216,29 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr) log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return return
} }
chainTokens, err := data.ListChainTokens(chainNetwork)
if err != nil {
log.Sugar.Warnf("[%s-%s] load chain tokens: %v", net, tokenSym, err)
return
}
var tokenConfig *mdb.ChainToken
for _, t := range chainTokens {
if strings.EqualFold(t.Symbol, tokenSym) {
tokenConfig = &t
break
}
}
if tokenConfig == nil || !tokenConfig.Enabled {
log.Sugar.Warnf("[%s-%s] token not enabled or configured in chain_tokens", net, tokenSym)
return
}
pow := decimal.New(1, int32(tokenConfig.Decimals))
log.Sugar.Warnf("tokenConfig.Decimals %d pow %s", tokenConfig.Decimals, pow.String())
decimalQuant := decimal.NewFromBigInt(rawValue, 0) decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
if amount <= 0 { if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount) log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return return
@@ -375,6 +293,18 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
} }
func sendPaymentNotification(order *mdb.Orders) { func sendPaymentNotification(order *mdb.Orders) {
if order == nil {
return
}
if strings.TrimSpace(order.TradeId) != "" {
latest, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
} else if latest != nil && latest.TradeId != "" {
order = latest
}
}
msg := fmt.Sprintf( msg := fmt.Sprintf(
"🎉 <b>收款成功通知</b>\n\n"+ "🎉 <b>收款成功通知</b>\n\n"+
"💰 <b>金额信息</b>\n"+ "💰 <b>金额信息</b>\n"+
@@ -397,9 +327,9 @@ func sendPaymentNotification(order *mdb.Orders) {
networkDisplay(order.Network), networkDisplay(order.Network),
order.ReceiveAddress, order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(), order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(), order.UpdatedAt.ToDateTimeString(),
) )
telegram.SendToBot(msg) notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
} }
func networkDisplay(n string) string { func networkDisplay(n string) string {
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
)
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const channelType = "test-pay-success-time"
got := make(chan string, 1)
notify.RegisterSender(channelType, func(config, text string) error {
got <- text
return nil
})
if err := dao.Mdb.Create(&mdb.NotificationChannel{
Type: channelType,
Name: "test",
Config: "{}",
Events: `{"pay_success":true}`,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed notification channel: %v", err)
}
order := &mdb.Orders{
TradeId: "T202604270001",
OrderId: "ORD202604270001",
Amount: 100,
Currency: "cny",
ActualAmount: 14.28,
Token: "USDT",
Network: mdb.NetworkTron,
ReceiveAddress: "TTestAddress",
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
const createdAt = "2026-04-27 09:00:00"
const staleUpdatedAt = "2026-04-27 09:01:00"
const paidAt = "2026-04-27 10:20:30"
if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
t.Fatalf("set initial timestamps: %v", err)
}
var staleOrderModel mdb.Orders
if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
t.Fatalf("load stale order: %v", err)
}
if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
t.Fatalf("set paid timestamp: %v", err)
}
sendPaymentNotification(&staleOrderModel)
select {
case text := <-got:
if !strings.Contains(text, "支付时间:"+paidAt) {
t.Fatalf("notification payment time = %q, want %s", text, paidAt)
}
if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
t.Fatalf("notification used stale payment time: %q", text)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for notification")
}
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// 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()
if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
}
}
// TestResolveTronNode_WithRow verifies that resolveTronNode uses the DB row
// when an enabled TRON HTTP node is present.
func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
// Insert an enabled TRON http node.
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://custom-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "db-api-key",
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
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)
}
if gotKey != "db-api-key" {
t.Errorf("apiKey = %q, want \"db-api-key\"", gotKey)
}
}
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "disabled-key",
Weight: 1,
Enabled: true, // start enabled, then disable below
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
// Explicitly disable — GORM does not save bool zero-value on Create.
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
t.Fatalf("disable rpc_node: %v", err)
}
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 ( import (
"sync" "sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
) )
var ( var (
+70 -37
View File
@@ -3,22 +3,44 @@ package mq
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "strconv"
"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.
// Every order must carry an ApiKeyID (the default key is always seeded
// at bootstrap, and the middleware/EPAY inline flow always stamps it).
// If the row is missing or disabled, we return an error — using any
// other secret would produce a signature the merchant can't verify.
// The admin can resend the callback after fixing the key.
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order.ApiKeyID == 0 {
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
}
if row.ID == 0 {
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
}
if row.Status != mdb.ApiKeyStatusEnable {
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
}
return row, nil
}
const batchSize = 100 const batchSize = 100
const sqliteBusyRetryAttempts = 3 const sqliteBusyRetryAttempts = 3
@@ -165,56 +187,62 @@ func processCallback(tradeID string) {
} }
func sendOrderCallback(order *mdb.Orders) error { func sendOrderCallback(order *mdb.Orders) error {
apiKeyRow, err := resolveOrderApiKey(order)
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
return errors.New("no api key row available for callback")
}
switch order.PaymentType { switch order.PaymentType {
case mdb.PaymentTypeEpay: case mdb.PaymentTypeEpay:
// 构造 EPay 标准回调参数 // EPAY uses pid (integer) and secret_key as "key".
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
if convErr != nil {
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
}
notifyData := response.OrderNotifyResponseEpay{ notifyData := response.OrderNotifyResponseEpay{
PID: config.GetEpayPid(), PID: pidInt,
TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号 TradeNo: order.TradeId,
OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数 OutTradeNo: order.OrderId,
Type: "alipay", Type: "alipay",
Name: order.Name, Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount), Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS", TradeStatus: "TRADE_SUCCESS",
} }
signstr2, err := sign.Get(notifyData, config.GetEpayKey()) signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil { if err != nil {
return err return err
} }
// 使用 form-encoded POSTEPay 标准协议格式) formData := map[string]string{
formData := url.Values{ "pid": fmt.Sprintf("%d", notifyData.PID),
"pid": {fmt.Sprintf("%d", notifyData.PID)}, "trade_no": notifyData.TradeNo,
"trade_no": {notifyData.TradeNo}, "out_trade_no": notifyData.OutTradeNo,
"out_trade_no": {notifyData.OutTradeNo}, "type": notifyData.Type,
"type": {notifyData.Type}, "name": notifyData.Name,
"name": {notifyData.Name}, "money": notifyData.Money,
"money": {notifyData.Money}, "trade_status": notifyData.TradeStatus,
"trade_status": {notifyData.TradeStatus}, "sign": signstr2,
"sign": {signstr2}, "sign_type": "MD5",
"sign_type": {"MD5"},
} }
resp, err := http.PostForm(order.NotifyUrl, formData) epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() log.Sugar.Infof("[mq] epay notify_url response status: %d, body: %s", epayResp.StatusCode(), string(epayResp.Body()))
if epayResp.StatusCode() != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body) return errors.New(epayResp.Status())
if err != nil { }
return err if !isCallbackAck(epayResp.Body()) {
return errors.New("not ok")
} }
fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody))
default: default:
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
orderResp := response.OrderNotifyResponse{ orderResp := response.OrderNotifyResponse{
Pid: apiKeyRow.Pid,
TradeId: order.TradeId, TradeId: order.TradeId,
OrderId: order.OrderId, OrderId: order.OrderId,
Amount: order.Amount, Amount: order.Amount,
@@ -224,7 +252,7 @@ func sendOrderCallback(order *mdb.Orders) error {
BlockTransactionId: order.BlockTransactionId, BlockTransactionId: order.BlockTransactionId,
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
} }
signature, err := sign.Get(orderResp, config.GetApiAuthToken()) signature, err := sign.Get(orderResp, apiKeyRow.SecretKey)
if err != nil { if err != nil {
return err return err
} }
@@ -240,7 +268,7 @@ func sendOrderCallback(order *mdb.Orders) error {
if resp.StatusCode() != http.StatusOK { if resp.StatusCode() != http.StatusOK {
return errors.New(resp.Status()) return errors.New(resp.Status())
} }
if string(resp.Body()) != "ok" { if !isCallbackAck(resp.Body()) {
return errors.New("not ok") return errors.New("not ok")
} }
} }
@@ -248,6 +276,11 @@ func sendOrderCallback(order *mdb.Orders) error {
return nil return nil
} }
func isCallbackAck(body []byte) bool {
ack := strings.ToLower(strings.TrimSpace(string(body)))
return ack == "ok" || ack == "success"
}
func cleanupExpiredTransactionLocks() { func cleanupExpiredTransactionLocks() {
if err := data.CleanupExpiredTransactionLocks(); err != nil { if err := data.CleanupExpiredTransactionLocks(); err != nil {
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err) log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)
+405 -4
View File
@@ -1,6 +1,7 @@
package mq package mq
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -9,10 +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/GMWalletApp/epusdt/util/sign"
) )
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) { func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
@@ -158,6 +160,7 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
BlockTransactionId: "block_callback", BlockTransactionId: "block_callback",
CallbackNum: 1, CallbackNum: 1,
CallBackConfirm: mdb.CallBackConfirmNo, CallBackConfirm: mdb.CallBackConfirmNo,
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
} }
if err := dao.Mdb.Create(order).Error; err != nil { if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err) t.Fatalf("create callback order: %v", err)
@@ -218,6 +221,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
BlockTransactionId: "block_callback_restart", BlockTransactionId: "block_callback_restart",
CallbackNum: 0, CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo, CallBackConfirm: mdb.CallBackConfirmNo,
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
} }
if err := dao.Mdb.Create(order).Error; err != nil { if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err) t.Fatalf("create callback order: %v", err)
@@ -259,6 +263,403 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
} }
} }
func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
http.Error(w, "fail", http.StatusInternalServerError)
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_fail",
OrderId: "order_callback_epay_fail",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_fail",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_fail",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmNo && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
_, _ = io.WriteString(w, " ok\n")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_ok",
OrderId: "order_callback_epay_ok",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_ok",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_ok",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
// Orders must always carry an ApiKeyID (the default key is seeded at
// bootstrap and the signing middleware / EPAY inline flow always
// stamps it). If ApiKeyID is 0, resolveOrderApiKey returns an error —
// the callback won't sign with a guess.
func TestResolveOrderApiKeyRejectsZeroApiKeyID(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
order := &mdb.Orders{
TradeId: "no-api-key-id",
ApiKeyID: 0,
}
if _, err := resolveOrderApiKey(order); err == nil {
t.Fatal("expected error for ApiKeyID=0")
}
}
// Happy path: when ApiKeyID points at an enabled row, resolveOrderApiKey
// returns it.
func TestResolveOrderApiKeyReturnsEnabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
row := &mdb.ApiKey{
Name: "target",
Pid: "target-key",
SecretKey: "target-secret",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
t.Fatalf("create api key: %v", err)
}
order := &mdb.Orders{
TradeId: "happy-path",
ApiKeyID: row.ID,
}
got, err := resolveOrderApiKey(order)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got == nil || got.ID != row.ID {
t.Fatalf("resolved row = %+v, want id=%d", got, row.ID)
}
}
func TestSendOrderCallbackGmpayUsesApiKeySecretByPid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
key := &mdb.ApiKey{
Name: "gmpay-key",
Pid: "9001",
SecretKey: "gmpay-secret-9001",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(key).Error; err != nil {
t.Fatalf("create gmpay api key: %v", err)
}
warningKey := &mdb.ApiKey{
Name: "wrong-key",
Pid: "9002",
SecretKey: "wrong-secret-9002",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(warningKey).Error; err != nil {
t.Fatalf("create wrong api key: %v", err)
}
var received map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_gmpay_sign",
OrderId: "order_gmpay_sign",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_gmpay",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_gmpay_sign",
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send callback: %v", err)
}
if received == nil {
t.Fatal("expected callback payload")
}
if gotPid, _ := received["pid"].(string); gotPid != key.Pid {
t.Fatalf("payload pid = %q, want %q", gotPid, key.Pid)
}
recvSig, _ := received["signature"].(string)
if recvSig == "" {
t.Fatal("payload signature is empty")
}
delete(received, "signature")
calcSig, err := sign.Get(received, key.SecretKey)
if err != nil {
t.Fatalf("calc signature with target secret: %v", err)
}
if recvSig != calcSig {
t.Fatalf("signature mismatch: got %q want %q", recvSig, calcSig)
}
wrongSig, err := sign.Get(received, warningKey.SecretKey)
if err != nil {
t.Fatalf("calc signature with wrong secret: %v", err)
}
if recvSig == wrongSig {
t.Fatal("signature should not match wrong api key secret")
}
}
func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
key := &mdb.ApiKey{
Name: "epay-key",
Pid: "9101",
SecretKey: "epay-secret-9101",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(key).Error; err != nil {
t.Fatalf("create epay api key: %v", err)
}
warningKey := &mdb.ApiKey{
Name: "wrong-epay-key",
Pid: "9102",
SecretKey: "wrong-epay-secret-9102",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(warningKey).Error; err != nil {
t.Fatalf("create wrong epay api key: %v", err)
}
formPayload := map[string]string{}
callbackMethod := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callbackMethod = r.Method
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for k, v := range r.Form {
if len(v) > 0 {
formPayload[k] = v[0]
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_epay_sign",
OrderId: "order_epay_sign",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay",
Token: "USDT",
Name: "VIP",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_sign",
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err)
}
if callbackMethod != http.MethodGet {
t.Fatalf("callback method = %s, want %s", callbackMethod, http.MethodGet)
}
if formPayload["sign_type"] != "MD5" {
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
}
if formPayload["type"] != "alipay" {
t.Fatalf("type = %q, want alipay", formPayload["type"])
}
if formPayload["trade_status"] != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
}
if formPayload["pid"] != key.Pid {
t.Fatalf("form pid = %q, want %q", formPayload["pid"], key.Pid)
}
recvSig := formPayload["sign"]
if recvSig == "" {
t.Fatal("epay sign is empty")
}
signParams := map[string]interface{}{
"pid": formPayload["pid"],
"trade_no": formPayload["trade_no"],
"out_trade_no": formPayload["out_trade_no"],
"type": formPayload["type"],
"name": formPayload["name"],
"money": formPayload["money"],
"trade_status": formPayload["trade_status"],
}
calcSig, err := sign.Get(signParams, key.SecretKey)
if err != nil {
t.Fatalf("calc epay signature with target secret: %v", err)
}
if recvSig != calcSig {
t.Fatalf("epay signature mismatch: got %q want %q", recvSig, calcSig)
}
wrongSig, err := sign.Get(signParams, warningKey.SecretKey)
if err != nil {
t.Fatalf("calc epay signature with wrong secret: %v", err)
}
if recvSig == wrongSig {
t.Fatal("epay signature should not match wrong api key secret")
}
}
func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
_, _ = io.WriteString(w, "success")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_success",
OrderId: "order_callback_epay_success",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_success",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_success",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) { func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
t.Helper() t.Helper()
+70
View File
@@ -0,0 +1,70 @@
// Package notify fans out domain events (order paid, order expired,
// etc.) to every NotificationChannel subscribed to the event. Senders
// are type-keyed (telegram/webhook/email/...) so adding a new push
// method means registering one function; no callers change.
package notify
import (
"encoding/json"
"sync"
"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.
// Config is the channel's Config JSON string (type-specific shape).
type Sender func(config, text string) error
var (
sendersMu sync.RWMutex
senders = map[string]Sender{}
)
// RegisterSender wires a Sender for a channel type. Called once at
// package init of each sender file (e.g. telegram_sender.go).
func RegisterSender(channelType string, s Sender) {
sendersMu.Lock()
senders[channelType] = s
sendersMu.Unlock()
}
func getSender(channelType string) (Sender, bool) {
sendersMu.RLock()
defer sendersMu.RUnlock()
s, ok := senders[channelType]
return s, ok
}
// Dispatch delivers text to every enabled channel subscribed to event.
// Per-channel failures are logged but don't abort the fan-out — one
// broken TG token shouldn't prevent webhook delivery.
func Dispatch(event, text string) {
channels, err := data.ListEnabledChannelsByEvent(event)
if err != nil {
log.Sugar.Errorf("[notify] list channels failed: %v", err)
return
}
for _, ch := range channels {
sender, ok := getSender(ch.Type)
if !ok {
log.Sugar.Warnf("[notify] no sender registered for type=%s (channel_id=%d)", ch.Type, ch.ID)
continue
}
go func(c mdb.NotificationChannel) {
if err := sender(c.Config, text); err != nil {
log.Sugar.Errorf("[notify] send failed type=%s channel_id=%d: %v", c.Type, c.ID, err)
}
}(ch)
}
}
// ParseConfig helper for senders: unmarshal channel Config JSON into
// an arbitrary struct, returning a typed error on invalid JSON.
func ParseConfig(raw string, out interface{}) error {
if raw == "" {
return ErrEmptyConfig
}
return json.Unmarshal([]byte(raw), out)
}
+8
View File
@@ -0,0 +1,8 @@
package notify
import "errors"
var (
ErrNoSender = errors.New("no sender registered for channel type")
ErrEmptyConfig = errors.New("channel config is empty")
)
+147
View File
@@ -0,0 +1,147 @@
package notify
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
tb "gopkg.in/telebot.v3"
)
// TelegramConfig mirrors the Config JSON of a telegram channel row.
type TelegramConfig struct {
BotToken string `json:"bot_token"`
ChatID int64 `json:"chat_id"`
Proxy string `json:"proxy"`
}
func init() {
RegisterSender("telegram", sendTelegram)
}
// botCache caches *tb.Bot instances keyed by bot_token so we don't
// create a new Bot (and call getMe) on every notification.
var (
botCacheMu sync.RWMutex
botCache = map[string]*tb.Bot{}
)
func getOrCreateBot(cfg *TelegramConfig) (*tb.Bot, error) {
botCacheMu.RLock()
bot, ok := botCache[cfg.BotToken]
botCacheMu.RUnlock()
if ok {
return bot, nil
}
settings := tb.Settings{
Token: cfg.BotToken,
Poller: &tb.LongPoller{Timeout: 1 * time.Second},
Synchronous: true,
Offline: true,
}
if cfg.Proxy != "" {
settings.URL = cfg.Proxy
}
bot, err := tb.NewBot(settings)
if err != nil {
return nil, err
}
botCacheMu.Lock()
botCache[cfg.BotToken] = bot
botCacheMu.Unlock()
return bot, nil
}
// ParseTelegramConfig decodes telegram channel config and supports
// chat_id in either numeric or string form so API clients can send
// both JSON number and quoted text.
func ParseTelegramConfig(raw string) (*TelegramConfig, error) {
if strings.TrimSpace(raw) == "" {
return nil, ErrEmptyConfig
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, err
}
cfg := &TelegramConfig{}
cfg.BotToken = pickFirstString(payload, "bot_token", "botToken", "bot-token")
if cfg.BotToken == "" {
return nil, fmt.Errorf("telegram config.bot_token required")
}
chatRaw, ok := pickFirstValue(payload, "chat_id", "chatId", "chat-id")
if !ok {
return nil, fmt.Errorf("telegram config.chat_id required")
}
chatID, err := parseTelegramChatID(chatRaw)
if err != nil {
return nil, err
}
cfg.ChatID = chatID
cfg.Proxy = pickFirstString(payload, "proxy", "proxy_url", "proxyUrl")
return cfg, nil
}
func pickFirstString(payload map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := payload[k].(string); ok {
s := strings.TrimSpace(v)
if s != "" {
return s
}
}
}
return ""
}
func pickFirstValue(payload map[string]interface{}, keys ...string) (interface{}, bool) {
for _, k := range keys {
if v, ok := payload[k]; ok {
return v, true
}
}
return nil, false
}
func parseTelegramChatID(v interface{}) (int64, error) {
switch t := v.(type) {
case float64:
return int64(t), nil
case json.Number:
return t.Int64()
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, fmt.Errorf("telegram config.chat_id required")
}
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("telegram config.chat_id invalid: %w", err)
}
return id, nil
default:
return 0, fmt.Errorf("telegram config.chat_id invalid type")
}
}
func sendTelegram(configJSON, text string) error {
cfg, err := ParseTelegramConfig(configJSON)
if err != nil {
return err
}
bot, err := getOrCreateBot(cfg)
if err != nil {
return err
}
_, err = bot.Send(&tb.User{ID: cfg.ChatID}, text, &tb.SendOptions{ParseMode: tb.ModeHTML})
return err
}
+53
View File
@@ -0,0 +1,53 @@
package notify
import "testing"
func TestParseTelegramConfigAcceptsNumericChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":123456789}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.BotToken != "123:ABC" {
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
}
if cfg.ChatID != 123456789 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, 123456789)
}
}
func TestParseTelegramConfigAcceptsStringChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":"-1001234567890"}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.ChatID != -1001234567890 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
}
}
func TestParseTelegramConfigRejectsInvalidChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":"not-a-number"}`
_, err := ParseTelegramConfig(raw)
if err == nil {
t.Fatal("expected parse error for invalid chat_id")
}
}
func TestParseTelegramConfigAcceptsCamelCaseKeys(t *testing.T) {
raw := `{"botToken":"123:ABC","chatId":"-1001234567890","proxyUrl":"http://127.0.0.1:7890"}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.BotToken != "123:ABC" {
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
}
if cfg.ChatID != -1001234567890 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
}
if cfg.Proxy != "http://127.0.0.1:7890" {
t.Fatalf("proxy = %q, want %q", cfg.Proxy, "http://127.0.0.1:7890")
}
}
+892
View File
@@ -0,0 +1,892 @@
package route
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
const (
testAdminUsername = "admin"
testAdminPassword = "test-admin-pass-123"
)
// setupAdminTestEnv sets up the test environment with a seeded admin user
// and returns the echo instance and a valid JWT token.
func setupAdminTestEnv(t *testing.T) (*echo.Echo, string) {
t.Helper()
e := setupTestEnv(t)
// Seed admin user with a known password.
hash, err := data.HashPassword(testAdminPassword)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
// Login to obtain a JWT for subsequent authenticated requests.
token := adminLogin(t, e, testAdminUsername, testAdminPassword)
return e, token
}
// adminLogin performs a login request and returns the JWT token.
func adminLogin(t *testing.T, e *echo.Echo, username, password string) string {
t.Helper()
body := map[string]interface{}{
"username": username,
"password": password,
}
rec := doPost(e, "/admin/api/v1/auth/login", body)
if rec.Code != http.StatusOK {
t.Fatalf("login failed: status=%d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("login unmarshal: %v", err)
}
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("login response missing data field: %s", rec.Body.String())
}
token, ok := dataObj["token"].(string)
if !ok || token == "" {
t.Fatalf("login response missing token: %s", rec.Body.String())
}
return token
}
// doGetAdmin sends an authenticated GET request with a Bearer JWT.
func doGetAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPostAdmin sends an authenticated POST request with a Bearer JWT.
func doPostAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPatchAdmin sends an authenticated PATCH request with a Bearer JWT.
func doPatchAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doDeleteAdmin sends an authenticated DELETE request with a Bearer JWT.
func doDeleteAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, path, nil)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// doPutAdmin sends an authenticated PUT request with a Bearer JWT.
func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
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()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
code, _ := resp["status_code"].(float64)
if code != 200 {
t.Fatalf("expected status_code=200, got %v: %s", code, rec.Body.String())
}
return resp
}
// assertUnauthorized asserts that a request without a valid JWT returns 401.
func assertUnauthorized(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestAdminLogin_Success verifies correct credentials return 200 + JWT.
func TestAdminLogin_Success(t *testing.T) {
e := setupTestEnv(t)
hash, _ := data.HashPassword(testAdminPassword)
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": testAdminPassword,
})
t.Logf("Login response: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj["token"] == "" || dataObj["token"] == nil {
t.Fatal("expected non-empty token in login response")
}
}
// TestAdminLogin_WrongPassword verifies wrong credentials return 4xx.
func TestAdminLogin_WrongPassword(t *testing.T) {
e := setupTestEnv(t)
hash, _ := data.HashPassword(testAdminPassword)
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
})
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": "wrong-password",
})
t.Logf("Login wrong-password response: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code == 200 {
t.Fatal("expected failure for wrong password, got 200")
}
}
}
// TestAdminLogin_MissingFields verifies missing required fields return 4xx.
func TestAdminLogin_MissingFields(t *testing.T) {
e := setupTestEnv(t)
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
// password missing
})
t.Logf("Login missing-fields response: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code == 200 {
t.Fatal("expected validation failure, got code=200")
}
}
}
// TestAdminProtectedRoute_NoToken verifies protected routes reject unauthenticated requests.
func TestAdminProtectedRoute_NoToken(t *testing.T) {
e := setupTestEnv(t)
protectedRoutes := []string{
"/admin/api/v1/auth/me",
"/admin/api/v1/api-keys",
"/admin/api/v1/chains",
"/admin/api/v1/wallets",
"/admin/api/v1/orders",
"/admin/api/v1/dashboard/overview",
"/admin/api/v1/settings",
"/admin/api/v1/rpc-nodes",
"/admin/api/v1/notification-channels",
}
for _, path := range protectedRoutes {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("GET %s → %d", path, rec.Code)
assertUnauthorized(t, rec)
}
}
// TestAdminMe verifies the /auth/me route returns current user info.
func TestAdminMe(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/auth/me", token)
t.Logf("Me response: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
if dataObj["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, dataObj["username"])
}
}
// TestAdminLogout verifies the /auth/logout route succeeds with a valid token.
func TestAdminLogout(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/auth/logout", nil, token)
t.Logf("Logout response: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminChangePassword verifies the change-password route works correctly.
func TestAdminChangePassword(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
"old_password": testAdminPassword,
"new_password": "new-pass-456",
}, token)
t.Logf("ChangePassword response: %s", rec.Body.String())
assertOK(t, rec)
// Verify old password no longer works.
rec2 := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
"username": testAdminUsername,
"password": testAdminPassword,
})
var resp2 map[string]interface{}
json.Unmarshal(rec2.Body.Bytes(), &resp2)
code2, _ := resp2["code"].(float64)
if code2 == 200 {
t.Fatal("old password should no longer work after change")
}
}
// 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,
// stats, rotate, and delete for API keys.
func TestAdminApiKeys_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — should contain seeded keys from setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/api-keys", token)
t.Logf("ListApiKeys: %s", rec.Body.String())
assertOK(t, rec)
// Create a new key.
rec = doPostAdmin(e, "/admin/api/v1/api-keys", map[string]interface{}{
"name": "test-created-key",
}, token)
t.Logf("CreateApiKey: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
keyID := dataObj["id"]
if keyID == nil {
t.Fatal("CreateApiKey response missing id")
}
keyIDStr := fmt.Sprintf("%.0f", keyID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, map[string]interface{}{
"name": "renamed-key",
}, token)
t.Logf("UpdateApiKey: %s", rec.Body.String())
assertOK(t, rec)
// Get secret.
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/secret", token)
t.Logf("GetApiKeySecret: %s", rec.Body.String())
assertOK(t, rec)
// Get stats.
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/stats", token)
t.Logf("GetApiKeyStats: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/status", map[string]interface{}{
"status": 2, // disable
}, token)
t.Logf("ChangeApiKeyStatus: %s", rec.Body.String())
assertOK(t, rec)
// Rotate secret.
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/rotate-secret", nil, token)
t.Logf("RotateApiKeySecret: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, token)
t.Logf("DeleteApiKey: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Chains ──────────────────────────────────────────────────────────────────
// TestAdminChains_ListAndUpdate verifies listing and updating chains.
func TestAdminChains_ListAndUpdate(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List chains — seeded in setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/chains", token)
t.Logf("ListChains: %s", rec.Body.String())
assertOK(t, rec)
// Update the tron chain display name.
rec = doPatchAdmin(e, "/admin/api/v1/chains/tron", map[string]interface{}{
"display_name": "TRON Updated",
}, token)
t.Logf("UpdateChain: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Chain Tokens ─────────────────────────────────────────────────────────────
// TestAdminChainTokens_CRUD verifies CRUD operations for chain tokens.
func TestAdminChainTokens_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — seeded chain tokens.
rec := doGetAdmin(e, "/admin/api/v1/chain-tokens", token)
t.Logf("ListChainTokens: %s", rec.Body.String())
assertOK(t, rec)
// Create.
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens", map[string]interface{}{
"network": "ethereum",
"symbol": "TEST",
"contract_address": "0xTESTADDRESS",
"decimals": 18,
}, token)
t.Logf("CreateChainToken: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
tokenID := dataObj["id"]
if tokenID == nil {
t.Fatal("CreateChainToken missing id")
}
tokenIDStr := fmt.Sprintf("%.0f", tokenID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, map[string]interface{}{
"symbol": "TEST2",
}, token)
t.Logf("UpdateChainToken: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr+"/status", map[string]interface{}{
"enabled": false,
}, token)
t.Logf("ChangeChainTokenStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, token)
t.Logf("DeleteChainToken: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── RPC Nodes ────────────────────────────────────────────────────────────────
// TestAdminRpcNodes_CRUD verifies CRUD operations for RPC nodes.
func TestAdminRpcNodes_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — empty initially.
rec := doGetAdmin(e, "/admin/api/v1/rpc-nodes", token)
t.Logf("ListRpcNodes: %s", rec.Body.String())
assertOK(t, rec)
// Create.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
"network": "ethereum",
"url": "https://eth-mainnet.example.com",
"type": "http",
}, token)
t.Logf("CreateRpcNode: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
nodeID := dataObj["id"]
if nodeID == nil {
t.Fatal("CreateRpcNode missing id")
}
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
"url": "https://eth-mainnet2.example.com",
}, token)
t.Logf("UpdateRpcNode: %s", rec.Body.String())
assertOK(t, rec)
// Health check — network likely unreachable in test, but route must not 404/500.
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
t.Logf("HealthCheckRpcNode: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusNotFound {
t.Fatalf("health-check route returned 404")
}
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, token)
t.Logf("DeleteRpcNode: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Wallets ─────────────────────────────────────────────────────────────────
// TestAdminWallets_CRUD verifies wallet management routes.
func TestAdminWallets_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — two wallets seeded in setupTestEnv.
rec := doGetAdmin(e, "/admin/api/v1/wallets", token)
t.Logf("ListWallets: %s", rec.Body.String())
resp := assertOK(t, rec)
_ = resp
// Add a new wallet.
rec = doPostAdmin(e, "/admin/api/v1/wallets", map[string]interface{}{
"network": "ethereum",
"address": "0xTestEthAddress001",
}, token)
t.Logf("AddWallet: %s", rec.Body.String())
resp2 := assertOK(t, rec)
dataObj, _ := resp2["data"].(map[string]interface{})
walletID := dataObj["id"]
if walletID == nil {
t.Fatal("AddWallet missing id")
}
walletIDStr := fmt.Sprintf("%.0f", walletID.(float64))
// Get single wallet.
rec = doGetAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
t.Logf("GetWallet: %s", rec.Body.String())
assertOK(t, rec)
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, map[string]interface{}{
"address": "0xTestEthAddress002",
}, token)
t.Logf("UpdateWallet: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/wallets/"+walletIDStr+"/status", map[string]interface{}{
"status": 2, // disable
}, token)
t.Logf("ChangeWalletStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
t.Logf("DeleteWallet: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminWallets_BatchImport verifies batch wallet import.
func TestAdminWallets_BatchImport(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/wallets/batch-import", map[string]interface{}{
"network": "ethereum",
"addresses": []string{"0xBatchAddr001", "0xBatchAddr002"},
}, token)
t.Logf("BatchImportWallets: %s", rec.Body.String())
assertOK(t, rec)
}
// ─── Orders ──────────────────────────────────────────────────────────────────
// TestAdminOrders_List verifies listing orders (empty initially).
func TestAdminOrders_List(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders", token)
t.Logf("ListOrders: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminOrders_Export verifies the export endpoint is accessible.
func TestAdminOrders_Export(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders/export", token)
t.Logf("ExportOrders: status=%d", rec.Code)
// Should return 200 (even with empty result set).
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_GetNotFound verifies a non-existent trade_id returns a
// non-200 business code (not a 404 or 500 HTTP error).
func TestAdminOrders_GetNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doGetAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id", token)
t.Logf("GetOrder (not found): status=%d body=%s", rec.Code, rec.Body.String())
// Should not be a server error.
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_CloseNotFound verifies closing a non-existent order returns
// a graceful error (not 500).
func TestAdminOrders_CloseNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/close", nil, token)
t.Logf("CloseOrder (not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/mark-paid", nil, token)
t.Logf("MarkOrderPaid (not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/resend-callback", nil, token)
t.Logf("ResendCallback (not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
func TestAdminDashboard_AllRoutes(t *testing.T) {
e, token := setupAdminTestEnv(t)
routes := []string{
"/admin/api/v1/dashboard/overview",
"/admin/api/v1/dashboard/asset-trend",
"/admin/api/v1/dashboard/revenue-trend",
"/admin/api/v1/dashboard/order-stats",
"/admin/api/v1/dashboard/recent-orders",
}
for _, path := range routes {
rec := doGetAdmin(e, path, token)
t.Logf("GET %s → %d: %s", path, rec.Code, rec.Body.String())
assertOK(t, rec)
}
}
// ─── Settings ────────────────────────────────────────────────────────────────
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
func TestAdminSettings_ListAndUpsert(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List settings.
rec := doGetAdmin(e, "/admin/api/v1/settings", token)
t.Logf("ListSettings: %s", rec.Body.String())
assertOK(t, rec)
// Upsert a setting.
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"},
},
}, token)
t.Logf("UpsertSettings: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
func TestAdminSettings_DeleteNonExistent(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doDeleteAdmin(e, "/admin/api/v1/settings/nonexistent.key", token)
t.Logf("DeleteSetting (nonexistent): status=%d body=%s", rec.Code, rec.Body.String())
// Should not be a server error.
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// ─── Notification Channels ───────────────────────────────────────────────────
// TestAdminNotificationChannels_CRUD verifies notification channel routes.
func TestAdminNotificationChannels_CRUD(t *testing.T) {
e, token := setupAdminTestEnv(t)
// List — empty initially.
rec := doGetAdmin(e, "/admin/api/v1/notification-channels", token)
t.Logf("ListNotificationChannels: %s", rec.Body.String())
assertOK(t, rec)
// Create a webhook channel.
rec = doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
"name": "test-webhook",
"type": "webhook",
"config": map[string]interface{}{"url": "http://localhost/webhook"},
"events": map[string]bool{"order.paid": true},
}, token)
t.Logf("CreateNotificationChannel: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
chanID := dataObj["id"]
if chanID == nil {
t.Fatal("CreateNotificationChannel missing id")
}
chanIDStr := fmt.Sprintf("%.0f", chanID.(float64))
// Update.
rec = doPatchAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, map[string]interface{}{
"name": "renamed-webhook",
}, token)
t.Logf("UpdateNotificationChannel: %s", rec.Body.String())
assertOK(t, rec)
// Change status.
rec = doPostAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr+"/status", map[string]interface{}{
"enabled": false,
}, token)
t.Logf("ChangeNotificationChannelStatus: %s", rec.Body.String())
assertOK(t, rec)
// Delete.
rec = doDeleteAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, token)
t.Logf("DeleteNotificationChannel: %s", rec.Body.String())
assertOK(t, rec)
}
// TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility verifies
// the admin API accepts common frontend telegram payload variants:
// - type in mixed case ("Telegram")
// - config in camelCase keys (botToken/chatId)
// - events as string array
func TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
"name": "frontend-telegram",
"type": "Telegram",
"config": map[string]interface{}{
"botToken": "123:ABC",
"chatId": "-1001234567890",
},
"events": []string{"order.paid", "order.expired"},
}, token)
t.Logf("CreateFrontendStyleTelegramChannel: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("missing data object: %s", rec.Body.String())
}
eventsRaw, _ := dataObj["events"].(string)
if eventsRaw == "" {
t.Fatalf("missing events json in response data: %s", rec.Body.String())
}
var events map[string]bool
if err := json.Unmarshal([]byte(eventsRaw), &events); err != nil {
t.Fatalf("unmarshal events: %v", err)
}
if !events["order.paid"] || !events["order.expired"] {
t.Fatalf("events not normalized as expected: %+v", events)
}
}
// ─── Orders list-with-sub ─────────────────────────────────────────────────────
// TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel verifies that
// sub-orders do NOT appear at the top level of /orders/list-with-sub and are
// instead nested inside their parent's sub_orders array.
func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
e, token := setupAdminTestEnv(t)
// Seed Ethereum wallet and chain_token so SwitchNetwork can allocate
// a sub-order on that network. The chain record is already seeded by
// MdbTableInit; only the wallet address and token are needed here.
dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Status: mdb.TokenStatusEnable,
})
if err := dao.Mdb.Where(mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT"}).
FirstOrCreate(&mdb.ChainToken{
Network: mdb.NetworkEthereum, Symbol: "USDT",
ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
Decimals: 6, Enabled: true,
}).Error; err != nil {
t.Fatalf("seed ethereum USDT token: %v", err)
}
// Create a parent order via the gmpay API.
parentBody := signBody(map[string]interface{}{
"order_id": "listsubtest-parent",
"amount": 5.0,
"currency": "CNY",
"token": "USDT",
"network": "tron",
"notify_url": "https://merchant.example/callback",
"redirect_url": "https://merchant.example/redirect",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
t.Logf("CreateParent: %s", rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("create parent: got %d: %s", rec.Code, rec.Body.String())
}
var parentResp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &parentResp)
parentTradeId := parentResp["data"].(map[string]interface{})["trade_id"].(string)
// Switch to Ethereum to create a sub-order (no signature required).
rec = doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": parentTradeId,
"token": "USDT",
"network": "ethereum",
})
t.Logf("SwitchNetwork: %s", rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("switch network: got %d: %s", rec.Code, rec.Body.String())
}
var switchResp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &switchResp)
subTradeId := switchResp["data"].(map[string]interface{})["trade_id"].(string)
// Fetch list-with-sub.
rec = doGetAdmin(e, "/admin/api/v1/orders/list-with-sub", token)
t.Logf("ListWithSub: %s", rec.Body.String())
resp := assertOK(t, rec)
dataObj, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("missing data object: %s", rec.Body.String())
}
listRaw, _ := dataObj["list"].([]interface{})
// Collect all trade_ids at the top level and in sub_orders.
topLevelIds := map[string]bool{}
subOrderIds := map[string]bool{}
for _, item := range listRaw {
entry := item.(map[string]interface{})
tradeId := entry["trade_id"].(string)
topLevelIds[tradeId] = true
if subs, ok := entry["sub_orders"].([]interface{}); ok {
for _, s := range subs {
sub := s.(map[string]interface{})
subOrderIds[sub["trade_id"].(string)] = true
}
}
}
// Sub-order must NOT appear at the top level.
if topLevelIds[subTradeId] {
t.Fatalf("sub-order trade_id=%s appears at top level — should be nested only", subTradeId)
}
// Parent must appear at the top level.
if !topLevelIds[parentTradeId] {
t.Fatalf("parent trade_id=%s missing from top level", parentTradeId)
}
// Sub-order must appear under the parent's sub_orders.
if !subOrderIds[subTradeId] {
t.Fatalf("sub-order trade_id=%s not found in any sub_orders array", subTradeId)
}
}
+164 -63
View File
@@ -2,84 +2,60 @@ package route
import ( import (
"bytes" "bytes"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"github.com/assimon/luuu/config" "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/mdb" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
) )
// RegisterRoute 路由注册 // RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) { func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error { e.POST("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt") return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
}) })
payRoute := e.Group("/pay") payRoute := e.Group("/pay")
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/checkout-counter/:trade_id", func(ctx echo.Context) error {
tradeId := ctx.Param("trade_id")
targetURL := fmt.Sprintf("/cashier/%s", tradeId)
return ctx.Redirect(http.StatusMovedPermanently, targetURL)
})
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
// payment routes // payment routes
paymentRoute := e.Group("/payments") paymentRoute := e.Group("/payments")
// for epusdt
epusdtV1 := paymentRoute.Group("/epusdt/v1")
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
// add default token and currency for old plugin
body := make(map[string]interface{})
if err := ctx.Bind(&body); err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
if _, ok := body["token"]; !ok {
body["token"] = "usdt"
}
if _, ok := body["currency"]; !ok {
body["currency"] = "cny"
}
if _, ok := body["network"]; !ok {
body["network"] = "tron"
}
ctx.Set("request_body", body)
jsonBytes, err := json.Marshal(body)
if err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
ctx.Request().ContentLength = int64(len(jsonBytes))
return comm.Ctrl.CreateTransaction(ctx)
}, middleware.CheckApiSign())
// gmpay v1 routes // gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1") gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign()) gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords) // gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset) // gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken())
// wallet management routes
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
walletV1.POST("/add", comm.Ctrl.AddWallet)
walletV1.GET("/list", comm.Ctrl.ListWallets)
walletV1.GET("/:id", comm.Ctrl.GetWallet)
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
// epay v1 routes // epay v1 routes
//
// Signature uses the pid from the request as the api_keys lookup
// key; the matching row's secret_key plays the role of the legacy
// EPAY "key" value. Env-based (EPAY_PID/EPAY_KEY) fallback was
// removed — the default seeded api_key is always available, and
// having two sources of truth led to inbound/outbound sig mismatch.
epayV1 := paymentRoute.Group("/epay/v1") epayV1 := paymentRoute.Group("/epay/v1")
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error { epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
params := make(map[string]interface{}) params := make(map[string]interface{})
@@ -107,11 +83,11 @@ func RegisterRoute(e *echo.Echo) {
if !ok { if !ok {
return "" return ""
} }
s, ok := v.(string) switch t := v.(type) {
if !ok { case string:
return "" return t
} }
return s return ""
} }
signstr := getString(params, "sign") signstr := getString(params, "sign")
@@ -122,17 +98,29 @@ func RegisterRoute(e *echo.Echo) {
delete(params, "sign") delete(params, "sign")
delete(params, "sign_type") delete(params, "sign_type")
// we need to add pid to params for signature verification pidStr := getString(params, "pid")
params["pid"] = config.GetEpayPid() if pidStr == "" {
return constant.SignatureErr
}
apiKeyRow, err := data.GetEnabledApiKey(pidStr)
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
return constant.SignatureErr
}
checkSignature, err := sign.Get(params, config.GetApiAuthToken()) checkSignature, err := sign.Get(params, apiKeyRow.SecretKey)
if err != nil { if err != nil {
return constant.SignatureErr return constant.SignatureErr
} }
if checkSignature != signstr { if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signstr)) != 1 {
return constant.SignatureErr return constant.SignatureErr
} }
if !middleware.IsIPWhitelisted(apiKeyRow.IpWhitelist, ctx.RealIP()) {
return constant.SignatureErr
}
_ = data.TouchApiKeyUsage(apiKeyRow.ID)
money := getString(params, "money") money := getString(params, "money")
name := getString(params, "name") name := getString(params, "name")
notifyURL := getString(params, "notify_url") notifyURL := getString(params, "notify_url")
@@ -145,9 +133,9 @@ func RegisterRoute(e *echo.Echo) {
} }
body := map[string]interface{}{ body := map[string]interface{}{
"token": "usdt", "token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
"currency": "cny", "currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
"network": "tron", "network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
"amount": amountFloat, "amount": amountFloat,
"notify_url": notifyURL, "notify_url": notifyURL,
"order_id": outTradeNo, "order_id": outTradeNo,
@@ -158,6 +146,8 @@ func RegisterRoute(e *echo.Echo) {
} }
ctx.Set("request_body", body) ctx.Set("request_body", body)
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
jsonBytes, err := json.Marshal(body) jsonBytes, err := json.Marshal(body)
if err != nil { if err != nil {
@@ -170,6 +160,117 @@ func RegisterRoute(e *echo.Echo) {
ctx.Request().Header.Set("Content-Type", "application/json") ctx.Request().Header.Set("Content-Type", "application/json")
return comm.Ctrl.CreateTransactionAndRedirect(ctx) return comm.Ctrl.CreateTransactionAndRedirect(ctx)
}) })
registerAdminRoutes(e)
}
// registerAdminRoutes wires the management console API surface under
// /admin/api/v1. Everything except /auth/login requires a valid JWT.
func registerAdminRoutes(e *echo.Echo) {
// CORS for the management console. The admin SPA is commonly served
// from a different origin (local dev, CDN, etc.), so allow any origin
// but require explicit echoing — browsers refuse wildcard + credentials.
adminCORS := echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOriginFunc: func(origin string) (bool, error) { return true, nil },
AllowMethods: []string{
http.MethodGet,
http.MethodPost,
http.MethodPatch,
http.MethodPut,
http.MethodDelete,
http.MethodOptions,
},
AllowHeaders: []string{
echo.HeaderOrigin,
echo.HeaderContentType,
echo.HeaderAuthorization,
echo.HeaderAccept,
echo.HeaderXRequestedWith,
},
AllowCredentials: true,
MaxAge: 86400,
})
adminV1 := e.Group("/admin/api/v1", adminCORS)
// 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())
authed.POST("/auth/logout", admin.Ctrl.Logout)
authed.GET("/auth/me", admin.Ctrl.Me)
authed.POST("/auth/password", admin.Ctrl.ChangePassword)
// API key management
authed.GET("/api-keys", admin.Ctrl.ListApiKeys)
authed.POST("/api-keys", admin.Ctrl.CreateApiKey)
authed.PATCH("/api-keys/:id", admin.Ctrl.UpdateApiKey)
authed.POST("/api-keys/:id/status", admin.Ctrl.ChangeApiKeyStatus)
authed.POST("/api-keys/:id/rotate-secret", admin.Ctrl.RotateApiKeySecret)
authed.DELETE("/api-keys/:id", admin.Ctrl.DeleteApiKey)
authed.GET("/api-keys/:id/stats", admin.Ctrl.GetApiKeyStats)
authed.GET("/api-keys/:id/secret", admin.Ctrl.GetApiKeySecret)
// Notification channels
authed.GET("/notification-channels", admin.Ctrl.ListNotificationChannels)
authed.POST("/notification-channels", admin.Ctrl.CreateNotificationChannel)
authed.PATCH("/notification-channels/:id", admin.Ctrl.UpdateNotificationChannel)
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
// Chains
authed.GET("/chains", admin.Ctrl.ListChains)
authed.PATCH("/chains/:network", admin.Ctrl.UpdateChain)
// Chain tokens (per-chain token catalog)
authed.GET("/chain-tokens", admin.Ctrl.ListChainTokens)
authed.POST("/chain-tokens", admin.Ctrl.CreateChainToken)
authed.PATCH("/chain-tokens/:id", admin.Ctrl.UpdateChainToken)
authed.POST("/chain-tokens/:id/status", admin.Ctrl.ChangeChainTokenStatus)
authed.DELETE("/chain-tokens/:id", admin.Ctrl.DeleteChainToken)
// RPC nodes
authed.GET("/rpc-nodes", admin.Ctrl.ListRpcNodes)
authed.POST("/rpc-nodes", admin.Ctrl.CreateRpcNode)
authed.PATCH("/rpc-nodes/:id", admin.Ctrl.UpdateRpcNode)
authed.DELETE("/rpc-nodes/:id", admin.Ctrl.DeleteRpcNode)
authed.POST("/rpc-nodes/:id/health-check", admin.Ctrl.HealthCheckRpcNode)
// Wallet (address) management
authed.GET("/wallets", admin.Ctrl.AdminListWallets)
authed.POST("/wallets", admin.Ctrl.AdminAddWallet)
authed.GET("/wallets/:id", admin.Ctrl.AdminGetWallet)
authed.PATCH("/wallets/:id", admin.Ctrl.AdminUpdateWallet)
authed.POST("/wallets/:id/status", admin.Ctrl.AdminChangeWalletStatus)
authed.DELETE("/wallets/:id", admin.Ctrl.AdminDeleteWallet)
authed.POST("/wallets/batch-import", admin.Ctrl.AdminBatchImportWallets)
// Orders
authed.GET("/orders", admin.Ctrl.ListOrders)
authed.GET("/orders/export", admin.Ctrl.ExportOrders)
authed.GET("/orders/list-with-sub", admin.Ctrl.ListOrdersWithSub)
authed.GET("/orders/:trade_id", admin.Ctrl.GetOrder)
authed.GET("/orders/:trade_id/with-sub", admin.Ctrl.GetOrderWithSub)
authed.POST("/orders/:trade_id/close", admin.Ctrl.CloseOrder)
authed.POST("/orders/:trade_id/mark-paid", admin.Ctrl.MarkOrderPaid)
authed.POST("/orders/:trade_id/resend-callback", admin.Ctrl.ResendCallback)
// Dashboard
authed.GET("/dashboard/overview", admin.Ctrl.Overview)
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
// Settings
authed.GET("/settings", admin.Ctrl.ListSettings)
authed.PUT("/settings", admin.Ctrl.UpsertSettings)
authed.DELETE("/settings/:key", admin.Ctrl.DeleteSetting)
} }
+411 -304
View File
@@ -10,12 +10,15 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/sign" "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/labstack/echo/v4"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/gorm/clause"
) )
const testAPIToken = "test-secret-token" const testAPIToken = "test-secret-token"
@@ -28,12 +31,9 @@ func setupTestEnv(t *testing.T) *echo.Echo {
// minimal viper config // minimal viper config
viper.Reset() viper.Reset()
viper.Set("db_type", "sqlite") viper.Set("db_type", "sqlite")
viper.Set("api_auth_token", testAPIToken)
viper.Set("epay_pid", 1)
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")
@@ -54,30 +54,71 @@ func setupTestEnv(t *testing.T) *echo.Echo {
} }
// ensure tables exist (MdbTableInit uses sync.Once, so migrate directly) // ensure tables exist (MdbTableInit uses sync.Once, so migrate directly)
dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}, &mdb.SupportedAsset{}) dao.Mdb.AutoMigrate(
&mdb.Orders{},
&mdb.WalletAddress{},
&mdb.AdminUser{},
&mdb.ApiKey{},
&mdb.Setting{},
&mdb.NotificationChannel{},
&mdb.Chain{},
&mdb.ChainToken{},
&mdb.RpcNode{},
)
// reset the settings cache so stale entries from a prior test don't leak
_ = 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})
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable}) dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable})
// seed supported assets if empty // seed chains so scanners know the test networks are "enabled".
var supportCnt int64 // Use ON CONFLICT DO NOTHING: MdbTableInit (called via DBInit) uses sync.Once
dao.Mdb.Model(&mdb.SupportedAsset{}).Count(&supportCnt) // and may have already seeded these rows into this DB on the first test run.
if supportCnt == 0 { dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.Chain{
dao.Mdb.Create(&[]mdb.SupportedAsset{ {Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true},
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable}, })
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable}, // seed chain_tokens — GetSupportedAssets now reads from this table.
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable}, // Same idempotency rationale as above.
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable}, dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable}, {Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
})
// Seed one universal api_keys row. The test's testAPIToken doubles
// as both pid and secret_key so sign.Get(body, testAPIToken) calls
// stay valid.
dao.Mdb.Create(&mdb.ApiKey{
Name: "test-universal",
Pid: testAPIToken,
SecretKey: testAPIToken,
Status: mdb.ApiKeyStatusEnable,
})
// Additional numeric-PID row for EPAY tests (EPAY pid must be numeric).
dao.Mdb.Create(&mdb.ApiKey{
Name: "test-epay-pid-1",
Pid: "1",
SecretKey: testAPIToken,
Status: mdb.ApiKeyStatusEnable,
}) })
}
e := echo.New() e := echo.New()
RegisterRoute(e) RegisterRoute(e)
@@ -85,6 +126,11 @@ func setupTestEnv(t *testing.T) *echo.Echo {
} }
func signBody(body map[string]interface{}) map[string]interface{} { func signBody(body map[string]interface{}) map[string]interface{} {
// Signature middleware looks up api_keys by the "pid" field and
// uses that row's secret_key as the signing bizKey.
if _, ok := body["pid"]; !ok {
body["pid"] = testAPIToken
}
sig, _ := sign.Get(body, testAPIToken) sig, _ := sign.Get(body, testAPIToken)
body["signature"] = sig body["signature"] = sig
return body return body
@@ -121,40 +167,19 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
return rec return rec
} }
// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network. func TestRootPostRoute(t *testing.T) {
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
body := signBody(map[string]interface{}{ req := httptest.NewRequest(http.MethodPost, "/", nil)
"order_id": "test-tron-001", rec := httptest.NewRecorder()
"amount": 1.00, e.ServeHTTP(rec, req)
"notify_url": "http://localhost/notify",
})
rec := doPost(e, "/payments/epusdt/v1/order/create-transaction", body)
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code) t.Fatalf("expected 200, got %d", rec.Code)
} }
if body := rec.Body.String(); body != "hello epusdt, https://github.com/GMwalletApp/epusdt" {
var resp map[string]interface{} t.Fatalf("unexpected body: %q", body)
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
} }
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in response, got: %v", resp)
}
if data["trade_id"] == nil || data["trade_id"] == "" {
t.Error("expected trade_id in response")
}
if data["receive_address"] != "TTestTronAddress001" {
t.Errorf("expected tron address, got: %v", data["receive_address"])
}
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
} }
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network. // TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
@@ -223,24 +248,6 @@ func TestCreateOrderGmpayV1SolNative(t *testing.T) {
} }
} }
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", testAPIToken)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func doPostWithToken(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("Authorization", testAPIToken)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper() t.Helper()
var resp map[string]interface{} var resp map[string]interface{}
@@ -250,6 +257,183 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac
return resp return resp
} }
// signGmpayFormValues builds a signed url.Values for the GMPAY create-transaction endpoint.
// The GMPAY middleware uses "signature" (not "sign") and "pid" (not numeric pid from EPAY).
func signGmpayFormValues(values url.Values) url.Values {
if values.Get("pid") == "" {
values.Set("pid", testAPIToken)
}
params := make(map[string]interface{})
for k, vs := range values {
if k == "signature" || len(vs) == 0 {
continue
}
params[k] = vs[0]
}
sig, _ := sign.Get(params, testAPIToken)
values.Set("signature", sig)
return values
}
// TestCreateOrderGmpayV1FormData verifies that the GMPAY create-transaction endpoint
// accepts application/x-www-form-urlencoded in addition to JSON.
func TestCreateOrderGmpayV1FormData(t *testing.T) {
e := setupTestEnv(t)
values := signGmpayFormValues(url.Values{
"order_id": {"test-form-001"},
"amount": {"1.00"},
"token": {"usdt"},
"currency": {"cny"},
"network": {"solana"},
"notify_url": {"http://localhost/notify"},
})
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in response, got: %v", resp)
}
if data["trade_id"] == nil || data["trade_id"] == "" {
t.Error("expected trade_id in response")
}
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
}
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/supported-assets
// and returns a map of network → []token for easy assertions.
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("getSupportedNetworks: status=%d body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
rawData, _ := resp["data"].(map[string]interface{})
rawSupports, _ := rawData["supports"].([]interface{})
result := make(map[string][]string, len(rawSupports))
for _, item := range rawSupports {
row := item.(map[string]interface{})
network := row["network"].(string)
rawTokens, _ := row["tokens"].([]interface{})
tokens := make([]string, 0, len(rawTokens))
for _, tok := range rawTokens {
tokens = append(tokens, tok.(string))
}
result[network] = tokens
}
return result
}
// TestGetSupportedAssets_ChainTokenToggle verifies that:
// - disabling a chain_token removes that token (and possibly the whole network)
// from the /supported-assets response
// - re-enabling it brings it back
func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
e := setupTestEnv(t)
// Baseline: tron should appear with USDT and TRX.
before := getSupportedNetworks(t, e)
if _, ok := before["tron"]; !ok {
t.Fatal("expected tron in baseline supported-assets")
}
// Disable the tron USDT chain_token.
dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ? AND symbol = ?", "tron", "USDT").
Update("enabled", false)
after := getSupportedNetworks(t, e)
tronTokens := after["tron"]
for _, tok := range tronTokens {
if tok == "USDT" {
t.Fatal("USDT should be absent after disabling tron USDT chain_token")
}
}
t.Logf("After disabling tron USDT: tron tokens = %v", tronTokens)
// Disable the tron TRX chain_token as well — now tron has no tokens → network disappears.
dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ? AND symbol = ?", "tron", "TRX").
Update("enabled", false)
afterAllDisabled := getSupportedNetworks(t, e)
if _, ok := afterAllDisabled["tron"]; ok {
t.Fatal("tron should disappear from supported-assets when all its chain_tokens are disabled")
}
t.Logf("After disabling all tron tokens: networks = %v", afterAllDisabled)
// Re-enable tron USDT — tron reappears with only USDT.
dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ? AND symbol = ?", "tron", "USDT").
Update("enabled", true)
restored := getSupportedNetworks(t, e)
tronRestored, ok := restored["tron"]
if !ok {
t.Fatal("tron should reappear after re-enabling USDT chain_token")
}
found := false
for _, tok := range tronRestored {
if tok == "USDT" {
found = true
}
}
if !found {
t.Fatalf("expected USDT in restored tron tokens, got %v", tronRestored)
}
t.Logf("After re-enabling tron USDT: tron tokens = %v", tronRestored)
}
// TestGetSupportedAssets_WalletAddressToggle verifies that:
// - disabling ALL wallet addresses for a network removes that network from
// the /supported-assets response (even if chain + tokens are still enabled)
// - re-enabling any address brings the network back
func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
e := setupTestEnv(t)
// Baseline: solana should be present.
before := getSupportedNetworks(t, e)
if _, ok := before["solana"]; !ok {
t.Fatal("expected solana in baseline supported-assets")
}
// Disable the only solana wallet address.
dao.Mdb.Model(&mdb.WalletAddress{}).
Where("network = ?", mdb.NetworkSolana).
Update("status", mdb.TokenStatusDisable)
after := getSupportedNetworks(t, e)
if _, ok := after["solana"]; ok {
t.Fatal("solana should disappear from supported-assets when all its wallet addresses are disabled")
}
t.Logf("After disabling solana wallets: networks = %v", after)
// Re-enable the solana wallet.
dao.Mdb.Model(&mdb.WalletAddress{}).
Where("network = ?", mdb.NetworkSolana).
Update("status", mdb.TokenStatusEnable)
restored := getSupportedNetworks(t, e)
if _, ok := restored["solana"]; !ok {
t.Fatal("solana should reappear after re-enabling its wallet address")
}
t.Logf("After re-enabling solana wallets: solana tokens = %v", restored["solana"])
}
func TestGetSupportedAssetsPublic(t *testing.T) { func TestGetSupportedAssetsPublic(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -291,239 +475,6 @@ func TestGetSupportedAssetsPublic(t *testing.T) {
} }
} }
func TestSupportedAssetCRUD(t *testing.T) {
e := setupTestEnv(t)
// public query list (no auth)
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/records", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public list failed: %v", resp)
}
// create requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arb",
"token": "usdt",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add supported asset failed: %v", resp)
}
created := resp["data"].(map[string]interface{})
assetID := fmt.Sprintf("%.0f", created["id"].(float64))
// get by id is public
req = httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/"+assetID, nil)
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public get by id failed: %v", resp)
}
// update requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/update", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("update supported asset failed: %v", resp)
}
updated := resp["data"].(map[string]interface{})
if updated["network"] != "arbitrum" || updated["token"] != "USDC" {
t.Fatalf("unexpected updated data: %v", updated)
}
// delete requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/delete", nil)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("delete supported asset failed: %v", resp)
}
// recreate after delete should restore soft-deleted row, not unique-conflict
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("recreate after delete failed: %v", resp)
}
}
// TestWalletAddAndList tests adding wallets via API and listing them.
func TestWalletAddAndList(t *testing.T) {
e := setupTestEnv(t)
// Add a solana wallet
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "solana",
"address": "NewSolWallet001",
})
t.Logf("Add: %s", rec.Body.String())
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add wallet failed: %v", resp)
}
// Add a tron wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "tron",
"address": "NewTronWallet001",
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add tron wallet failed: %v", resp)
}
// List all wallets
rec = doGet(e, "/payments/gmpay/v1/wallet/list")
resp = parseResp(t, rec)
wallets := resp["data"].([]interface{})
// 2 seeded + 2 added = 4
if len(wallets) != 4 {
t.Fatalf("expected 4 wallets, got %d: %v", len(wallets), wallets)
}
// List by network
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
resp = parseResp(t, rec)
wallets = resp["data"].([]interface{})
if len(wallets) != 2 {
t.Fatalf("expected 2 solana wallets, got %d", len(wallets))
}
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=tron")
resp = parseResp(t, rec)
wallets = resp["data"].([]interface{})
if len(wallets) != 2 {
t.Fatalf("expected 2 tron wallets, got %d", len(wallets))
}
}
// TestWalletDuplicateRejected tests that adding the same network+address twice fails.
func TestWalletDuplicateRejected(t *testing.T) {
e := setupTestEnv(t)
body := map[string]interface{}{"network": "solana", "address": "DupWallet001"}
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("first add failed: %v", resp)
}
// Same network+address should fail
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
resp = parseResp(t, rec)
if resp["status_code"].(float64) == 200 {
t.Fatal("expected duplicate to be rejected")
}
t.Logf("Duplicate rejected: %v", resp["message"])
// Same address, different network should succeed
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "tron",
"address": "DupWallet001",
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("same address on different network should succeed: %v", resp)
}
}
// TestWalletStatusAndDelete tests enable/disable/delete operations.
func TestWalletStatusAndDelete(t *testing.T) {
e := setupTestEnv(t)
// Add a wallet
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "solana",
"address": "StatusTestWallet",
})
resp := parseResp(t, rec)
wallet := resp["data"].(map[string]interface{})
walletID := fmt.Sprintf("%.0f", wallet["id"].(float64))
// Get wallet
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("get wallet failed: %v", resp)
}
// Disable wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/status", map[string]interface{}{
"status": 2,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("disable wallet failed: %v", resp)
}
// Verify disabled — should not appear in available list
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
resp = parseResp(t, rec)
wallets := resp["data"].([]interface{})
for _, w := range wallets {
wm := w.(map[string]interface{})
if wm["address"] == "StatusTestWallet" && wm["status"].(float64) != 2 {
t.Error("wallet should be disabled")
}
}
// Delete wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/delete", nil)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("delete wallet failed: %v", resp)
}
// Verify deleted
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
resp = parseResp(t, rec)
// Should return not found
if resp["status_code"].(float64) == 200 {
data := resp["data"].(map[string]interface{})
if data["id"].(float64) > 0 {
t.Error("wallet should be deleted")
}
}
}
// TestWalletAuthRequired tests that wallet APIs require auth token.
func TestWalletAuthRequired(t *testing.T) {
e := setupTestEnv(t)
// No auth header — should not return success
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/wallet/list", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// The response should indicate auth failure (not 200 success)
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
// echo may return plain text error
if rec.Code == http.StatusOK {
t.Error("expected auth failure without token")
}
t.Logf("Auth rejected (non-JSON): status=%d body=%s", rec.Code, rec.Body.String())
return
}
statusCode, _ := resp["status_code"].(float64)
if statusCode == 200 {
t.Error("expected auth failure without token")
}
t.Logf("Auth rejected: %v", resp)
}
// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix. // TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
func TestCreateOrderNetworkIsolation(t *testing.T) { func TestCreateOrderNetworkIsolation(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -602,3 +553,159 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location")) t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
} }
} }
// TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a
// graceful JSON error (not 500) when the trade_id doesn't exist.
func TestCheckStatus_NotFound(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
t.Fatalf("route returned 404 with empty body — route may not be registered")
}
}
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
// and a status field when the order exists.
func TestCheckStatus_WithOrder(t *testing.T) {
e := setupTestEnv(t)
// Create an order first via the GMPAY route.
body := signBody(map[string]interface{}{
"order_id": "check-status-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "tron",
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if createRec.Code != http.StatusOK {
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
}
var createResp map[string]interface{}
json.Unmarshal(createRec.Body.Bytes(), &createResp)
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeId == "" {
t.Fatal("no trade_id in create response")
}
// Now check status.
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["data"] == nil {
t.Fatal("expected data in check-status response")
}
}
// TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id
// with an unknown trade_id does not return an empty 404 (which would mean the
// route is not registered). When the static HTML template is present the
// controller renders it with a 404 status; when it is absent (test env) the
// controller returns a 500 with a descriptive body — both outcomes are
// acceptable because the route IS registered and functional.
func TestCheckoutCounter_NotFound(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckoutCounter(not found): status=%d content-type=%s body=%s",
rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
// A completely empty 404 means the route is not registered at all.
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
t.Fatalf("route not registered (404 with empty body)")
}
// A 500 whose body mentions a missing file is expected in test environments
// where the static directory is not present.
if rec.Code >= 500 && rec.Body.Len() == 0 {
t.Fatalf("unexpected server error with empty body: %d", rec.Code)
}
}
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
// required fields and returns a graceful error when they are missing.
func TestSwitchNetwork_MissingFields(t *testing.T) {
e := setupTestEnv(t)
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
// trade_id, token, network are all missing
})
t.Logf("SwitchNetwork(missing fields): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
// Must return a non-200 business code for validation failure.
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code == 200 {
t.Fatal("expected validation error for missing fields, got code=200")
}
}
// TestSwitchNetwork_OrderNotFound verifies that /pay/switch-network returns a
// graceful error when the referenced trade_id doesn't exist.
func TestSwitchNetwork_OrderNotFound(t *testing.T) {
e := setupTestEnv(t)
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": "nonexistent-trade-id",
"token": "USDT",
"network": "tron",
})
t.Logf("SwitchNetwork(not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
}
}
// TestSwitchNetwork_WithOrder verifies /pay/switch-network can create a
// sub-order when given a valid parent trade_id.
func TestSwitchNetwork_WithOrder(t *testing.T) {
e := setupTestEnv(t)
// Create a parent order on solana.
createBody := signBody(map[string]interface{}{
"order_id": "switch-net-parent-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
t.Fatalf("create parent order failed: %d %s", createRec.Code, createRec.Body.String())
}
var createResp map[string]interface{}
json.Unmarshal(createRec.Body.Bytes(), &createResp)
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeId == "" {
t.Fatal("no trade_id in create response")
}
// Switch to tron.
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeId,
"token": "USDT",
"network": "tron",
})
t.Logf("SwitchNetwork(valid): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["data"] == nil {
t.Fatal("expected data in switch-network response")
}
}
-547
View File
@@ -1,547 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GMWallet — Crypto Payment</title>
<link rel="icon" href="https://www.gmwallet.app/favicon.png" />
<link rel="preconnect" href="https://cdn.jsdmirror.com" crossorigin />
<link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fontsource-variable/noto-sans-sc/index.css" />
<link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fontsource-variable/nunito/index.css" />
<script src="https://cdn.jsdmirror.com/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme inline {
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
--font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground:var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground:var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground:var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success);
--color-warning: var(--warning);
--animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
}
/* ── Design tokens ── */
:root, [data-theme="light"] {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground:oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground:oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground:oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--radius: 0.875rem;
--success: oklch(0.721 0.193 143.56);
--warning: oklch(0.741 0.188 55.68);
}
[data-theme="dark"] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground:oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground:oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground:oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--success: oklch(0.747 0.201 143.5);
--warning: oklch(0.762 0.182 61.47);
}
/* ── Ambient blobs ── */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes fill-bar {
from { width: 0%; }
to { width: 100%; }
}
@keyframes slide-out-l {
from { transform: translateX(0); }
to { transform: translateX(calc(-100% - 20px)); }
}
@keyframes slide-out-r {
from { transform: translateX(0); }
to { transform: translateX(calc(100% + 20px)); }
}
@keyframes slide-in-r {
from { transform: translateX(calc(100% + 20px)); }
to { transform: translateX(0); }
}
@keyframes slide-in-l {
from { transform: translateX(calc(-100% - 20px)); }
to { transform: translateX(0); }
}
@keyframes blob-drift {
0% { transform: translate(0,0) scale(1); }
50% { transform: translate(30px,-20px) scale(1.06); }
100% { transform: translate(-20px,30px) scale(0.96); }
}
@layer components {
/* Card */
.card {
@apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300;
}
/* Chip / pill button */
.chip {
@apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all;
}
.chip:hover { @apply bg-accent border-ring; }
.chip:active { @apply opacity-60; }
.chip.w-9 { @apply p-0; }
/* Copy rows */
.row {
@apply flex items-center px-4 py-3.5 transition-colors;
}
.row:active { @apply bg-muted; }
/* Icon button */
.icon-btn {
@apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors;
}
.icon-btn:hover { @apply bg-accent text-card-foreground border-border; }
/* Primary button */
.btn-primary {
@apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity];
}
.btn-primary:hover { @apply bg-primary/90; }
.btn-primary:active { @apply scale-[0.97] opacity-90; }
.btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; }
/* Secondary button */
.btn-secondary {
@apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all;
}
.btn-secondary:hover { @apply bg-accent border-ring; }
.btn-secondary:active { @apply opacity-70; }
/* State icon circle */
.state-icon {
@apply size-20 rounded-full flex items-center justify-center mx-auto;
}
/* Dropdown menu */
.menu {
@apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.menu--up { transform: translateY(8px); }
.menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; }
.menu-item {
@apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors;
}
.menu-item:hover { @apply bg-accent; }
.menu-item.is-selected { @apply text-primary font-semibold; }
.menu-item + .menu-item { @apply border-t border-border; }
.select-trigger.is-open { opacity: 0.8; }
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
/* QR wrapper */
.qr-wrapper {
@apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block;
}
/* Timer SVG rings */
.timer-ring-track { stroke: var(--border); transition: stroke .3s; }
.timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; }
.timer-icon { @apply text-muted-foreground; }
/* Flippable status card */
.status-card {
position: relative;
perspective: 1600px;
transition: height 0.38s cubic-bezier(.22,1,.36,1);
}
.status-card-inner {
position: relative; width: 100%;
transform-style: preserve-3d;
transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
}
.status-card.is-flipped .status-card-inner { transform: rotateY(180deg); }
.status-face {
@apply absolute inset-0 w-full;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform-style: preserve-3d;
}
.status-face-front { z-index: 2; }
.status-face-back { transform: rotateY(180deg); }
.state-panel-shell { @apply pt-px; }
.state-screen { @apply hidden; }
.state-screen.is-active { @apply flex; }
}
/* ── Panel viewport: hide scrollbars ── */
#panel-viewport { scrollbar-width: none; }
#panel-viewport::-webkit-scrollbar { display: none; }
/* ── Toast ── */
#toast {
@apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999;
@apply bg-foreground/95 text-background;
@apply dark:bg-card/95 dark:text-card-foreground;
transform: translateX(-50%) translateY(12px);
transition: opacity 0.22s, transform 0.22s;
}
@keyframes state-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
::view-transition-old(root) { z-index: 1; }
::view-transition-new(root) { z-index: 9; }
@media (prefers-reduced-motion: reduce) {
.status-card, .status-card-inner, .status-face, .timer-ring-progress {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
</style>
<script defer src="https://cdn.jsdmirror.com/npm/lucide@latest/dist/umd/lucide.min.js"></script>
<script defer src="https://cdn.jsdmirror.com/npm/qrcodejs@latest/qrcode.min.js"></script>
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
</head>
<body
class="bg-background text-foreground min-h-screen flex flex-col items-center font-sans transition-colors duration-300">
<!-- Nav bar -->
<header class="w-full max-w-sm px-4 pt-8 pb-6 flex items-center justify-between relative z-10">
<div class="flex items-center gap-2">
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-sm shadow-sm" />
<span class="text-lg font-semibold tracking-tight text-card-foreground">GM Pay</span>
</div>
<div class="flex items-center gap-2">
<!-- Language pill -->
<div class="select-wrap relative select-none" id="dd-lang">
<div class="select-trigger chip flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
<span class="text-sm font-medium" id="lang-label">EN</span>
<i data-lucide="chevron-down"
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11"
height="11" stroke-width="2.5"></i>
</div>
<div class="select-menu menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-40" id="dd-lang-menu">
<div class="select-option menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸&nbsp; English
</div>
<div class="select-option menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳&nbsp; 中文</div>
<div class="select-option menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵&nbsp; 日本語</div>
<div class="select-option menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷&nbsp; 한국어</div>
<div class="select-option menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰&nbsp; 繁體中文</div>
<div class="select-option menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺&nbsp; Русский</div>
</div>
</div>
<!-- Theme toggle -->
<button class="chip w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)"
title="Toggle theme">
<i data-lucide="moon" id="icon-moon" class="hidden" width="16" height="16" stroke-width="1.8"></i>
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
</button>
<!-- Ambient background blobs -->
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] size-[420px] -top-30 -left-25 bg-[color-mix(in_srgb,var(--muted)_60%,transparent)]">
</div>
<div
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-6s] size-[340px] -bottom-20 -right-15 bg-[color-mix(in_srgb,var(--success)_14%,transparent)]">
</div>
<div
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-12s] size-[260px] top-[45%] left-[55%] bg-[color-mix(in_srgb,var(--warning)_10%,transparent)]">
</div>
</div>
</div>
</header>
<main class="flex-1 flex flex-col items-center justify-center w-full max-w-sm px-5 relative">
<!-- Steps -->
<div id="step-progress" class="flex items-center gap-2 w-full mb-4 select-none">
<div id="step-bar-1" class="h-1 flex-1 rounded-full overflow-hidden"
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
<div id="step-fill-1" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
</div>
<div id="step-bar-2" class="h-1 flex-1 rounded-full overflow-hidden"
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
<div id="step-fill-2" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
</div>
</div>
<!-- Order info -->
<aside id="order-info" class="card w-full px-6 pt-6 pb-5 mb-4">
<p class="text-sm font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
<div class="flex items-end gap-3 justify-between">
<span class="text-4xl font-bold leading-none tracking-tighter text-card-foreground font-nunito"
id="display-amount">--</span>
<button class="icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
</button>
</div>
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
<div class="mt-3 pt-3 border-t border-border/50">
<table class="w-full text-sm text-muted-foreground border-separate border-spacing-y-1">
<tbody>
<tr id="display-fiat"></tr>
<tr id="display-order-id"></tr>
</tbody>
</table>
</div>
</aside>
<!-- Panel viewport: clips horizontal slide -->
<div class="relative w-full" id="panel-viewport">
<!-- Step 1: Select coin + network -->
<section id="step1-panel" class="w-full mb-4 pb-4">
<div class="flex gap-2 mb-4">
<!-- Network -->
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
data-i18n="network_label">Network</p>
<div class="select-wrap relative select-none" id="dd-network">
<div
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
onclick="toggleSelect('dd-network')">
<span id="network-label" class="text-sm font-semibold text-card-foreground leading-none"></span>
<i data-lucide="chevron-down"
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
height="13" stroke-width="2.5"></i>
</div>
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-network-menu">
</div>
</div>
</div>
<!-- Currency -->
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
data-i18n="currency_label">Currency</p>
<div class="select-wrap relative select-none" id="dd-token">
<div
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
onclick="toggleSelect('dd-token')">
<span id="token-label" class="text-sm font-semibold text-card-foreground"></span>
<i data-lucide="chevron-down"
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
height="13" stroke-width="2.5"></i>
</div>
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-token-menu">
</div>
</div>
</div>
</div>
<button id="btn-confirm-step1" class="btn-primary w-full" onclick="confirmStep1()"
data-i18n="confirm">Confirm</button>
</section>
<!-- Payment panel (Step 2) -->
<section id="payment-panel" class="w-full pb-4" style="display:none">
<!-- QR + address card -->
<div class="card w-full px-5 pt-5 pb-0 mb-4 overflow-hidden">
<!-- Scan title + timer -->
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay
</p>
<div id="timer-row" class="relative w-10 h-10 shrink-0">
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="var(--success)"
stroke-width="3" stroke-linecap="round" stroke-dasharray="125.66" stroke-dashoffset="0" />
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
</div>
</div>
</div>
<!-- Countdown -->
<p class="text-3xl font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
<!-- QR code -->
<div class="flex justify-center mb-4">
<div class="qr-wrapper">
<div id="qrcode" class="w-44 h-44"></div>
</div>
</div>
<!-- Address row (edge-to-edge inside card) -->
<div class="h-px bg-border/50 -mx-5"></div>
<div id="copy-addr-box" class="row cursor-pointer select-none -mx-5">
<div class="flex-1 min-w-0 pr-3">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-0.5"
data-i18n="payment_address">Payment address</p>
<p class="text-sm font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
</div>
<span class="icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
</span>
</div>
</div>
<span id="countdown-inline" class="sr-only"></span>
<!-- Connect Wallet (above I have transferred) -->
<button class="btn-secondary w-full mb-3" id="btn-connect-wallet" onclick="connectWallet()">
<i data-lucide="wallet" width="16" height="16" stroke-width="1.8" class="mr-1.5 shrink-0"></i>
<span data-i18n="connect_wallet">Connect Wallet to Pay</span>
</button>
<!-- I have transferred -->
<button class="btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
<span data-i18n="i_have_transferred">I have transferred</span>
</button>
<!-- Status -->
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13"
stroke-width="2.2"></i>
<span class="text-xs text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking
blockchain</span>
</div>
</section>
<!-- State: Success -->
<section id="screen-success"
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
aria-live="polite" style="display:none">
<div class="state-icon bg-success/15 mb-6">
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="var(--success)"></i>
</div>
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
<p class="text-sm text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22"
stroke-width="2"></i>
</section>
<!-- State: Expired -->
<section id="screen-expired" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
<div class="state-icon bg-destructive/12 mb-6">
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="var(--destructive)"></i>
</div>
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
<p class="text-sm text-muted-foreground" data-i18n="expired_sub">Please initiate a new payment</p>
</div>
<button class="btn-secondary w-full" onclick="goBack()" data-i18n="back">Back</button>
</section>
<!-- State: Timeout -->
<section id="screen-timeout" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
<div class="state-icon bg-warning/12 mb-6">
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="var(--warning)"></i>
</div>
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
<p class="text-sm text-muted-foreground" data-i18n="timeout_sub">Unable to connect to the payment server</p>
</div>
<div class="flex gap-3">
<button class="btn-secondary flex-1" onclick="goBack()" data-i18n="back">Back</button>
<button class="btn-primary flex-1" onclick="retryPolling()" data-i18n="retry">Retry</button>
</div>
</section>
<!-- State: Not Found -->
<section id="screen-not-found"
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
aria-live="polite" style="display:none">
<div class="state-icon bg-muted-foreground/12 mb-6">
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="var(--muted-foreground)"></i>
</div>
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
<p class="text-sm text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already
expired
</p>
</section>
</div><!-- /panel viewport -->
</main>
<!-- Footer -->
<footer class="flex items-center justify-center gap-2.5 pb-6 text-xs text-muted-foreground select-none relative z-10">
<span class="flex items-center gap-1.5">
Powered by
<a href="https://www.gmwallet.app" target="_blank" rel="noopener"
class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-xs" />
GM Wallet
</a>
</span>
<span class="opacity-30">|</span>
<a href="https://github.com/GMwalletApp" target="_blank" rel="noopener"
class="flex items-center gap-1 hover:opacity-70 transition-opacity">
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
</a>
</footer>
<!-- Toast -->
<div id="toast">Copied</div>
<!-- Order data injected by server (Go template) -->
<script>
var ORDER = {
tradeId: "{{.TradeId}}",
amount: "{{.Amount}}",
actualAmount: "{{.ActualAmount}}",
token: "{{.Token}}",
currency: "{{.Currency}}",
network: "{{.Network}}",
receiveAddress: "{{.ReceiveAddress}}",
expirationTime: "{{.ExpirationTime}}",
redirectUrl: "{{.RedirectUrl}}",
createdAt: "{{.CreatedAt}}",
is_selected: "{{.IsSelected}}"
};
var PAYMENT_OPTIONS = null;
</script>
<script defer src="/static/payment.js"></script>
</body>
</html>

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