mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
21 Commits
dev-runtime
...
v0.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 67263da0f1 | |||
| 95879e33d4 | |||
| 0c059e8a28 | |||
| 485059f60e | |||
| 143bc84ac3 | |||
| 06856e5caa | |||
| b9bde0f95b | |||
| 1b92ec1905 | |||
| 89be1fc967 | |||
| 91936d580f | |||
| 09514a6946 | |||
| b6cd4325b8 | |||
| 27d5e5bcfe | |||
| b625bac1e3 | |||
| bc5bbf72f7 | |||
| 3a3a830d93 | |||
| 59aa71209c | |||
| cdf274599e | |||
| 916394337b | |||
| efc8f29af6 | |||
| 9eaf27298c |
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 安全漏洞报告
|
||||||
|
url: https://github.com/ginta/epusdt/security/advisories/new
|
||||||
|
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
|
||||||
@@ -5,28 +5,40 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "main"
|
- "main"
|
||||||
|
- "master"
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
DOCKERHUB_NAMESPACE: gmwallet
|
||||||
IMAGE_NAME: epusdt
|
IMAGE_NAME: epusdt
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push-docker-hub:
|
push-docker-hub:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Compute Build Metadata
|
- name: Set Up QEMU
|
||||||
id: meta
|
uses: docker/setup-qemu-action@v3
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Login
|
- name: Set Up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Docker Metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=alpine
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=ref,event=branch,suffix=-alpine
|
||||||
|
type=ref,event=tag
|
||||||
|
type=sha,prefix=sha-,suffix=-alpine
|
||||||
|
|
||||||
|
- name: Login To Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -38,9 +50,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.meta.outputs.version }}
|
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||||
COMMIT=${{ steps.meta.outputs.commit }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
DATE=${{ steps.meta.outputs.date }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* The file was created by Assimon.
|
* The file was created by Assimon.
|
||||||
*
|
*
|
||||||
* @author assimon<ashang@utf8.hk>
|
* @author GMWalletApp<ashang@utf8.hk>
|
||||||
* @copyright assimon<ashang@utf8.hk>
|
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||||
* @link http://utf8.hk/
|
* @link http://utf8.hk/
|
||||||
*/
|
*/
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|||||||
+12
-15
@@ -3,14 +3,14 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/mq"
|
"github.com/GMWalletApp/epusdt/mq"
|
||||||
"github.com/assimon/luuu/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/assimon/luuu/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
appjwt "github.com/assimon/luuu/util/jwt"
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ func InitApp() {
|
|||||||
log.Init()
|
log.Init()
|
||||||
dao.Init()
|
dao.Init()
|
||||||
// Wire settings-table lookups into the config package so
|
// Wire settings-table lookups into the config package so
|
||||||
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides.
|
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||||
config.SettingsGetString = func(key string) string {
|
config.SettingsGetString = func(key string) string {
|
||||||
return data.GetSettingString(key, "")
|
return data.GetSettingString(key, "")
|
||||||
}
|
}
|
||||||
@@ -36,20 +36,17 @@ func InitApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// config.Init() computes RateApiUrl before SettingsGetString is
|
|
||||||
// installed, so refresh the cache once DB-backed settings are available.
|
|
||||||
config.RateApiUrl = config.GetRateApiUrl()
|
|
||||||
// Seed admin account and JWT secret so the management console is
|
// Seed admin account and JWT secret so the management console is
|
||||||
// immediately usable on a fresh install. Both are idempotent.
|
// immediately usable on a fresh install. Both are idempotent.
|
||||||
adminPwd, isNew, err := data.EnsureDefaultAdmin()
|
_, isNew, err := data.EnsureDefaultAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||||
}
|
}
|
||||||
if isNew {
|
if isNew {
|
||||||
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||||
color.Yellow.Println("║ Default admin account created. Change the password via admin console! ║")
|
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
|
||||||
color.Yellow.Printf("║ Username: admin ║\n")
|
color.Yellow.Printf("║ Username: admin ║\n")
|
||||||
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd)
|
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
||||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||||
}
|
}
|
||||||
if _, err := appjwt.EnsureSecret(); err != nil {
|
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||||
|
|||||||
@@ -1,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/bootstrap"
|
"github.com/GMWalletApp/epusdt/bootstrap"
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/install"
|
"github.com/GMWalletApp/epusdt/install"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/GMWalletApp/epusdt/middleware"
|
||||||
"github.com/assimon/luuu/route"
|
"github.com/GMWalletApp/epusdt/route"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
luluHttp "github.com/assimon/luuu/util/http"
|
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -72,6 +73,15 @@ func HttpServerStart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
|
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
|
||||||
|
Skipper: func(c echo.Context) bool {
|
||||||
|
path := c.Request().URL.Path
|
||||||
|
if path == "/install" || strings.HasPrefix(path, "/install/") {
|
||||||
|
// The install wizard is only served by install.RunInstallServer
|
||||||
|
// before bootstrap. Once main server starts, block /install.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return luluHttp.ShouldSkipSPAFallback(path)
|
||||||
|
},
|
||||||
HTML5: true,
|
HTML5: true,
|
||||||
Index: "index.html",
|
Index: "index.html",
|
||||||
Root: wwwRoot,
|
Root: wwwRoot,
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package command
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+14
-32
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
@@ -34,9 +34,6 @@ var (
|
|||||||
TgBotToken string
|
TgBotToken string
|
||||||
TgProxy string
|
TgProxy string
|
||||||
TgManage int64
|
TgManage int64
|
||||||
UsdtRate float64
|
|
||||||
RateApiUrl string
|
|
||||||
TRON_GRID_API_KEY string
|
|
||||||
BuildVersion = "0.0.0-dev"
|
BuildVersion = "0.0.0-dev"
|
||||||
BuildCommit = "none"
|
BuildCommit = "none"
|
||||||
BuildDate = "unknown"
|
BuildDate = "unknown"
|
||||||
@@ -77,9 +74,6 @@ func Init() {
|
|||||||
TgBotToken = viper.GetString("tg_bot_token")
|
TgBotToken = viper.GetString("tg_bot_token")
|
||||||
TgProxy = viper.GetString("tg_proxy")
|
TgProxy = viper.GetString("tg_proxy")
|
||||||
TgManage = viper.GetInt64("tg_manage")
|
TgManage = viper.GetInt64("tg_manage")
|
||||||
|
|
||||||
RateApiUrl = GetRateApiUrl()
|
|
||||||
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustMkdir(path string) {
|
func mustMkdir(path string) {
|
||||||
@@ -277,13 +271,14 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
if usdtRate > 0 {
|
if usdtRate > 0 {
|
||||||
return 1 / usdtRate
|
return 1 / usdtRate
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return getRateForCoinFromAPI(coin, base)
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := RateApiUrl
|
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||||
if baseURL == "" {
|
baseURL := GetRateApiUrl()
|
||||||
baseURL = GetRateApiUrl()
|
|
||||||
}
|
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
log.Printf("rate api url is empty")
|
log.Printf("rate api url is empty")
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -315,19 +310,18 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUsdtRate() float64 {
|
func GetUsdtRate() float64 {
|
||||||
// Prefer the DB-backed override (admin-configurable). Fall back to
|
// Only the admin setting can force the USDT/CNY rate. When the
|
||||||
// the legacy .env var, then the hardcoded 6.4 default.
|
// setting is unset, zero, or negative, fall back to the rate API.
|
||||||
if forced := settingsForcedUsdtRate(); forced > 0 {
|
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||||
return forced
|
return forced
|
||||||
}
|
}
|
||||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
|
||||||
if forcedUsdtRate > 0 {
|
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||||
return forcedUsdtRate
|
if apiRate > 0 {
|
||||||
|
return 1 / apiRate
|
||||||
}
|
}
|
||||||
if UsdtRate <= 0 {
|
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
|
||||||
return 6.4
|
return 0
|
||||||
}
|
|
||||||
return UsdtRate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOrderExpirationTime() int {
|
func GetOrderExpirationTime() int {
|
||||||
@@ -398,15 +392,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSolanaRpcUrl() string {
|
|
||||||
rpcUrl := viper.GetString("solana_rpc_url")
|
|
||||||
if rpcUrl == "" {
|
|
||||||
return "https://api.mainnet-beta.solana.com"
|
|
||||||
}
|
|
||||||
return rpcUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEthereumWsUrl() string {
|
|
||||||
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func installSettingsGetter(t *testing.T, values map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldGetter := SettingsGetString
|
||||||
|
SettingsGetString = func(key string) string {
|
||||||
|
return values[key]
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SettingsGetString = oldGetter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldFactory := http_client.ClientFactory
|
||||||
|
http_client.ClientFactory = func() *resty.Client {
|
||||||
|
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||||
|
client.SetTimeout(10 * time.Second)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http_client.ClientFactory = oldFactory
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
|
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -135,3 +176,127 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
|
|||||||
t.Fatalf("config path = %s, want %s", got, flagPath)
|
t.Fatalf("config path = %s, want %s", got, flagPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
apiCalled := false
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
apiCalled = true
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Status: http.StatusText(http.StatusInternalServerError),
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_usdt_rate": "7.25",
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := GetUsdtRate()
|
||||||
|
if got != 7.25 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||||
|
}
|
||||||
|
if apiCalled {
|
||||||
|
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_usdt_rate": "-1",
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := GetUsdtRate()
|
||||||
|
want := 1 / 0.14635
|
||||||
|
if math.Abs(got-want) > 1e-9 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := GetRateForCoin("usdt", "cny")
|
||||||
|
if math.Abs(rate-0.14635) > 1e-9 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Status: "502 Bad Gateway",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_usdt_rate": "0",
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := GetUsdtRate(); got != 0 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
callCount++
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Status: "502 Bad Gateway",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_usdt_rate": "0",
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("rate api call count = %d, want 1", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package config
|
|||||||
// create a cycle via `model/dao -> config`.
|
// create a cycle via `model/dao -> config`.
|
||||||
//
|
//
|
||||||
// If unset (e.g. during early startup or tests) the getters return the
|
// If unset (e.g. during early startup or tests) the getters return the
|
||||||
// zero string / 0 and callers fall through to the .env / default path.
|
// zero string / 0 and callers apply their own fallback behavior.
|
||||||
|
|
||||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||||
// reads from the settings table. Runtime-only.
|
// reads from the settings table. Runtime-only.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
appjwt "github.com/assimon/luuu/util/jwt"
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +34,12 @@ type MeResponse struct {
|
|||||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitialPasswordResponse is returned by the one-time initial password API.
|
||||||
|
type InitialPasswordResponse struct {
|
||||||
|
Username string `json:"username" example:"admin"`
|
||||||
|
Password string `json:"password" example:"a1b2c3d4e5f6"`
|
||||||
|
}
|
||||||
|
|
||||||
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
||||||
// @Summary Admin login
|
// @Summary Admin login
|
||||||
// @Description Verify credentials and return a signed JWT token
|
// @Description Verify credentials and return a signed JWT token
|
||||||
@@ -77,6 +83,42 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInitialPassword returns the one-time initial admin password.
|
||||||
|
// @Summary Get initial admin password (one-time)
|
||||||
|
// @Description Returns the initial random admin password once, then invalidates it.
|
||||||
|
// @Tags Admin Auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/auth/init-password [get]
|
||||||
|
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||||
|
password, err := data.ConsumeInitialAdminPassword()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, InitialPasswordResponse{
|
||||||
|
Username: "admin",
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInitialPasswordHash returns the initial-password fingerprint so the
|
||||||
|
// frontend can warn until the password is changed.
|
||||||
|
// @Summary Get initial admin password hash
|
||||||
|
// @Description Returns the initial password hash and password-changed flag.
|
||||||
|
// @Tags Admin Auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/auth/init-password-hash [get]
|
||||||
|
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
|
||||||
|
info, err := data.GetInitialAdminPasswordHashInfo()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, info)
|
||||||
|
}
|
||||||
|
|
||||||
// Logout is a no-op stub — tokens are stateless and the frontend
|
// Logout is a no-op stub — tokens are stateless and the frontend
|
||||||
// discards them. Kept for API symmetry with the documented spec.
|
// discards them. Kept for API symmetry with the documented spec.
|
||||||
// @Summary Admin logout
|
// @Summary Admin logout
|
||||||
@@ -111,9 +153,9 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
|
|||||||
if user.ID == 0 {
|
if user.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("user not found"))
|
return c.FailJson(ctx, errors.New("user not found"))
|
||||||
}
|
}
|
||||||
// Warn the frontend when the operator hasn't changed the default
|
// Warn the frontend when the operator hasn't changed the seeded
|
||||||
// password so the UI can show a prominent reminder.
|
// initial password.
|
||||||
isDefault := data.VerifyPassword(user.PasswordHash, "admin")
|
isDefault := data.IsUsingInitialAdminPassword()
|
||||||
return c.SucJson(ctx, MeResponse{
|
return c.SucJson(ctx, MeResponse{
|
||||||
AdminUser: *user,
|
AdminUser: *user,
|
||||||
PasswordIsDefault: isDefault,
|
PasswordIsDefault: isDefault,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/controller"
|
"github.com/GMWalletApp/epusdt/controller"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/GMWalletApp/epusdt/middleware"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/assimon/luuu/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
// Supported groups and keys:
|
// Supported groups and keys:
|
||||||
//
|
//
|
||||||
// - group=rate:
|
// - group=rate:
|
||||||
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api)
|
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
|
||||||
// rate.api_url (string) — external rate API URL
|
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
|
||||||
// rate.adjust_percent (float) — rate adjustment percentage
|
// rate.adjust_percent (float) — rate adjustment percentage
|
||||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||||
//
|
//
|
||||||
@@ -73,7 +73,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
// @Description Batch insert/update settings. Returns per-key status.
|
// @Description Batch insert/update settings. Returns per-key status.
|
||||||
// @Description Supported groups: brand, rate, system, epay.
|
// @Description Supported groups: brand, rate, system, epay.
|
||||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
||||||
// @Description rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||||
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
||||||
// @Description system group keys: system.order_expiration_time.
|
// @Description system group keys: system.order_expiration_time.
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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{}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/GMWalletApp/epusdt/middleware"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
module github.com/assimon/luuu
|
module github.com/GMWalletApp/epusdt
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
|||||||
+56
-28
@@ -28,6 +28,7 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
@@ -78,6 +79,60 @@ type installHandler struct {
|
|||||||
done chan struct{}
|
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.
|
// GetDefaults returns default values for the install form.
|
||||||
//
|
//
|
||||||
// @Summary Install — get default values
|
// @Summary Install — get default values
|
||||||
@@ -171,34 +226,7 @@ func RunInstallServer(listenAddr, envFilePath string) {
|
|||||||
listenAddr = DefaultInstallAddr
|
listenAddr = DefaultInstallAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &installHandler{
|
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
|
||||||
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)
|
|
||||||
|
|
||||||
// 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(middleware.StaticWithConfig(middleware.StaticConfig{
|
|
||||||
HTML5: true,
|
|
||||||
Index: "index.html",
|
|
||||||
Root: wwwRoot,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Build a human-readable URL for the console hint.
|
// Build a human-readable URL for the console hint.
|
||||||
installHost := listenAddr
|
installHost := listenAddr
|
||||||
|
|||||||
@@ -95,6 +95,55 @@ func TestInstallAPIDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestInstallAPISubmit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
appLog "github.com/assimon/luuu/util/log"
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/libtnb/sqlite"
|
"github.com/libtnb/sqlite"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -20,7 +20,6 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
viper.Set("forced_usdt_rate", 1.0)
|
|
||||||
viper.Set("app_uri", "https://example.com")
|
viper.Set("app_uri", "https://example.com")
|
||||||
viper.Set("order_expiration_time", 10)
|
viper.Set("order_expiration_time", 10)
|
||||||
viper.Set("order_notice_max_retry", 2)
|
viper.Set("order_notice_max_retry", 2)
|
||||||
@@ -31,7 +30,6 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
config.HTTPAccessLog = false
|
config.HTTPAccessLog = false
|
||||||
config.SQLDebug = false
|
config.SQLDebug = false
|
||||||
config.LogLevel = "error"
|
config.LogLevel = "error"
|
||||||
config.UsdtRate = 0
|
|
||||||
appLog.Sugar = zap.NewNop().Sugar()
|
appLog.Sugar = zap.NewNop().Sugar()
|
||||||
|
|
||||||
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
||||||
@@ -52,6 +50,16 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
|
|
||||||
dao.Mdb = mainDB
|
dao.Mdb = mainDB
|
||||||
dao.RuntimeDB = runtimeDB
|
dao.RuntimeDB = runtimeDB
|
||||||
|
config.SettingsGetString = func(key string) string {
|
||||||
|
if dao.Mdb == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row.Value
|
||||||
|
}
|
||||||
|
|
||||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||||
for _, network := range []string{
|
for _, network := range []string{
|
||||||
@@ -74,12 +82,21 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
Pid: "1001", SecretKey: "test-token",
|
Pid: "1001", SecretKey: "test-token",
|
||||||
Status: mdb.ApiKeyStatusEnable,
|
Status: mdb.ApiKeyStatusEnable,
|
||||||
})
|
})
|
||||||
|
if err := dao.Mdb.Create(&mdb.Setting{
|
||||||
|
Group: "rate",
|
||||||
|
Key: "rate.forced_usdt_rate",
|
||||||
|
Value: "1.0",
|
||||||
|
Type: "string",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
closeDB(t, runtimeDB)
|
closeDB(t, runtimeDB)
|
||||||
closeDB(t, mainDB)
|
closeDB(t, mainDB)
|
||||||
dao.Mdb = nil
|
dao.Mdb = nil
|
||||||
dao.RuntimeDB = nil
|
dao.RuntimeDB = nil
|
||||||
|
config.SettingsGetString = nil
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/assimon/luuu/command"
|
"github.com/GMWalletApp/epusdt/command"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
appjwt "github.com/assimon/luuu/util/jwt"
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/json"
|
"github.com/GMWalletApp/epusdt/util/json"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -2,16 +2,38 @@ package data
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAdminUsername = "admin"
|
const defaultAdminUsername = "admin"
|
||||||
|
const InitialAdminPasswordHashAlgorithm = "sha256"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
|
||||||
|
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
|
||||||
|
// so the frontend can detect whether the operator is still using the
|
||||||
|
// initial password.
|
||||||
|
type InitialAdminPasswordHashInfo struct {
|
||||||
|
Algorithm string `json:"algorithm" example:"sha256"`
|
||||||
|
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
|
||||||
|
PasswordChanged bool `json:"password_changed" example:"false"`
|
||||||
|
Available bool `json:"available" example:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
||||||
// exists. The password is randomly generated and returned so the caller
|
// exists. The password is randomly generated and returned so the caller
|
||||||
@@ -35,7 +57,15 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
|
|||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
Status: mdb.AdminUserStatusEnable,
|
Status: mdb.AdminUserStatusEnable,
|
||||||
}
|
}
|
||||||
return password, true, dao.Mdb.Create(user).Error
|
if err := dao.Mdb.Create(user).Error; err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if err := initAdminPasswordState(password); err != nil {
|
||||||
|
// Account was already created; surface both for visibility while
|
||||||
|
// preserving created=true and password for emergency fallback.
|
||||||
|
return password, true, err
|
||||||
|
}
|
||||||
|
return password, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomAdminPassword() string {
|
func randomAdminPassword() string {
|
||||||
@@ -44,6 +74,141 @@ func randomAdminPassword() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashInitialAdminPassword fingerprints the initial plaintext password so
|
||||||
|
// the frontend can compare user input locally without exposing plaintext.
|
||||||
|
func HashInitialAdminPassword(plain string) string {
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
return fmt.Sprintf("%x", sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAdminPasswordState(plain string) error {
|
||||||
|
hash := HashInitialAdminPassword(plain)
|
||||||
|
settings := []mdb.Setting{
|
||||||
|
{
|
||||||
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||||
|
Value: plain, Type: mdb.SettingTypeString,
|
||||||
|
Description: "One-time readable initial admin password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
|
||||||
|
Value: hash, Type: mdb.SettingTypeString,
|
||||||
|
Description: "SHA-256 fingerprint for initial admin password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||||
|
Value: "false", Type: mdb.SettingTypeBool,
|
||||||
|
Description: "Whether initial admin password has been fetched",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||||
|
Value: "false", Type: mdb.SettingTypeBool,
|
||||||
|
Description: "Whether initial admin password has been changed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, row := range settings {
|
||||||
|
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep in-process cache coherent for the current process.
|
||||||
|
settingsCacheMu.Lock()
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
||||||
|
settingsCacheMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
||||||
|
}).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeInitialAdminPassword returns the one-time initial admin password.
|
||||||
|
// After a successful read, the plaintext is deleted and cannot be fetched
|
||||||
|
// again.
|
||||||
|
func ConsumeInitialAdminPassword() (string, error) {
|
||||||
|
var password string
|
||||||
|
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||||
|
row := new(mdb.Setting)
|
||||||
|
if err := tx.Model(&mdb.Setting{}).
|
||||||
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
Limit(1).
|
||||||
|
Find(row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.ID == 0 || row.Value == "" {
|
||||||
|
var fetched mdb.Setting
|
||||||
|
if err := tx.Model(&mdb.Setting{}).
|
||||||
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
|
||||||
|
Limit(1).
|
||||||
|
Find(&fetched).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
|
||||||
|
return ErrInitAdminPasswordAlreadyFetched
|
||||||
|
}
|
||||||
|
return ErrInitAdminPasswordUnavailable
|
||||||
|
}
|
||||||
|
password = row.Value
|
||||||
|
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrInitAdminPasswordAlreadyFetched
|
||||||
|
}
|
||||||
|
return upsertSettingRow(tx, mdb.Setting{
|
||||||
|
Group: mdb.SettingGroupSystem,
|
||||||
|
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||||
|
Value: "true",
|
||||||
|
Type: mdb.SettingTypeBool,
|
||||||
|
Description: "Whether initial admin password has been fetched",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
settingsCacheMu.Lock()
|
||||||
|
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||||
|
settingsCacheMu.Unlock()
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
||||||
|
// and the changed-state flag used by the admin frontend.
|
||||||
|
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
|
||||||
|
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||||
|
if hash == "" {
|
||||||
|
return &InitialAdminPasswordHashInfo{
|
||||||
|
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||||
|
PasswordHash: "",
|
||||||
|
PasswordChanged: true,
|
||||||
|
Available: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &InitialAdminPasswordHashInfo{
|
||||||
|
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||||
|
PasswordHash: hash,
|
||||||
|
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
|
||||||
|
Available: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUsingInitialAdminPassword reports whether the current admin password is
|
||||||
|
// still considered the seeded initial password.
|
||||||
|
func IsUsingInitialAdminPassword() bool {
|
||||||
|
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||||
|
if hash == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
|
||||||
|
}
|
||||||
|
|
||||||
// HashPassword bcrypts a plaintext password.
|
// HashPassword bcrypts a plaintext password.
|
||||||
func HashPassword(plain string) (string, error) {
|
func HashPassword(plain string) (string, error) {
|
||||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||||
@@ -80,9 +245,55 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
changedCacheValue := ""
|
||||||
Where("id = ?", id).
|
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||||
Update("password_hash", hash).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.
|
// TouchAdminUserLastLogin stamps last_login_at to now.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package data
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListChains returns all chain rows ordered by id.
|
// ListChains returns all chain rows ordered by id.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package data
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
|
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListNotificationChannels returns all rows, optionally filtered by type.
|
// ListNotificationChannels returns all rows, optionally filtered by type.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package data
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyStat is one bucket of day-level aggregation used by the
|
// DailyStat is one bucket of day-level aggregation used by the
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ func DeleteRpcNodeByID(id uint64) error {
|
|||||||
|
|
||||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||||
// Falls back to any enabled row when none are healthy yet (bootstrap case
|
// Falls back to enabled rows with status=unknown when no health check has
|
||||||
// where the health check hasn't run). Returns nil if nothing is defined.
|
// run yet. Explicitly down rows are not selected.
|
||||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||||
var rows []mdb.RpcNode
|
var rows []mdb.RpcNode
|
||||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
@@ -62,15 +62,22 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
|||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
healthy := rows[:0]
|
healthy := make([]mdb.RpcNode, 0, len(rows))
|
||||||
|
bootstrap := make([]mdb.RpcNode, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
if r.Status == mdb.RpcNodeStatusOk {
|
switch r.Status {
|
||||||
|
case mdb.RpcNodeStatusOk:
|
||||||
healthy = append(healthy, r)
|
healthy = append(healthy, r)
|
||||||
|
case "", mdb.RpcNodeStatusUnknown:
|
||||||
|
bootstrap = append(bootstrap, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
candidates := healthy
|
candidates := healthy
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
candidates = rows // bootstrap: no health check has run yet
|
candidates = bootstrap
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return pickWeighted(candidates), nil
|
return pickWeighted(candidates), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,6 +151,10 @@ func DeleteSetting(key string) error {
|
|||||||
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
||||||
var sensitiveSettingKeys = []string{
|
var sensitiveSettingKeys = []string{
|
||||||
mdb.SettingKeyJwtSecret,
|
mdb.SettingKeyJwtSecret,
|
||||||
|
mdb.SettingKeyInitAdminPasswordPlain,
|
||||||
|
mdb.SettingKeyInitAdminPasswordHash,
|
||||||
|
mdb.SettingKeyInitAdminPasswordFetched,
|
||||||
|
mdb.SettingKeyInitAdminPasswordChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||||
|
|||||||
@@ -3,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 添加)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||||
|
|||||||
+15
-11
@@ -22,17 +22,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SettingKeyJwtSecret = "system.jwt_secret"
|
SettingKeyJwtSecret = "system.jwt_secret"
|
||||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
|
||||||
SettingKeyBrandSiteName = "brand.site_name"
|
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
|
||||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||||
SettingKeyBrandPageTitle = "brand.page_title"
|
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
SettingKeyBrandSiteName = "brand.site_name"
|
||||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
SettingKeyBrandPageTitle = "brand.page_title"
|
||||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||||
SettingKeyRateApiUrl = "rate.api_url"
|
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.
|
// EPAY route defaults — can be overridden via admin settings.
|
||||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||||
@@ -25,6 +30,26 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldFactory := http_client.ClientFactory
|
||||||
|
http_client.ClientFactory = func() *resty.Client {
|
||||||
|
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||||
|
client.SetTimeout(10 * time.Second)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http_client.ClientFactory = oldFactory
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -72,6 +97,59 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||||
|
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
||||||
|
t.Fatalf("set rate.api_url: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||||
|
t.Fatalf("add wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create transaction: %v", err)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
|
||||||
|
t.Fatalf("actual amount = %s, want 1.46", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||||
|
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||||
|
t.Fatalf("clear rate.api_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
|
||||||
|
if err != constant.RateAmountErr {
|
||||||
|
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -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("不存在待支付订单或已过期")
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/assimon/luuu/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
"github.com/gagliardetto/solana-go"
|
"github.com/gagliardetto/solana-go"
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
@@ -281,6 +281,21 @@ type solSignatureResult struct {
|
|||||||
BlockTime *int64 `json:"blockTime"`
|
BlockTime *int64 `json:"blockTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveSolanaRpcURL() (string, error) {
|
||||||
|
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if node == nil || node.ID == 0 {
|
||||||
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
if rpcURL == "" {
|
||||||
|
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
|
}
|
||||||
|
return rpcURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
@@ -297,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
rpcUrl := config.GetSolanaRpcUrl()
|
rpcUrl, err := resolveSolanaRpcURL()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.R().
|
resp, err := client.R().
|
||||||
SetHeader("Content-Type", "application/json").
|
SetHeader("Content-Type", "application/json").
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||||
tron "github.com/assimon/luuu/crypto"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/assimon/luuu/notify"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/assimon/luuu/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
"github.com/assimon/luuu/util/math"
|
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/gookit/goutil/stdutil"
|
"github.com/gookit/goutil/stdutil"
|
||||||
@@ -26,19 +25,21 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
|
|
||||||
const tronNodeDefault = "https://api.trongrid.io"
|
|
||||||
|
|
||||||
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
||||||
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
|
// It reads the first healthy (or any enabled) row from the rpc_nodes table.
|
||||||
// if none exists it falls back to the hard-coded default URL and the
|
func resolveTronNode() (string, string, error) {
|
||||||
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
|
|
||||||
func resolveTronNode() (string, string) {
|
|
||||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
if err != nil {
|
||||||
return strings.TrimRight(node.Url, "/"), node.ApiKey
|
return "", "", err
|
||||||
}
|
}
|
||||||
return tronNodeDefault, config.TRON_GRID_API_KEY
|
if node == nil || node.ID == 0 {
|
||||||
|
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||||
|
if rpcURL == "" {
|
||||||
|
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
|
}
|
||||||
|
return rpcURL, node.ApiKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||||
@@ -79,7 +80,11 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
client := http_client.GetHttpClient()
|
client := http_client.GetHttpClient()
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||||
endTime := carbon.Now().TimestampMilli()
|
endTime := carbon.Now().TimestampMilli()
|
||||||
tronBaseURL, tronAPIKey := resolveTronNode()
|
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
resp, err := client.R().SetQueryParams(map[string]string{
|
||||||
@@ -217,7 +222,11 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
client := http_client.GetHttpClient()
|
client := http_client.GetHttpClient()
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||||
endTime := carbon.Now().TimestampMilli()
|
endTime := carbon.Now().TimestampMilli()
|
||||||
tronBaseURL, tronAPIKey := resolveTronNode()
|
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
|
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
resp, err := client.R().SetQueryParams(map[string]string{
|
||||||
|
|||||||
@@ -3,26 +3,19 @@ package service
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
|
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
|
||||||
// hard-coded default when the rpc_nodes table has no TRON row.
|
// TRON row from rpc_nodes.
|
||||||
func TestResolveTronNode_NoRow(t *testing.T) {
|
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
config.TRON_GRID_API_KEY = "fallback-key"
|
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||||
|
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||||
gotURL, gotKey := resolveTronNode()
|
|
||||||
if gotURL != tronNodeDefault {
|
|
||||||
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
|
|
||||||
}
|
|
||||||
if gotKey != "fallback-key" {
|
|
||||||
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +25,6 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
|||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
config.TRON_GRID_API_KEY = "fallback-key"
|
|
||||||
|
|
||||||
// Insert an enabled TRON http node.
|
// Insert an enabled TRON http node.
|
||||||
node := &mdb.RpcNode{
|
node := &mdb.RpcNode{
|
||||||
Network: mdb.NetworkTron,
|
Network: mdb.NetworkTron,
|
||||||
@@ -48,7 +39,10 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
|||||||
t.Fatalf("seed rpc_node: %v", err)
|
t.Fatalf("seed rpc_node: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gotURL, gotKey := resolveTronNode()
|
gotURL, gotKey, err := resolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveTronNode(): %v", err)
|
||||||
|
}
|
||||||
if gotURL != "https://custom-tron.example.com" {
|
if gotURL != "https://custom-tron.example.com" {
|
||||||
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
|
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
|
||||||
}
|
}
|
||||||
@@ -57,14 +51,11 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored and
|
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||||
// the fallback is used.
|
|
||||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
config.TRON_GRID_API_KEY = "fallback-key"
|
|
||||||
|
|
||||||
node := &mdb.RpcNode{
|
node := &mdb.RpcNode{
|
||||||
Network: mdb.NetworkTron,
|
Network: mdb.NetworkTron,
|
||||||
Url: "https://disabled-tron.example.com",
|
Url: "https://disabled-tron.example.com",
|
||||||
@@ -82,11 +73,7 @@ func TestResolveTronNode_DisabledRow(t *testing.T) {
|
|||||||
t.Fatalf("disable rpc_node: %v", err)
|
t.Fatalf("disable rpc_node: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gotURL, gotKey := resolveTronNode()
|
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||||
if gotURL != tronNodeDefault {
|
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||||
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
|
|
||||||
}
|
|
||||||
if gotKey != "fallback-key" {
|
|
||||||
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package mq
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+8
-8
@@ -8,14 +8,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/assimon/luuu/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveOrderApiKey returns the api_keys row that signed the order.
|
// resolveOrderApiKey returns the api_keys row that signed the order.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
|
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sender delivers an already-rendered text message via a channel row.
|
// Sender delivers an already-rendered text message via a channel row.
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,6 +118,14 @@ func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token st
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doGet sends an unauthenticated GET request.
|
||||||
|
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
// assertOK asserts the response status is 200 and the status_code field is 200.
|
// assertOK asserts the response status is 200 and the status_code field is 200.
|
||||||
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -273,6 +281,84 @@ func TestAdminChangePassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
|
||||||
|
// and hash-based "password changed" detection flow.
|
||||||
|
func TestAdminInitPasswordFlow(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
const initPassword = "init-pass-123456"
|
||||||
|
|
||||||
|
adminHash, err := data.HashPassword(initPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
dao.Mdb.Create(&mdb.AdminUser{
|
||||||
|
Username: testAdminUsername,
|
||||||
|
PasswordHash: adminHash,
|
||||||
|
Status: mdb.AdminUserStatusEnable,
|
||||||
|
})
|
||||||
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
|
||||||
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
|
||||||
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
|
||||||
|
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
|
||||||
|
|
||||||
|
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
||||||
|
respHash := assertOK(t, recHash)
|
||||||
|
hashData, _ := respHash["data"].(map[string]interface{})
|
||||||
|
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
|
||||||
|
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
|
||||||
|
}
|
||||||
|
if got, _ := hashData["password_changed"].(bool); got {
|
||||||
|
t.Fatalf("expected password_changed=false before change, got true")
|
||||||
|
}
|
||||||
|
|
||||||
|
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
|
||||||
|
respFetch := assertOK(t, recFetch)
|
||||||
|
fetchData, _ := respFetch["data"].(map[string]interface{})
|
||||||
|
if fetchData["username"] != testAdminUsername {
|
||||||
|
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
|
||||||
|
}
|
||||||
|
if fetchData["password"] != initPassword {
|
||||||
|
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
|
||||||
|
}
|
||||||
|
|
||||||
|
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
|
||||||
|
if recFetch2.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
|
||||||
|
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
token := adminLogin(t, e, testAdminUsername, initPassword)
|
||||||
|
|
||||||
|
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||||
|
respMe1 := assertOK(t, recMe1)
|
||||||
|
meData1, _ := respMe1["data"].(map[string]interface{})
|
||||||
|
if got, _ := meData1["password_is_default"].(bool); !got {
|
||||||
|
t.Fatalf("expected password_is_default=true before change, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
|
||||||
|
"old_password": initPassword,
|
||||||
|
"new_password": "new-pass-789",
|
||||||
|
}, token)
|
||||||
|
assertOK(t, recChange)
|
||||||
|
|
||||||
|
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
||||||
|
respHash2 := assertOK(t, recHash2)
|
||||||
|
hashData2, _ := respHash2["data"].(map[string]interface{})
|
||||||
|
if got, _ := hashData2["password_changed"].(bool); !got {
|
||||||
|
t.Fatalf("expected password_changed=true after change, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||||
|
respMe2 := assertOK(t, recMe2)
|
||||||
|
meData2, _ := respMe2["data"].(map[string]interface{})
|
||||||
|
if got, _ := meData2["password_is_default"].(bool); got {
|
||||||
|
t.Fatalf("expected password_is_default=false after change, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── API Keys ────────────────────────────────────────────────────────────────
|
// ─── API Keys ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
|
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
|
||||||
|
|||||||
+10
-8
@@ -9,20 +9,20 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/assimon/luuu/controller/admin"
|
"github.com/GMWalletApp/epusdt/controller/admin"
|
||||||
"github.com/assimon/luuu/controller/comm"
|
"github.com/GMWalletApp/epusdt/controller/comm"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/GMWalletApp/epusdt/middleware"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -188,6 +188,8 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
|
|
||||||
// Public (no JWT)
|
// Public (no JWT)
|
||||||
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
||||||
|
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
|
||||||
|
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
|
||||||
|
|
||||||
// Authenticated
|
// Authenticated
|
||||||
authed := adminV1.Group("", middleware.CheckAdminJWT())
|
authed := adminV1.Group("", middleware.CheckAdminJWT())
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -33,7 +34,6 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
|||||||
viper.Set("app_uri", "http://localhost:8080")
|
viper.Set("app_uri", "http://localhost:8080")
|
||||||
viper.Set("order_expiration_time", 10)
|
viper.Set("order_expiration_time", 10)
|
||||||
viper.Set("api_rate_url", "")
|
viper.Set("api_rate_url", "")
|
||||||
viper.Set("forced_usdt_rate", 7.0)
|
|
||||||
viper.Set("runtime_root_path", tmpDir)
|
viper.Set("runtime_root_path", tmpDir)
|
||||||
viper.Set("log_save_path", tmpDir)
|
viper.Set("log_save_path", tmpDir)
|
||||||
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
|
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
|
||||||
@@ -68,6 +68,15 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
|||||||
|
|
||||||
// reset the settings cache so stale entries from a prior test don't leak
|
// reset the settings cache so stale entries from a prior test don't leak
|
||||||
_ = data.ReloadSettings()
|
_ = data.ReloadSettings()
|
||||||
|
config.SettingsGetString = func(key string) string {
|
||||||
|
return data.GetSettingString(key, "")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.SettingsGetString = nil
|
||||||
|
})
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil {
|
||||||
|
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// seed wallet addresses
|
// seed wallet addresses
|
||||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
|
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
|
||||||
@@ -158,6 +167,21 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRootPostRoute(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if body := rec.Body.String(); body != "hello epusdt, https://github.com/GMwalletApp/epusdt" {
|
||||||
|
t.Fatalf("unexpected body: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
|
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
|
||||||
func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ func Start() {
|
|||||||
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
||||||
// Solana polling
|
// Solana polling
|
||||||
_, err = c.AddJob("@every 5s", ListenSolJob{})
|
_, err = c.AddJob("@every 5s", ListenSolJob{})
|
||||||
@@ -28,6 +29,7 @@ func Start() {
|
|||||||
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
|
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
|
||||||
// RPC node health checks
|
// RPC node health checks
|
||||||
_, err = c.AddJob("@every 30s", RpcHealthJob{})
|
_, err = c.AddJob("@every 30s", RpcHealthJob{})
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const bscDefaultWsURL = "wss://bsc.drpc.org"
|
|
||||||
|
|
||||||
type bscRecipientSnapshot struct {
|
type bscRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -68,7 +66,10 @@ func runBscListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
)
|
)
|
||||||
@@ -90,12 +90,22 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
|
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
|
||||||
// given network, falling back to the provided default (usually the
|
// given network. If no enabled node is configured, the caller skips the
|
||||||
// public node URL) when no row is configured.
|
// current listener run so admin-side disabled/deleted rows are respected.
|
||||||
func resolveChainWsURL(network, fallback string) string {
|
func resolveChainWsURL(network, logPrefix string) (string, bool) {
|
||||||
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
return node.Url
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
if rpcURL != "" {
|
||||||
|
return rpcURL, true
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
return fallback
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
|
||||||
|
} else {
|
||||||
|
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChainWsURLWithRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Url: " wss://ethereum.example.com ",
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() ok=false, want true")
|
||||||
|
}
|
||||||
|
if got != "wss://ethereum.example.com" {
|
||||||
|
t.Fatalf("resolveChainWsURL() = %q, want wss://ethereum.example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Url: "wss://disabled.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-23
@@ -7,11 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/assimon/luuu/util/log"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -22,22 +21,6 @@ import (
|
|||||||
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
|
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
|
||||||
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||||
|
|
||||||
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
|
|
||||||
|
|
||||||
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
|
|
||||||
// legacy .env value (if set) before the shared default — keeps existing
|
|
||||||
// deployments working until the admin adds an rpc_nodes row.
|
|
||||||
func resolveEthereumWsURL() string {
|
|
||||||
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
|
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
|
||||||
return node.Url
|
|
||||||
}
|
|
||||||
if u := config.GetEthereumWsUrl(); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return ethereumDefaultWsURL
|
|
||||||
}
|
|
||||||
|
|
||||||
type ethRecipientSnapshot struct {
|
type ethRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -88,7 +71,10 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveEthereumWsURL()
|
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
@@ -145,4 +131,3 @@ func isWatchedEthRecipient(to common.Address) bool {
|
|||||||
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
|
|
||||||
|
|
||||||
type plasmaRecipientSnapshot struct {
|
type plasmaRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,10 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
|
|
||||||
|
|
||||||
type polygonRecipientSnapshot struct {
|
type polygonRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,10 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package task
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenSolJob struct{}
|
type ListenSolJob struct{}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package task
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenTrc20Job struct{}
|
type ListenTrc20Job struct{}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rpcProbeTimeout = 5 * time.Second
|
const rpcProbeTimeout = 5 * time.Second
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --------------- ParseAddress ---------------
|
// --------------- ParseAddress ---------------
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/gookit/goutil/mathutil"
|
"github.com/gookit/goutil/mathutil"
|
||||||
"github.com/gookit/goutil/strutil"
|
"github.com/gookit/goutil/strutil"
|
||||||
tb "gopkg.in/telebot.v3"
|
tb "gopkg.in/telebot.v3"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
tb "gopkg.in/telebot.v3"
|
tb "gopkg.in/telebot.v3"
|
||||||
"gopkg.in/telebot.v3/middleware"
|
"gopkg.in/telebot.v3/middleware"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package telegram
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/assimon/luuu/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
"github.com/gagliardetto/solana-go"
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/assimon/luuu/util/page"
|
"github.com/GMWalletApp/epusdt/util/page"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var spaFallbackDenyPrefixes = []string{
|
||||||
|
"/api",
|
||||||
|
"/admin/api",
|
||||||
|
"/payments",
|
||||||
|
"/pay",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldSkipSPAFallback reports whether a request path should bypass
|
||||||
|
// server-side SPA fallback to index.html and continue normal backend routing.
|
||||||
|
func ShouldSkipSPAFallback(requestPath string) bool {
|
||||||
|
for _, prefix := range spaFallbackDenyPrefixes {
|
||||||
|
if requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
|
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
|
||||||
// It strips any leading slash and blocks path traversal outside wwwRoot.
|
// It strips any leading slash and blocks path traversal outside wwwRoot.
|
||||||
// The second return value indicates whether the caller should try os.Stat
|
// The second return value indicates whether the caller should try os.Stat
|
||||||
|
|||||||
@@ -66,3 +66,32 @@ func TestResolveSPAFilePath(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldSkipSPAFallback(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "api root", path: "/api", want: true},
|
||||||
|
{name: "api sub path", path: "/api/install/defaults", want: true},
|
||||||
|
{name: "admin api root", path: "/admin/api", want: true},
|
||||||
|
{name: "admin api sub path", path: "/admin/api/v1/orders", want: true},
|
||||||
|
{name: "payments root", path: "/payments", want: true},
|
||||||
|
{name: "payments sub path", path: "/payments/gmpay/v1/order/create-transaction", want: true},
|
||||||
|
{name: "pay root", path: "/pay", want: true},
|
||||||
|
{name: "pay sub path", path: "/pay/checkout-counter/abc", want: true},
|
||||||
|
{name: "not exact prefix", path: "/apiary", want: false},
|
||||||
|
{name: "normal spa route", path: "/install", want: false},
|
||||||
|
{name: "normal asset route", path: "/assets/index.js", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ShouldSkipSPAFallback(tt.path)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("ShouldSkipSPAFallback(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ClientFactory is overridden in tests to stub outbound HTTP calls.
|
||||||
|
var ClientFactory = resty.New
|
||||||
|
|
||||||
// GetHttpClient 获取请求客户端
|
// GetHttpClient 获取请求客户端
|
||||||
func GetHttpClient(proxys ...string) *resty.Client {
|
func GetHttpClient(proxys ...string) *resty.Client {
|
||||||
client := resty.New()
|
client := ClientFactory()
|
||||||
// 如果有代理
|
// 如果有代理
|
||||||
if len(proxys) > 0 {
|
if len(proxys) > 0 {
|
||||||
proxy := proxys[0]
|
proxy := proxys[0]
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/natefinch/lumberjack"
|
"github.com/natefinch/lumberjack"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package sign
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/assimon/luuu/util/json"
|
"github.com/GMWalletApp/epusdt/util/json"
|
||||||
"github.com/gookit/goutil/strutil"
|
"github.com/gookit/goutil/strutil"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./unauthorized-error-CxmfHb-a.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./unauthorized-error-9CwofmD6.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./forbidden-BEz-zjy_.js";var t=e;export{t as component};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user