mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
93 Commits
v0.0.8
..
75dfeced01
| Author | SHA1 | Date | |
|---|---|---|---|
| 75dfeced01 | |||
| bfdf453e3c | |||
| 55cc6a9c61 | |||
| 3ab3659fe4 | |||
| 04c2b9b0b6 | |||
| 9ac9894dba | |||
| 9b397bd45e | |||
| 225cae2c24 | |||
| 08d409d5eb | |||
| 54b83f8b16 | |||
| 3879853836 | |||
| 2824687d9f | |||
| 3e85bf77c2 | |||
| cd44c7c818 | |||
| c69b0d6c0c | |||
| 13c81ee560 | |||
| c38f728e10 | |||
| b70995f8e9 | |||
| fca75a99ea | |||
| 8bf2adad89 | |||
| 0d97237c71 | |||
| c65b07cfe7 | |||
| 7fcb7b38fe | |||
| 2a00a0332b | |||
| 8a48611625 | |||
| dec1b97b9e | |||
| 449d22a41b | |||
| 4b4701f5b9 | |||
| f4ef71b359 | |||
| c19a7e5f2c | |||
| 09642d4c2b | |||
| 9f9b258d59 | |||
| 85b81e661c | |||
| 049a15cb95 | |||
| 2a36a10e7f | |||
| 864eb5969c | |||
| b0093c48c7 | |||
| 4a69b7c863 | |||
| 52b171b49b | |||
| 4dc1ba88cd | |||
| 7a905ba307 | |||
| 07d1d8345f | |||
| 8d2ddbd045 | |||
| 1c805ed061 | |||
| 84972ea0f0 | |||
| 7c9e3e123e | |||
| 3e0be9bb20 | |||
| a8a3b8697f | |||
| 2d546e3478 | |||
| 1a35cab919 | |||
| ff157c33d9 | |||
| 51ad344b36 | |||
| 3623a543bb | |||
| 377176a0f2 | |||
| a4022df7b4 | |||
| 32aa1b66f5 | |||
| 53e66bad35 | |||
| ff68279b0f | |||
| 9c533a7ff8 | |||
| 785a162f88 | |||
| 06933451c5 | |||
| d17d212c24 | |||
| 51a2accd9f | |||
| 96ea7481bc | |||
| de9c45a944 | |||
| 9e885d8b2f | |||
| 74c3a3a08c | |||
| 0a19854602 | |||
| 67263da0f1 | |||
| 95879e33d4 | |||
| 0c059e8a28 | |||
| 485059f60e | |||
| 143bc84ac3 | |||
| 06856e5caa | |||
| b9bde0f95b | |||
| 1b92ec1905 | |||
| 89be1fc967 | |||
| 91936d580f | |||
| 09514a6946 | |||
| b6cd4325b8 | |||
| 27d5e5bcfe | |||
| b625bac1e3 | |||
| bc5bbf72f7 | |||
| 3a3a830d93 | |||
| 59aa71209c | |||
| cdf274599e | |||
| 916394337b | |||
| 6ea5637f6b | |||
| b499bc01cf | |||
| 5edc9dc842 | |||
| 6bb47d4b00 | |||
| 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 私下报告
|
||||
@@ -1,32 +1,39 @@
|
||||
name: docker-alpine-epusdt
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
DOCKERHUB_NAMESPACE: gmwallet
|
||||
IMAGE_NAME: epusdt
|
||||
|
||||
jobs:
|
||||
push-docker-hub:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Compute Build Metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login
|
||||
- name: Set Up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=sha-,suffix=-alpine
|
||||
|
||||
- name: Login To Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -38,9 +45,9 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ steps.meta.outputs.commit }}
|
||||
DATE=${{ steps.meta.outputs.date }}
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
|
||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||
BUILD_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -25,6 +31,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Ensure Tag Commit Is On Default Branch
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
name: sync-www
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths-ignore:
|
||||
- "src/www/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: sync-www-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-sync:
|
||||
name: Build Frontend And Sync src/www
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
FRONTEND_REPOSITORY: ${{ vars.WWW_FRONTEND_REPOSITORY }}
|
||||
FRONTEND_REF: ${{ vars.WWW_FRONTEND_REF }}
|
||||
FRONTEND_BUILD_DIR: ${{ vars.WWW_FRONTEND_BUILD_DIR }}
|
||||
FRONTEND_TOKEN: ${{ secrets.WWW_FRONTEND_TOKEN }}
|
||||
WWW_TARGET_DIR: src/www
|
||||
|
||||
steps:
|
||||
- name: Checkout Backend
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Configuration
|
||||
run: |
|
||||
if [[ -z "$FRONTEND_REPOSITORY" ]]; then
|
||||
echo "Set repository variable WWW_FRONTEND_REPOSITORY, for example owner/repo"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$FRONTEND_REF" ]]; then
|
||||
echo "Set repository variable WWW_FRONTEND_REF, for example main"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$FRONTEND_BUILD_DIR" ]]; then
|
||||
echo "Set repository variable WWW_FRONTEND_BUILD_DIR, for example dist"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$FRONTEND_TOKEN" ]]; then
|
||||
echo "Set repository secret WWW_FRONTEND_TOKEN to read the private frontend repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clone Frontend
|
||||
run: |
|
||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||
frontend_path="$RUNNER_TEMP/$frontend_name"
|
||||
git clone \
|
||||
--depth=1 \
|
||||
--branch "$FRONTEND_REF" \
|
||||
"https://x-access-token:${FRONTEND_TOKEN}@github.com/${FRONTEND_REPOSITORY}.git" \
|
||||
"$frontend_path"
|
||||
|
||||
- name: Set Up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||
cd "$RUNNER_TEMP/$frontend_name"
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
- name: Replace src/www
|
||||
run: |
|
||||
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||
cd "$RUNNER_TEMP/$frontend_name"
|
||||
if [[ ! -d "$FRONTEND_BUILD_DIR" ]]; then
|
||||
echo "Frontend build output does not exist: $FRONTEND_BUILD_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
target_path="$GITHUB_WORKSPACE/$WWW_TARGET_DIR"
|
||||
mkdir -p "$target_path"
|
||||
rsync -a --delete "$FRONTEND_BUILD_DIR"/ "$target_path"/
|
||||
|
||||
- name: Commit And Push
|
||||
run: |
|
||||
if [[ -z "$(git status --porcelain -- "$WWW_TARGET_DIR")" ]]; then
|
||||
echo "src/www is already up to date"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add "$WWW_TARGET_DIR"
|
||||
git commit -m "chore(www): sync frontend build"
|
||||
git pull --rebase origin "$GITHUB_REF_NAME"
|
||||
git push origin "HEAD:$GITHUB_REF_NAME"
|
||||
+8
-4
@@ -5,19 +5,23 @@ ENV CGO_ENABLED=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN git clone https://github.com/GMwalletApp/epusdt.git .
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app/src
|
||||
ARG BUILD_VERSION=0.0.0-dev
|
||||
RUN go mod download
|
||||
RUN go build -o /app/epusdt .
|
||||
RUN go build -trimpath -ldflags="-s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion=${BUILD_VERSION}" -o /app/epusdt .
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
ARG API_RATE_URL=""
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/src/static /app/static
|
||||
COPY --from=builder /app/src/static /static
|
||||
COPY --from=builder /app/src/.env.example /app/.env
|
||||
RUN if [ -n "$API_RATE_URL" ]; then \
|
||||
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
|
||||
fi
|
||||
COPY --from=builder /app/epusdt .
|
||||
|
||||
VOLUME /app/conf
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# Epusdt — Easy Payment USDT
|
||||
|
||||
<p align="center">
|
||||
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Open-source multi-chain crypto payment gateway · Top real-world adoption</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/Docs-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-Channel-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
||||
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-Group-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
|
||||
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is Epusdt?
|
||||
|
||||
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It started as a TRC20-only solution and evolved into a broader **multi-chain receiving platform**, allowing websites and applications to accept crypto payments across multiple networks and token types. No third-party custody, no platform fees — funds go directly into your wallet.
|
||||
|
||||
> **GitHub Star 3000+** · **10+ supported platform integrations** · **Top real-world adoption among crypto payment tools**
|
||||
|
||||
Deploy it privately, integrate with the HTTP API, and start receiving crypto payments in minutes.
|
||||
|
||||
### Supported Networks and Tokens
|
||||
|
||||
| Network | Tokens |
|
||||
|---------|--------|
|
||||
| **TRC20** (Tron) | USDT, TRX |
|
||||
| **ERC20** (Ethereum) | USDT, USDC, ETH |
|
||||
| **Solana** | USDT, USDC |
|
||||
| **BEP20** (BSC) | USDT, USDC, BNB |
|
||||
| **Polygon** | USDT, USDC |
|
||||
| **More** | Expanding continuously |
|
||||
|
||||
> Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com).
|
||||
|
||||
---
|
||||
|
||||
## Broad Compatibility, Plug and Play
|
||||
|
||||
No matter what kind of platform you operate, Epusdt can fit into existing payment integration patterns with **minimal or no business logic refactor**, giving you a fast path to crypto payments for global use cases:
|
||||
|
||||
| Category | Supported Systems |
|
||||
|----------|-------------------|
|
||||
| **AI Distribution** | [OneAPI](https://github.com/songquanpeng/one-api), [NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **Card/Voucher Platforms** | [Dujiaoka](https://dujiao-next.com/), [ACG Faka](https://github.com/lizhipay/acg-faka) |
|
||||
| **Proxy Panels** | [V2Board](https://github.com/v2board/v2board), [XBoard](https://github.com/cedar2025/Xboard), [xiaoV2board](https://github.com/wyx2685/v2board/), [SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||
| **Website Ecosystem** | [WordPress](https://wordpress.com/), [WHMCS](https://www.whmcs.com/) |
|
||||
| **Epay-Compatible Flow** | Works with platforms already using Epay-style payment interfaces |
|
||||
| **More** | Simple HTTP API, often integrated within 10 minutes |
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, and more
|
||||
- **Self-hosted** — full control of funds and wallet infrastructure
|
||||
- **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis
|
||||
- **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac
|
||||
- **Multi-wallet rotation** — rotates receiving addresses to improve concurrency handling
|
||||
- **Async callback queue** — handles merchant notifications efficiently
|
||||
- **HTTP API** — easy integration from any language or framework
|
||||
- **Telegram Bot** — instant payment notifications and lightweight operations
|
||||
|
||||
---
|
||||
|
||||
## Documentation and Guides
|
||||
|
||||
Full documentation: **[epusdt.com](https://epusdt.com)**
|
||||
|
||||
Quick-start links:
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
|
||||
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
|
||||
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
|
||||
| [Developer API Docs](https://epusdt.com) | Integration reference |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
Epusdt
|
||||
├── src/ Core project source code
|
||||
└── wiki/ Documentation assets and knowledge base
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
Epusdt listens to supported blockchain APIs or RPC nodes across multiple networks such as TRC20, ERC20, BEP20, and Polygon. It captures incoming token transfers in real time and matches ownership using **amount differentiation** and **time-bounded locks**:
|
||||
|
||||
```text
|
||||
Workflow:
|
||||
1. A customer initiates payment and needs to send 20.05 USDT
|
||||
2. The system searches for an available wallet-address + amount combination
|
||||
3. If address_1:20.05 is free -> lock it for 10 minutes and return it
|
||||
4. If already occupied -> add 0.0001 and try the next amount combination
|
||||
5. Background listeners keep watching wallet deposits and mark the order paid once the amount matches
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Community and Support
|
||||
|
||||
If you hit an issue, please open a [GitHub Issue](https://github.com/GMWalletApp/epusdt/issues) first. That is the best way to get attention and help.
|
||||
|
||||
| Channel | Link |
|
||||
|---------|------|
|
||||
| Telegram Channel | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||
| Telegram Group | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||
| Official Docs | [https://epusdt.com](https://epusdt.com) |
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?type=date&repos=gmwalletapp%2Fepusdt">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Epusdt is released under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer and Terms of Use
|
||||
|
||||
EPusdt is developed and published by Good Morning Technology, LLC as free, open-source, non-profit, and non-custodial software for learning, research, and technical communication. Nothing in this project constitutes investment, financial, legal, tax, compliance, or any other professional advice, nor should it be interpreted as a guarantee regarding any asset, transaction outcome, revenue, fund safety, technical availability, or fitness for a particular purpose.
|
||||
|
||||
Good Morning Technology, LLC is an entity organized under United States law and will comply with applicable legal and regulatory obligations as required. Under U.S. regulatory frameworks, whether virtual currencies, digital assets, or related technical services constitute money transmission, a money services business (MSB), or another regulated activity generally depends on the specific business model, including whether the operator receives, holds, controls, or transmits funds or value, provides custody on behalf of users, or engages in exchange, payment, clearing, settlement, or other intermediary financial services.
|
||||
|
||||
As a free, open-source, non-profit, and non-custodial software project, the source code, documentation, technical communication, and related development activities of EPusdt should not be interpreted as authorization, endorsement, control, participation, warranty, or commitment by Good Morning Technology, LLC or its contributors regarding any user's downstream use, modification, deployment, integration, distribution, or secondary development.
|
||||
|
||||
Users independently determine how they use this project, for what purpose, and in what context. Good Morning Technology, LLC and its contributors cannot control, review, or restrict such conduct. Users are solely responsible for ensuring that their use, modification, deployment, integration, distribution, or derivative development complies with applicable laws, regulations, sanctions rules, and third-party rights in the relevant jurisdictions, and they bear all resulting risks, liabilities, and consequences.
|
||||
|
||||
Crypto assets are a high-risk emerging asset class. Stablecoins and other digital assets may experience sharp volatility, de-pegging, insufficient liquidity, technical failures, regulatory changes, or total loss of value. All code, documentation, and related materials in this project are provided on an "as is" and "as available" basis. Except where mandatory law provides otherwise, Good Morning Technology, LLC and its contributors are not liable for any direct or indirect loss resulting from use, inability to use, misuse, unlawful use, modification, deployment, integration, distribution, derivative development, or reliance on this project.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · Dujiaoka Payment · ACG Faka Payment ·
|
||||
V2Board Payment · XBoard Payment · SSPanel Payment Interface ·
|
||||
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||
Epusdt · Easy Payment USDT · Open Source Payment Gateway · Multi-chain Collection
|
||||
</sub>
|
||||
</p>
|
||||
@@ -1,13 +1,18 @@
|
||||
# Epusdt — Easy Payment USDT
|
||||
|
||||
<p align="center">
|
||||
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
||||
@@ -15,69 +20,74 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||
<a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
|
||||
<a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌍 What is Epusdt?
|
||||
## What is Epusdt?
|
||||
|
||||
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
|
||||
**Epusdt** (Easy Payment USDT) 是一个基于 Go 构建、支持私有化部署的 **多链多币种 Crypto 支付网关**。它从最初的 TRC20 单链方案逐步演进为完整的 **多链收款平台**,让任意网站或应用都能快速接入多条链、多种代币的加密支付能力。没有第三方托管,没有平台抽成,资金直接进入你的钱包。
|
||||
|
||||
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
|
||||
> **GitHub Star 3000+** · **已支持站点解决方案 10+** · **Crypto 支付工具实际采用率 Top 1**
|
||||
|
||||
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
|
||||
私有部署,按 HTTP API 接入,几分钟内就可以开始接收 **Crypto Payments**。
|
||||
|
||||
### 🔗 已支持网络与代币
|
||||
### 已支持网络与代币
|
||||
|
||||
| 网络 | 代币 |
|
||||
|------|------|
|
||||
| **TRC20** (Tron) | USDT、TRX|
|
||||
| **TRC20** (Tron) | USDT、TRX |
|
||||
| **ERC20** (Ethereum) | USDT、USDC、ETH |
|
||||
| **Solana** | USDT、USDC |
|
||||
| **BEP20** (BSC) | USDT、USDC、BNB |
|
||||
| **Polygon** | USDT、USDC |
|
||||
| **更多** | 持续扩展中… |
|
||||
|
||||
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||
> 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 广泛兼容,即插即用
|
||||
## 安全审计
|
||||
Epusdt 已完成第三方安全审计。
|
||||
[查看安全审计报告](https://github.com/VectorBits/audit/blob/main/epusdt-secure-audit-report-2026-05-14.pdf)
|
||||
|
||||
---
|
||||
|
||||
## 广泛兼容,即插即用
|
||||
|
||||
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
||||
|
||||
| 领域 | 已支持系统 |
|
||||
|------|-----------|
|
||||
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
||||
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
||||
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
|
||||
| **更多** | 简易HTTP API 10分钟内接入 |
|
||||
|
||||
👉 查看更多集成列表与插件:[plugins/](plugins/)
|
||||
| **Epay 兼容** | 兼容各类支持 Epay 易支付接口的平台 |
|
||||
| **更多** | 简易 HTTP API,10 分钟内接入 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
## 核心特性
|
||||
|
||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
|
||||
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
|
||||
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
|
||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
|
||||
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
|
||||
- **异步队列** — 高性能消息回调,优雅处理高并发场景
|
||||
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
|
||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络
|
||||
- **私有化部署** — 资金完全自主掌控
|
||||
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
|
||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
|
||||
- **多钱包轮询** — 自动轮换收款地址,提高并发处理能力
|
||||
- **异步队列** — 高性能消息回调,适配高并发场景
|
||||
- **HTTP API** — 标准化接口,任何语言 / 框架都能快速集成
|
||||
- **Telegram Bot** — 实时支付通知,快捷管理与监控
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档与教程
|
||||
## 文档与教程
|
||||
|
||||
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
|
||||
完整文档请访问:**[epusdt.com](https://epusdt.com)**
|
||||
|
||||
快速入门:
|
||||
|
||||
@@ -90,29 +100,51 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 项目结构
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
```text
|
||||
Epusdt
|
||||
├── plugins/ → 已集成的系统插件(独角数卡等)
|
||||
├── src/ → 项目核心代码
|
||||
├── sdk/ → 接入 SDK
|
||||
├── sql/ → 数据库安装 / 升级脚本
|
||||
└── wiki/ → 文档与知识库
|
||||
├── src/ 项目核心代码
|
||||
└── wiki/ 文档与知识库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 实现原理
|
||||
## 程序截图
|
||||
|
||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<img src="wiki/img/web2.png" alt="Epusdt 管理面板首页" height="260"><br>
|
||||
<sub>管理面板首页</sub>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<img src="wiki/img/web1.png" alt="Epusdt 管理面板" height="260"><br>
|
||||
<sub>管理面板</sub>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<img src="wiki/img/pay1.jpeg" alt="Epusdt 收银台" height="260"><br>
|
||||
<sub>收银台</sub>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<img src="wiki/img/pay2.jpeg" alt="Epusdt 支付页面" height="260"><br>
|
||||
<sub>支付页面</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```
|
||||
---
|
||||
|
||||
## 实现原理
|
||||
|
||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||
|
||||
```text
|
||||
工作流程:
|
||||
1. 客户发起支付,需支付 20.05 USDT
|
||||
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
||||
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
||||
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||
2. 系统在哈希表中查找可用的钱包地址 + 金额组合
|
||||
3. 若 address_1:20.05 未被占用 -> 锁定该组合(有效期 10 分钟),返回给客户
|
||||
4. 若已被占用 -> 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||
```
|
||||
|
||||
@@ -120,17 +152,15 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
|
||||
|
||||
---
|
||||
|
||||
## 💬 社区与支持
|
||||
## 社区与支持
|
||||
|
||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
||||
|
||||
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会优先处理反馈。
|
||||
|
||||
| 渠道 | 链接 |
|
||||
|------|------|
|
||||
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
|
||||
| Epusdt 频道 | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||
| Epusdt 交流群 | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||
| 官方文档站 | [https://epusdt.com](https://epusdt.com) |
|
||||
|
||||
---
|
||||
|
||||
@@ -146,24 +176,32 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
|
||||
|
||||
---
|
||||
|
||||
## 📜 开源协议
|
||||
## 开源协议
|
||||
|
||||
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
## 免责声明及使用条款
|
||||
|
||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
||||
EPusdt 由 Good Morning Technology, LLC 以免费、开源、非盈利及非托管性质开发和披露,仅供学习、研究与技术交流使用。项目本身不构成投资、金融、法律、税务、合规或任何其他专业建议,也不应被视为对任何资产、交易结果、收益、资金安全、技术可用性或特定用途作出任何保证。
|
||||
|
||||
Good Morning Technology, LLC 为依据美国法律设立的主体,并将在适用法律法规范围内履行相应合规义务。就美国监管框架而言,虚拟货币、数字资产及相关技术服务是否构成 money transmission、money services business(MSB)或其他受监管活动,通常取决于具体业务模式,包括项目方是否接收、持有、控制或传输资金或价值,是否代表用户托管资产,以及是否从事兑换、支付、清算、结算或其他中介性金融服务。
|
||||
|
||||
作为免费、开源、非盈利、非托管的软件项目,EPusdt 的代码披露、文档说明、技术交流及相关开发活动,本身不应被理解为 Good Morning Technology, LLC 或其贡献者对任何用户后续使用、修改、部署、集成、分发或二次开发行为的授权、背书、控制、参与、保证或承诺。
|
||||
|
||||
用户对本项目的实际使用方式、使用目的及后续行为由其自行决定,Good Morning Technology, LLC 及其贡献者无法控制、审查或限制该等行为。用户应自行确保其使用、修改、部署、集成、分发或二次开发行为符合所在地适用法律法规、监管要求、制裁规则及第三方权利,并独立承担由此产生的全部风险、责任与后果。
|
||||
|
||||
加密资产属于高风险新兴资产类别,包括稳定币在内的数字资产均可能发生剧烈波动、脱锚、流动性不足、技术故障、监管变化或价值归零等风险。本项目所有代码、文档及相关材料均按“现状”和“可用状态”提供。除适用法律另有强制规定外,Good Morning Technology, LLC 及其贡献者不因用户使用、无法使用、错误使用、违法使用、修改、部署、集成、分发、二次开发或依赖本项目而产生的任何直接或间接损失承担责任。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
|
||||
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
|
||||
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
|
||||
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
|
||||
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
epusdt:
|
||||
image: gmwallet/epusdt:alpine
|
||||
image: gmwallet/epusdt:latest
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
## 使用方法
|
||||
### 注意此插件仅适用于独角数卡2.0.4版本以下,2.0.4版本或以上的独角数卡已经内置此插件,无需配置
|
||||
|
||||
1.将`app`和`routes`目录覆盖到网站根目录。
|
||||
2.在独角数卡后台添加一个支付方式。
|
||||
|
||||
| 支付选项 | 商户id | 商户key | 商户密钥 | 备注 |
|
||||
| :-----| :----- | :----- | :----- |:-----|
|
||||
| Epusdt | api接口认证token | 空 | epusdt收银台地址+/api/v1/order/create-transaction| 如果独角数卡和epusdt在同一服务器则填写`127.0.0.1`不要填域名,例如`http://127.0.0.1:8000/api/v1/order/create-transaction` |
|
||||
|
||||
示例:
|
||||

|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Pay;
|
||||
|
||||
|
||||
use App\Exceptions\RuleValidationException;
|
||||
use App\Http\Controllers\PayController;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EpusdtController extends PayController
|
||||
{
|
||||
public function gateway(string $payway, string $orderSN)
|
||||
{
|
||||
try {
|
||||
// 加载网关
|
||||
$this->loadGateWay($orderSN, $payway);
|
||||
//构造要请求的参数数组,无需改动
|
||||
$parameter = [
|
||||
"amount" => (float)$this->order->actual_price,//原价
|
||||
"order_id" => $this->order->order_sn, //可以是用户ID,站内商户订单号,用户名
|
||||
'redirect_url' => route('epusdt-return', ['order_id' => $this->order->order_sn]),
|
||||
'notify_url' => url($this->payGateway->pay_handleroute . '/notify_url'),
|
||||
];
|
||||
$parameter['signature'] = $this->epusdtSign($parameter, $this->payGateway->merchant_id);
|
||||
$client = new Client([
|
||||
'headers' => [ 'Content-Type' => 'application/json' ]
|
||||
]);
|
||||
$response = $client->post($this->payGateway->merchant_pem, ['body' => json_encode($parameter)]);
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
if (!isset($body['status_code']) || $body['status_code'] != 200) {
|
||||
return $this->err(__('dujiaoka.prompt.abnormal_payment_channel') . $body['message']);
|
||||
}
|
||||
return redirect()->away($body['data']['payment_url']);
|
||||
} catch (RuleValidationException $exception) {
|
||||
} catch (GuzzleException $exception) {
|
||||
return $this->err($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function epusdtSign(array $parameter, string $signKey)
|
||||
{
|
||||
ksort($parameter);
|
||||
reset($parameter); //内部指针指向数组中的第一个元素
|
||||
$sign = '';
|
||||
$urls = '';
|
||||
foreach ($parameter as $key => $val) {
|
||||
if ($val == '') continue;
|
||||
if ($key != 'signature') {
|
||||
if ($sign != '') {
|
||||
$sign .= "&";
|
||||
$urls .= "&";
|
||||
}
|
||||
$sign .= "$key=$val"; //拼接为url参数形式
|
||||
$urls .= "$key=" . urlencode($val); //拼接为url参数形式
|
||||
}
|
||||
}
|
||||
$sign = md5($sign . $signKey);//密码追加进入开始MD5签名
|
||||
return $sign;
|
||||
}
|
||||
|
||||
public function notifyUrl(Request $request)
|
||||
{
|
||||
$data = $request->all();
|
||||
$order = $this->orderService->detailOrderSN($data['order_id']);
|
||||
if (!$order) {
|
||||
return 'fail';
|
||||
}
|
||||
$payGateway = $this->payService->detail($order->pay_id);
|
||||
if (!$payGateway) {
|
||||
return 'fail';
|
||||
}
|
||||
$signature = $this->epusdtSign($data, $payGateway->merchant_id);
|
||||
if ($data['signature'] != $signature) { //不合法的数据
|
||||
return 'fail'; //返回失败 继续补单
|
||||
} else {
|
||||
//合法的数据
|
||||
//业务处理
|
||||
$this->orderProcessService->completedOrder($data['order_id'], $data['amount'], $data['trade_id']);
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
public function returnUrl(Request $request)
|
||||
{
|
||||
$oid = $request->get('order_id');
|
||||
// 异步通知还没到就跳转了,所以这里休眠2秒
|
||||
sleep(2);
|
||||
return redirect(url('detail-order-sn', ['orderSN' => $oid]));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('pay-gateway/{handle}/{payway}/{orderSN}', 'PayController@redirectGateway');
|
||||
|
||||
// 支付相关
|
||||
Route::group(['prefix' => 'pay', 'namespace' => 'Pay', 'middleware' => ['dujiaoka.pay_gate_way']], function () {
|
||||
// 支付宝
|
||||
Route::get('alipay/{payway}/{orderSN}', 'AlipayController@gateway');
|
||||
Route::post('alipay/notify_url', 'AlipayController@notifyUrl');
|
||||
// 微信
|
||||
Route::get('wepay/{payway}/{orderSN}', 'WepayController@gateway');
|
||||
Route::post('wepay/notify_url', 'WepayController@notifyUrl');
|
||||
// 码支付
|
||||
Route::get('mapay/{payway}/{orderSN}', 'MapayController@gateway');
|
||||
Route::post('mapay/notify_url', 'MapayController@notifyUrl');
|
||||
// Paysapi
|
||||
Route::get('paysapi/{payway}/{orderSN}', 'PaysapiController@gateway');
|
||||
Route::post('paysapi/notify_url', 'PaysapiController@notifyUrl');
|
||||
Route::get('paysapi/return_url', 'PaysapiController@returnUrl')->name('paysapi-return');
|
||||
// payjs
|
||||
Route::get('payjs/{payway}/{orderSN}', 'PayjsController@gateway');
|
||||
Route::post('payjs/notify_url', 'PayjsController@notifyUrl');
|
||||
// 易支付
|
||||
Route::get('yipay/{payway}/{orderSN}', 'YipayController@gateway');
|
||||
Route::get('yipay/notify_url', 'YipayController@notifyUrl');
|
||||
Route::get('yipay/return_url', 'YipayController@returnUrl')->name('yipay-return');
|
||||
// paypal
|
||||
Route::get('paypal/{payway}/{orderSN}', 'PaypalPayController@gateway');
|
||||
Route::get('paypal/return_url', 'PaypalPayController@returnUrl')->name('paypal-return');
|
||||
Route::any('paypal/notify_url', 'PaypalPayController@notifyUrl');
|
||||
// V免签
|
||||
Route::get('vpay/{payway}/{orderSN}', 'VpayController@gateway');
|
||||
Route::get('vpay/notify_url', 'VpayController@notifyUrl');
|
||||
Route::get('vpay/return_url', 'VpayController@returnUrl')->name('vpay-return');
|
||||
// stripe
|
||||
Route::get('stripe/{payway}/{oid}','StripeController@gateway');
|
||||
Route::get('stripe/return_url','StripeController@returnUrl');
|
||||
Route::get('stripe/check','StripeController@check');
|
||||
Route::get('stripe/charge','StripeController@charge');
|
||||
// Coinbase
|
||||
Route::get('coinbase/{payway}/{orderSN}', 'CoinbaseController@gateway');
|
||||
Route::post('coinbase/notify_url', 'CoinbaseController@notifyUrl');
|
||||
// epusdt
|
||||
Route::get('epusdt/{payway}/{orderSN}', 'EpusdtController@gateway');
|
||||
Route::post('epusdt/notify_url', 'EpusdtController@notifyUrl');
|
||||
Route::get('epusdt/return_url', 'EpusdtController@returnUrl')->name('epusdt-return');
|
||||
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
-- auto-generated definition
|
||||
create table orders
|
||||
(
|
||||
id int auto_increment
|
||||
primary key,
|
||||
trade_id varchar(32) not null comment 'epusdt订单号',
|
||||
order_id varchar(32) not null comment '客户交易id',
|
||||
block_transaction_id varchar(128) null comment '区块唯一编号',
|
||||
actual_amount decimal(19, 4) not null comment '订单实际需要支付的金额,保留4位小数',
|
||||
amount decimal(19, 4) not null comment '订单金额,保留4位小数',
|
||||
token varchar(50) not null comment '所属钱包地址',
|
||||
status int default 1 not null comment '1:等待支付,2:支付成功,3:已过期',
|
||||
notify_url varchar(128) not null comment '异步回调地址',
|
||||
redirect_url varchar(128) null comment '同步回调地址',
|
||||
callback_num int default 0 null comment '回调次数',
|
||||
callback_confirm int default 2 null comment '回调是否已确认? 1是 2否',
|
||||
created_at timestamp null,
|
||||
updated_at timestamp null,
|
||||
deleted_at timestamp null,
|
||||
constraint orders_order_id_uindex
|
||||
unique (order_id),
|
||||
constraint orders_trade_id_uindex
|
||||
unique (trade_id)
|
||||
);
|
||||
|
||||
create index orders_block_transaction_id_index
|
||||
on orders (block_transaction_id);
|
||||
|
||||
-- auto-generated definition
|
||||
create table wallet_address
|
||||
(
|
||||
id int auto_increment
|
||||
primary key,
|
||||
token varchar(50) not null comment '钱包token',
|
||||
status int default 1 not null comment '1:启用 2:禁用',
|
||||
created_at timestamp null,
|
||||
updated_at timestamp null,
|
||||
deleted_at timestamp null
|
||||
)
|
||||
comment '钱包表';
|
||||
|
||||
create index wallet_address_token_index
|
||||
on wallet_address (token);
|
||||
|
||||
+6
-39
@@ -1,14 +1,14 @@
|
||||
app_name=epusdt
|
||||
app_uri=https://dujiaoka.com
|
||||
app_uri=https://epusdt.com
|
||||
log_level=info
|
||||
http_access_log=false
|
||||
sql_debug=false
|
||||
http_listen=:8000
|
||||
|
||||
static_path=/static
|
||||
runtime_root_path=/runtime
|
||||
runtime_root_path=./runtime
|
||||
|
||||
log_save_path=/logs
|
||||
log_save_path=./logs
|
||||
log_max_size=32
|
||||
log_max_age=7
|
||||
max_backups=3
|
||||
@@ -20,27 +20,6 @@ db_type=sqlite
|
||||
sqlite_database_filename=
|
||||
sqlite_table_prefix=
|
||||
|
||||
# postgres config
|
||||
postgres_host=127.0.0.1
|
||||
postgres_port=3306
|
||||
postgres_user=mysql_user
|
||||
postgres_passwd=mysql_password
|
||||
postgres_database=database_name
|
||||
postgres_table_prefix=
|
||||
postgres_max_idle_conns=10
|
||||
postgres_max_open_conns=100
|
||||
postgres_max_life_time=6
|
||||
|
||||
# mysql config
|
||||
mysql_host=127.0.0.1
|
||||
mysql_port=3306
|
||||
mysql_user=mysql_user
|
||||
mysql_passwd=mysql_password
|
||||
mysql_database=database_name
|
||||
mysql_table_prefix=
|
||||
mysql_max_idle_conns=10
|
||||
mysql_max_open_conns=100
|
||||
mysql_max_life_time=6
|
||||
|
||||
# sqlite runtime store config
|
||||
runtime_sqlite_filename=epusdt-runtime.db
|
||||
@@ -50,22 +29,10 @@ queue_concurrency=10
|
||||
queue_poll_interval_ms=1000
|
||||
callback_retry_base_seconds=5
|
||||
|
||||
tg_bot_token=
|
||||
tg_proxy=
|
||||
tg_manage=
|
||||
|
||||
api_auth_token=
|
||||
|
||||
order_expiration_time=10
|
||||
order_notice_max_retry=0
|
||||
forced_usdt_rate=
|
||||
|
||||
api_rate_url=
|
||||
tron_grid_api_key=
|
||||
solana_rpc_url=
|
||||
|
||||
ethereum_ws_url=wss://ethereum.publicnode.com
|
||||
|
||||
|
||||
# epay
|
||||
epay_pid=
|
||||
epay_key=
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=true
|
||||
|
||||
@@ -15,7 +15,7 @@ builds:
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
|
||||
- -s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion={{ .Tag }} -X github.com/GMWalletApp/epusdt/config.BuildCommit={{ .Commit }} -X github.com/GMWalletApp/epusdt/config.BuildDate={{ .Date }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
|
||||
@@ -3,12 +3,16 @@ package bootstrap
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/mq"
|
||||
"github.com/assimon/luuu/task"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/mq"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
)
|
||||
|
||||
var initOnce sync.Once
|
||||
@@ -18,6 +22,52 @@ func InitApp() {
|
||||
config.Init()
|
||||
log.Init()
|
||||
dao.Init()
|
||||
logLevel := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel)
|
||||
if err := log.SetLevel(logLevel); err != nil {
|
||||
color.Red.Printf("[bootstrap] apply log level setting %q err=%s; fallback=%s\n", logLevel, err, mdb.SettingDefaultSystemLogLevel)
|
||||
if fallbackErr := log.SetLevel(mdb.SettingDefaultSystemLogLevel); fallbackErr != nil {
|
||||
color.Red.Printf("[bootstrap] apply fallback log level err=%s\n", fallbackErr)
|
||||
}
|
||||
}
|
||||
// Wire settings-table lookups into the config package so
|
||||
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||
config.SettingsGetString = func(key string) string {
|
||||
return data.GetSettingString(key, "")
|
||||
}
|
||||
// Seed rate.api_url from .env into the settings table on first run
|
||||
// so the admin UI can display and change it without a code restart.
|
||||
// Only written if the key is not already present in the DB.
|
||||
if data.GetSettingString("rate.api_url", "") == "" {
|
||||
if envURL := config.GetRateApiUrlFromEnv(); envURL != "" {
|
||||
if err := data.SetSetting("rate", "rate.api_url", envURL, "string"); err != nil {
|
||||
color.Red.Printf("[bootstrap] seed rate.api_url err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed admin account and JWT secret so the management console is
|
||||
// immediately usable on a fresh install. Both are idempotent.
|
||||
initialPassword, isNew, err := data.EnsureDefaultAdmin()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||
}
|
||||
if isNew {
|
||||
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
|
||||
color.Yellow.Printf("║ Username: %-54s║\n", "admin")
|
||||
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
|
||||
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
|
||||
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||
}
|
||||
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
|
||||
}
|
||||
// Seed one universal default API key on fresh installs. The seeded
|
||||
// key (PID=1000) works for all three gateway flows.
|
||||
_, err = data.EnsureDefaultApiKey()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
|
||||
}
|
||||
mq.Start()
|
||||
go telegram.BotStart()
|
||||
go task.Start()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/gookit/color"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,15 +5,18 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/bootstrap"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/middleware"
|
||||
"github.com/assimon/luuu/route"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
luluHttp "github.com/assimon/luuu/util/http"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/bootstrap"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/install"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/route"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -37,6 +40,14 @@ var startCmd = &cobra.Command{
|
||||
Short: "start",
|
||||
Long: "start http service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// If no config file exists, or if install=true is set in the config,
|
||||
// run the first-run install API on the same port as the main server.
|
||||
// The wizard writes the .env (with install=false) and shuts itself
|
||||
// down so bootstrap.InitApp() can read it normally on the same port.
|
||||
if config.NeedsInstall() {
|
||||
envPath, _ := config.ResolveConfigPath()
|
||||
install.RunInstallServer(install.DefaultInstallAddr, envPath)
|
||||
}
|
||||
bootstrap.InitApp()
|
||||
printBanner()
|
||||
HttpServerStart()
|
||||
@@ -53,6 +64,29 @@ func HttpServerStart() {
|
||||
route.RegisterRoute(e)
|
||||
e.Static(config.StaticPath, config.StaticFilePath)
|
||||
|
||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||
// of the working directory. main.go extracts www/ next to the binary.
|
||||
wwwRoot := "./www"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||
}
|
||||
}
|
||||
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
path := c.Request().URL.Path
|
||||
if path == "/install" || strings.HasPrefix(path, "/install/") {
|
||||
// The install wizard is only served by install.RunInstallServer
|
||||
// before bootstrap. Once main server starts, block /install.
|
||||
return true
|
||||
}
|
||||
return luluHttp.ShouldSkipSPAFallback(path)
|
||||
},
|
||||
HTML5: true,
|
||||
Index: "index.html",
|
||||
Root: wwwRoot,
|
||||
}))
|
||||
|
||||
httpListen := viper.GetString("http_listen")
|
||||
go func() {
|
||||
if err = e.Start(httpListen); err != http.ErrServerClosed {
|
||||
@@ -86,13 +120,28 @@ func customHTTPErrorHandler(err error, e echo.Context) {
|
||||
Message: msg,
|
||||
RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
|
||||
}
|
||||
// echo.HTTPError carries a real HTTP status (401 for auth failures,
|
||||
// 404 for missing routes, etc.). Propagate it instead of flattening
|
||||
// everything to 200 — clients rely on the status code.
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
e.String(http.StatusOK, he.Message.(string))
|
||||
resp.StatusCode = he.Code
|
||||
if s, ok := he.Message.(string); ok {
|
||||
resp.Message = s
|
||||
} else if he.Message != nil {
|
||||
resp.Message = http.StatusText(he.Code)
|
||||
}
|
||||
_ = e.JSON(he.Code, resp)
|
||||
return
|
||||
}
|
||||
// Internal RspError: propagate Code as both the JSON status_code and
|
||||
// the real HTTP status when it maps to one (400/401/...); business
|
||||
// codes (>=1000) map to HTTP 400 so clients get a proper 4xx while
|
||||
// still reading the granular code from the body.
|
||||
if he, ok := err.(*constant.RspError); ok {
|
||||
resp.StatusCode = he.Code
|
||||
resp.Message = he.Msg
|
||||
_ = e.JSON(he.HttpStatus(), resp)
|
||||
return
|
||||
}
|
||||
_ = e.JSON(http.StatusOK, resp)
|
||||
_ = e.JSON(http.StatusInternalServerError, resp)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package command
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
+188
-50
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
@@ -27,9 +28,6 @@ var (
|
||||
TgBotToken string
|
||||
TgProxy string
|
||||
TgManage int64
|
||||
UsdtRate float64
|
||||
RateApiUrl string
|
||||
TRON_GRID_API_KEY string
|
||||
BuildVersion = "0.0.0-dev"
|
||||
BuildCommit = "none"
|
||||
BuildDate = "unknown"
|
||||
@@ -58,6 +56,9 @@ func Init() {
|
||||
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
|
||||
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
|
||||
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
|
||||
if err = ensureConfiguredStaticFiles(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
|
||||
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
|
||||
mustMkdir(RuntimePath)
|
||||
@@ -70,9 +71,6 @@ func Init() {
|
||||
TgBotToken = viper.GetString("tg_bot_token")
|
||||
TgProxy = viper.GetString("tg_proxy")
|
||||
TgManage = viper.GetInt64("tg_manage")
|
||||
|
||||
RateApiUrl = GetRateApiUrl()
|
||||
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
|
||||
}
|
||||
|
||||
func mustMkdir(path string) {
|
||||
@@ -81,6 +79,93 @@ func mustMkdir(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureConfiguredStaticFiles() error {
|
||||
if strings.TrimSpace(StaticFilePath) == "" {
|
||||
return nil
|
||||
}
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcDir := filepath.Join(filepath.Dir(exePath), "static")
|
||||
srcInfo, err := os.Stat(srcDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !srcInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
srcAbs, err := filepath.Abs(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstAbs, err := filepath.Abs(StaticFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srcAbs == dstAbs {
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyMissingStaticFiles(srcAbs, dstAbs)
|
||||
}
|
||||
|
||||
func copyMissingStaticFiles(srcDir, dstDir string) error {
|
||||
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstPath := filepath.Join(dstDir, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
if _, err = os.Stat(dstPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(srcPath, dstPath string) error {
|
||||
in, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func normalizeLogLevel(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug", "info", "warn", "error":
|
||||
@@ -124,6 +209,57 @@ func resolveConfigFilePath() (string, error) {
|
||||
return normalizeConfiguredPath(".env")
|
||||
}
|
||||
|
||||
// NeedsInstall returns true when the first-run install wizard should run.
|
||||
// It returns true if the config file does not exist yet, or if the file
|
||||
// exists and contains install=true (an explicit reset flag).
|
||||
// Existing installs that have no install key are NOT affected.
|
||||
func NeedsInstall() bool {
|
||||
envPath, exists := ResolveConfigPath()
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
// Read just the install key without triggering full Init().
|
||||
v := viper.New()
|
||||
v.SetConfigFile(envPath)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
// Unreadable config → treat as needing install.
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
|
||||
}
|
||||
|
||||
// ResolveConfigPath returns the absolute path to the .env file and whether
|
||||
// it currently exists on disk. Unlike resolveConfigFilePath it does not
|
||||
// return an error when the file is absent — callers (e.g. the install
|
||||
// wizard) need the target path even before the file is written.
|
||||
func ResolveConfigPath() (path string, exists bool) {
|
||||
candidate := explicitConfigPath
|
||||
if candidate == "" {
|
||||
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
|
||||
candidate = e
|
||||
} else {
|
||||
candidate = ".env"
|
||||
}
|
||||
}
|
||||
// Resolve relative paths against cwd.
|
||||
p := strings.TrimSpace(candidate)
|
||||
if !filepath.IsAbs(p) {
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
p = filepath.Join(cwd, p)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err == nil && info.IsDir() {
|
||||
p = filepath.Join(p, ".env")
|
||||
info, err = os.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
return p, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func normalizeConfiguredPath(input string) (string, error) {
|
||||
path := strings.TrimSpace(input)
|
||||
if path == "" {
|
||||
@@ -178,11 +314,19 @@ func GetAppUri() string {
|
||||
return viper.GetString("app_uri")
|
||||
}
|
||||
|
||||
func GetApiAuthToken() string {
|
||||
return viper.GetString("api_auth_token")
|
||||
func GetRateApiUrl() string {
|
||||
// settings table wins (admin-configurable); .env and env var remain
|
||||
// as fallbacks for smooth migration from the old layout.
|
||||
if db := settingsRateApiUrl(); db != "" {
|
||||
return db
|
||||
}
|
||||
return GetRateApiUrlFromEnv()
|
||||
}
|
||||
|
||||
func GetRateApiUrl() string {
|
||||
// GetRateApiUrlFromEnv returns the rate API URL read only from the
|
||||
// .env / environment variables, bypassing the settings table. Used
|
||||
// by bootstrap to seed the settings table from the initial .env value.
|
||||
func GetRateApiUrlFromEnv() string {
|
||||
rateURL := viper.GetString("api_rate_url")
|
||||
if rateURL == "" {
|
||||
rateURL = os.Getenv("API_RATE_URL")
|
||||
@@ -190,7 +334,6 @@ func GetRateApiUrl() string {
|
||||
if rateURL == "" {
|
||||
log.Println("api_rate_url is empty")
|
||||
}
|
||||
RateApiUrl = rateURL
|
||||
return rateURL
|
||||
}
|
||||
|
||||
@@ -203,22 +346,33 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
if coin == base {
|
||||
return 1
|
||||
}
|
||||
if coin == "usdt" {
|
||||
switch base {
|
||||
case "usd":
|
||||
return 1
|
||||
case "cny":
|
||||
usdtRate := GetUsdtRate()
|
||||
if usdtRate > 0 {
|
||||
return 1 / usdtRate
|
||||
}
|
||||
if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
|
||||
return forcedRate
|
||||
}
|
||||
if coin == "usdt" && base == "usd" {
|
||||
return 1
|
||||
}
|
||||
if rate := getRateForCoinFromAPI(coin, base); rate > 0 {
|
||||
return rate
|
||||
}
|
||||
if coin == "ton" {
|
||||
return getRateForCoinFromAPI("toncoin", base)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getForcedRateForCoin(coin string, base string) float64 {
|
||||
raw := strings.TrimSpace(settingsForcedRateList())
|
||||
if raw != "" {
|
||||
if rate := gjson.Get(raw, base+"."+coin).Float(); rate > 0 {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
baseURL := RateApiUrl
|
||||
if baseURL == "" {
|
||||
baseURL = GetRateApiUrl()
|
||||
}
|
||||
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||
baseURL := GetRateApiUrl()
|
||||
if baseURL == "" {
|
||||
log.Printf("rate api url is empty")
|
||||
return 0.0
|
||||
@@ -250,14 +404,18 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
}
|
||||
|
||||
func GetUsdtRate() float64 {
|
||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
||||
if forcedUsdtRate > 0 {
|
||||
return forcedUsdtRate
|
||||
// rate.forced_rate_list stores token amount per one base currency unit.
|
||||
// GetUsdtRate returns the inverse display value: CNY per USDT.
|
||||
if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
|
||||
return 1 / forcedRate
|
||||
}
|
||||
if UsdtRate <= 0 {
|
||||
return 6.4
|
||||
|
||||
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||
if apiRate > 0 {
|
||||
return 1 / apiRate
|
||||
}
|
||||
return UsdtRate
|
||||
log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
|
||||
return 0
|
||||
}
|
||||
|
||||
func GetOrderExpirationTime() int {
|
||||
@@ -328,23 +486,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func GetSolanaRpcUrl() string {
|
||||
rpcUrl := viper.GetString("solana_rpc_url")
|
||||
if rpcUrl == "" {
|
||||
return "https://api.mainnet-beta.solana.com"
|
||||
}
|
||||
return rpcUrl
|
||||
}
|
||||
|
||||
func GetEthereumWsUrl() string {
|
||||
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
||||
}
|
||||
|
||||
func GetEpayPid() int {
|
||||
return viper.GetInt("epay_pid")
|
||||
}
|
||||
|
||||
func GetEpayKey() string {
|
||||
return viper.GetString("epay_key")
|
||||
}
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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) {
|
||||
t.Helper()
|
||||
|
||||
@@ -135,3 +176,242 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
|
||||
t.Fatalf("config path = %s, want %s", got, flagPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "app-static")
|
||||
dst := filepath.Join(root, "data-static")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir source: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
|
||||
t.Fatalf("write source index: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
|
||||
t.Fatalf("write source payment: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
|
||||
t.Fatalf("write source logo: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatalf("mkdir destination: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
|
||||
t.Fatalf("write existing destination index: %v", err)
|
||||
}
|
||||
|
||||
if err := copyMissingStaticFiles(src, dst); err != nil {
|
||||
t.Fatalf("copy missing static files: %v", err)
|
||||
}
|
||||
|
||||
index, err := os.ReadFile(filepath.Join(dst, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read destination index: %v", err)
|
||||
}
|
||||
if string(index) != "custom-index" {
|
||||
t.Fatalf("existing index was overwritten: %q", string(index))
|
||||
}
|
||||
|
||||
payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read copied payment: %v", err)
|
||||
}
|
||||
if string(payment) != "source-payment" {
|
||||
t.Fatalf("payment.js = %q, want source-payment", string(payment))
|
||||
}
|
||||
|
||||
logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("read copied nested asset: %v", err)
|
||||
}
|
||||
if string(logo) != "source-logo" {
|
||||
t.Fatalf("logo.png = %q, want source-logo", string(logo))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRatePrefersForcedRateList(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_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
if math.Abs(got-7.25) > 1e-9 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||
}
|
||||
if rate := GetRateForCoin("trx", "usd"); rate != 123.45 {
|
||||
t.Fatalf("GetRateForCoin(trx, usd) = %v, want 123.45", rate)
|
||||
}
|
||||
if apiCalled {
|
||||
t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
|
||||
}
|
||||
}
|
||||
|
||||
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_rate_list": `{"cny":{"usdt":0}}`,
|
||||
"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 TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_rate_list": `{"usd":{"trx":123.45}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
rate := GetRateForCoin("usdt", "cny")
|
||||
if math.Abs(rate-0.14635) > 1e-9 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRateForCoinUsesToncoinAPIFallbackForTon(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":{"toncoin":1.82}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_rate_list": `{"cny":{"ton":0}}`,
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
rate := GetRateForCoin("ton", "cny")
|
||||
if math.Abs(rate-1.82) > 1e-9 {
|
||||
t.Fatalf("GetRateForCoin(ton, cny) = %v, want 1.82", 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_rate_list": `{"cny":{"usdt":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_rate_list": `{"cny":{"usdt":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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package config
|
||||
|
||||
// Settings lookups are injected at runtime by bootstrap (after DB init)
|
||||
// to keep the config package free of a `model/data` import, which would
|
||||
// create a cycle via `model/dao -> config`.
|
||||
//
|
||||
// If unset (e.g. during early startup or tests) the getters return the
|
||||
// zero string / 0 and callers apply their own fallback behavior.
|
||||
|
||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||
// reads from the settings table. Runtime-only.
|
||||
var SettingsGetString func(key string) string
|
||||
|
||||
func settingsRateApiUrl() string {
|
||||
if SettingsGetString == nil {
|
||||
return ""
|
||||
}
|
||||
return SettingsGetString("rate.api_url")
|
||||
}
|
||||
|
||||
func settingsForcedRateList() string {
|
||||
if SettingsGetString == nil {
|
||||
return ""
|
||||
}
|
||||
return SettingsGetString("rate.forced_rate_list")
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateApiKeyRequest is the payload for creating an API key.
|
||||
// A single key is valid for both gateway flows (epay/gmpay); there is
|
||||
// no gateway_type. PID is auto-generated (incrementing from 1000);
|
||||
// no manual override.
|
||||
type CreateApiKeyRequest struct {
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
|
||||
IpWhitelist string `json:"ip_whitelist" example:""`
|
||||
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// CreateApiKeyResponse is the response for a newly created API key.
|
||||
type CreateApiKeyResponse struct {
|
||||
ID uint64 `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"My API Key"`
|
||||
Pid string `json:"pid" example:"1003"`
|
||||
// SecretKey is returned ONCE on creation. After that, fetch via
|
||||
// GET /api-keys/:id/secret or rotate to generate a new one.
|
||||
SecretKey string `json:"secret_key" example:"secret123abc456"`
|
||||
}
|
||||
|
||||
// UpdateApiKeyRequest is the payload for updating an API key.
|
||||
type UpdateApiKeyRequest struct {
|
||||
Name *string `json:"name" example:"Updated Key Name"`
|
||||
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
|
||||
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
|
||||
type ChangeApiKeyStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
|
||||
// mdb.ApiKey json tag (`json:"-"`).
|
||||
// @Summary List API keys
|
||||
// @Description Returns all API keys
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [get]
|
||||
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
||||
rows, err := data.ListApiKeys()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateApiKey mints a new universal key. PID is auto-incremented from
|
||||
// the highest existing numeric PID (starting at 1000). Secret is
|
||||
// randomly generated and returned once.
|
||||
// @Summary Create API key
|
||||
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateApiKeyRequest true "API key payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [post]
|
||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||
req := new(CreateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Retry on unique-index violation: two concurrent creates could
|
||||
// both see the same max PID from NextPid() and race on INSERT.
|
||||
// The loser re-reads and retries with the now-higher max.
|
||||
const maxAttempts = 5
|
||||
secret := randomHex(32)
|
||||
var row *mdb.ApiKey
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
pid, err := data.NextPid()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row = &mdb.ApiKey{
|
||||
Name: req.Name,
|
||||
Pid: strconv.Itoa(pid),
|
||||
SecretKey: secret,
|
||||
IpWhitelist: req.IpWhitelist,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
err = data.CreateApiKey(row)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
}
|
||||
return c.SucJson(ctx, CreateApiKeyResponse{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Pid: row.Pid,
|
||||
SecretKey: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// isUniqueViolation matches the driver-specific strings for a unique-
|
||||
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
|
||||
// the PID retry loop; a cleaner approach would be a typed error but
|
||||
// GORM doesn't surface one portably.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unique") ||
|
||||
strings.Contains(msg, "duplicate") ||
|
||||
strings.Contains(msg, "constraint failed")
|
||||
}
|
||||
|
||||
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
|
||||
// and enable/disable have their own endpoints.
|
||||
// @Summary Update API key
|
||||
// @Description Patch API key name / ip_whitelist / notify_url
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.IpWhitelist != nil {
|
||||
fields["ip_whitelist"] = *req.IpWhitelist
|
||||
}
|
||||
if req.NotifyUrl != nil {
|
||||
fields["notify_url"] = *req.NotifyUrl
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatus toggles enable/disable.
|
||||
// @Summary Change API key status
|
||||
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(ChangeApiKeyStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
|
||||
// @Summary Rotate API key secret
|
||||
// @Description Generate a new secret_key. Returned only once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
|
||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
secret := randomHex(32)
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": secret})
|
||||
}
|
||||
|
||||
// DeleteApiKey soft-deletes a row. Orders created with this key will
|
||||
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
|
||||
// disabling via /status over deletion if there are live orders.
|
||||
// @Summary Delete API key
|
||||
// @Description Soft-delete an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// GetApiKeyStats returns usage stats for one key. Currently thin — just
|
||||
// the stored call_count and last_used_at — since we don't persist per-
|
||||
// call telemetry. Expanded later if/when we add a request log table.
|
||||
// @Summary Get API key stats
|
||||
// @Description Returns usage stats (call_count, last_used_at) for an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/stats [get]
|
||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"call_count": row.CallCount,
|
||||
"last_used_at": row.LastUsedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetApiKeySecret returns the secret_key for one API key. Requires
|
||||
// admin JWT authentication.
|
||||
// @Summary View API key secret
|
||||
// @Description Returns the secret_key for an API key (admin only)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=map[string]string}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/secret [get]
|
||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// LoginRequest is the payload for admin login.
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" validate:"required" example:"admin"`
|
||||
Password string `json:"password" validate:"required" example:"password123"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response for a successful login.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
|
||||
Username string `json:"username" example:"admin"`
|
||||
UserID uint64 `json:"user_id" example:"1"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest is the payload for changing admin password.
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" validate:"required" example:"old_pass"`
|
||||
NewPassword string `json:"new_password" validate:"required|minLen:6" example:"new_pass123"`
|
||||
}
|
||||
|
||||
// MeResponse wraps AdminUser with a default-password flag.
|
||||
type MeResponse struct {
|
||||
mdb.AdminUser
|
||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
||||
}
|
||||
|
||||
// InitialPasswordResponse is returned by the one-time initial password API.
|
||||
type InitialPasswordResponse struct {
|
||||
Username string `json:"username" example:"admin"`
|
||||
Password string `json:"password" example:"a1b2c3d4e5f6"`
|
||||
}
|
||||
|
||||
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
||||
// @Summary Admin login
|
||||
// @Description Verify credentials and return a signed JWT token
|
||||
// @Tags Admin Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.LoginResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/login [post]
|
||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
req := new(LoginRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
|
||||
}
|
||||
if user.Status != mdb.AdminUserStatusEnable {
|
||||
return c.FailJson(ctx, constant.AdminUserDisabledErr)
|
||||
}
|
||||
token, err := appjwt.Sign(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
err = data.TouchAdminUserLastLogin(user.ID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, LoginResponse{
|
||||
Token: token,
|
||||
Username: user.Username,
|
||||
UserID: user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPassword returns the one-time initial admin password.
|
||||
// @Summary Get initial admin password (one-time)
|
||||
// @Description Returns the initial random admin password once, then invalidates it.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password [get]
|
||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||
password, err := data.ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
||||
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
||||
}
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, InitialPasswordResponse{
|
||||
Username: "admin",
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPasswordHash returns the initial-password fingerprint so the
|
||||
// frontend can warn until the password is changed.
|
||||
// @Summary Get initial admin password hash
|
||||
// @Description Returns the initial password hash and password-changed flag.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password-hash [get]
|
||||
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
|
||||
info, err := data.GetInitialAdminPasswordHashInfo()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, info)
|
||||
}
|
||||
|
||||
// Logout is a no-op stub — tokens are stateless and the frontend
|
||||
// discards them. Kept for API symmetry with the documented spec.
|
||||
// @Summary Admin logout
|
||||
// @Description Logout (no-op, frontend discards token)
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/logout [post]
|
||||
func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Me returns the currently authenticated admin user profile.
|
||||
// @Summary Get current admin profile
|
||||
// @Description Returns the currently authenticated admin user
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.MeResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/me [get]
|
||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
return c.FailJson(ctx, constant.AdminUserNotFoundErr)
|
||||
}
|
||||
// Warn the frontend when the operator hasn't changed the seeded
|
||||
// initial password.
|
||||
isDefault := data.IsUsingInitialAdminPassword()
|
||||
return c.SucJson(ctx, MeResponse{
|
||||
AdminUser: *user,
|
||||
PasswordIsDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword requires the old password, updates to the new one.
|
||||
// @Summary Change admin password
|
||||
// @Description Change the current admin user's password
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.ChangePasswordRequest true "Password payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/password [post]
|
||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||
}
|
||||
req := new(ChangePasswordRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||
return c.FailJson(ctx, constant.AdminOldPasswordErr)
|
||||
}
|
||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/controller"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Ctrl is the singleton admin controller. Keeps parity with comm.Ctrl.
|
||||
var Ctrl = &BaseAdminController{}
|
||||
|
||||
// BaseAdminController aggregates admin-domain handlers. Split across
|
||||
// *_controller.go files in this package; all receive methods hang off
|
||||
// this type so a single router registration is enough.
|
||||
type BaseAdminController struct {
|
||||
controller.BaseController
|
||||
}
|
||||
|
||||
// currentAdminUserID returns the admin user id injected by CheckAdminJWT.
|
||||
// Returns 0 if the middleware is missing (treat as auth bug upstream).
|
||||
func currentAdminUserID(ctx echo.Context) uint64 {
|
||||
if v, ok := ctx.Get(middleware.AdminUserIDKey).(uint64); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// UpdateChainRequest is the payload for updating chain settings.
|
||||
type UpdateChainRequest struct {
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinConfirmations *int `json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec *int `json:"scan_interval_sec" example:"5"`
|
||||
DisplayName *string `json:"display_name" example:"Tron"`
|
||||
}
|
||||
|
||||
// ListChains returns the chains table for all seeded supported networks.
|
||||
// @Summary List chains
|
||||
// @Description Returns all supported blockchain networks
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Chain}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains [get]
|
||||
func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
||||
rows, err := data.ListChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpdateChain toggles enabled / tweaks scan params. Scanners re-read the
|
||||
// chains table on every poll tick, so changes take effect within one
|
||||
// cycle without a restart.
|
||||
// @Summary Update chain
|
||||
// @Description Toggle enabled / tweak scan params for a chain
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)"
|
||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains/{network} [patch]
|
||||
func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
||||
network := ctx.Param("network")
|
||||
req := new(UpdateChainRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinConfirmations != nil {
|
||||
fields["min_confirmations"] = *req.MinConfirmations
|
||||
}
|
||||
if req.ScanIntervalSec != nil {
|
||||
fields["scan_interval_sec"] = *req.ScanIntervalSec
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
fields["display_name"] = *req.DisplayName
|
||||
}
|
||||
if err := data.UpdateChainFields(network, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateChainTokenRequest is the payload for creating a chain token.
|
||||
type CreateChainTokenRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
|
||||
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// UpdateChainTokenRequest is the payload for updating a chain token.
|
||||
type UpdateChainTokenRequest struct {
|
||||
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals *int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount *float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatusRequest is the payload for toggling chain token status.
|
||||
type ChangeChainTokenStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListChainTokens returns rows, optionally filtered by network.
|
||||
// @Summary List chain tokens
|
||||
// @Description Returns chain tokens, optionally filtered by network
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [get]
|
||||
func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
|
||||
// @Summary Create chain token
|
||||
// @Description Create a new chain token entry
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateChainTokenRequest true "Chain token payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [post]
|
||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||
req := new(CreateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
decimals := req.Decimals
|
||||
if decimals == 0 {
|
||||
decimals = 6
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.ChainToken{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Symbol: strings.ToUpper(strings.TrimSpace(req.Symbol)),
|
||||
ContractAddress: strings.TrimSpace(req.ContractAddress),
|
||||
Decimals: decimals,
|
||||
Enabled: enabled,
|
||||
MinAmount: req.MinAmount,
|
||||
}
|
||||
if err := data.CreateChainToken(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateChainToken patches mutable columns.
|
||||
// @Summary Update chain token
|
||||
// @Description Patch chain token fields
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.UpdateChainTokenRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.ContractAddress != nil {
|
||||
fields["contract_address"] = *req.ContractAddress
|
||||
}
|
||||
if req.Decimals != nil {
|
||||
fields["decimals"] = *req.Decimals
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
fields["min_amount"] = *req.MinAmount
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatus toggles enable/disable.
|
||||
// @Summary Change chain token status
|
||||
// @Description Toggle enable/disable for a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.ChangeChainTokenStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(ChangeChainTokenStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteChainToken soft-deletes the row.
|
||||
// @Summary Delete chain token
|
||||
// @Description Soft-delete a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OverviewResponse is the dashboard top-card response.
|
||||
type OverviewResponse struct {
|
||||
TotalAsset float64 `json:"total_asset" example:"12345.6789"`
|
||||
Volume float64 `json:"volume" example:"100.5"`
|
||||
OrderCount int64 `json:"order_count" example:"42"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
SevenDayVolume float64 `json:"seven_day_volume" example:"700.25"`
|
||||
ActiveAddresses int64 `json:"active_addresses" example:"5"`
|
||||
OnlineChains int64 `json:"online_chains" example:"3"`
|
||||
}
|
||||
|
||||
// OrderStatsResponse is the order statistics response.
|
||||
type OrderStatsResponse struct {
|
||||
OrderCount int64 `json:"order_count" example:"100"`
|
||||
SuccessCount int64 `json:"success_count" example:"85"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
AvgPaymentSeconds float64 `json:"avg_payment_seconds" example:"120.5"`
|
||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||
}
|
||||
|
||||
// RpcStatsDashboardResponse is the RPC runtime stats payload.
|
||||
type RpcStatsDashboardResponse struct {
|
||||
GeneratedAt string `json:"generated_at" example:"2026-06-09T12:00:00+08:00"`
|
||||
Summary RpcStatsSummaryResponse `json:"summary"`
|
||||
Chains []RpcStatsChainResponse `json:"chains"`
|
||||
}
|
||||
|
||||
// RpcStatsSummaryResponse is the overall RPC runtime summary.
|
||||
type RpcStatsSummaryResponse struct {
|
||||
TotalChains int `json:"total_chains" example:"6"`
|
||||
EnabledChains int `json:"enabled_chains" example:"5"`
|
||||
ActiveChains int `json:"active_chains" example:"4"`
|
||||
OkChains int `json:"ok_chains" example:"3"`
|
||||
WarningChains int `json:"warning_chains" example:"1"`
|
||||
DownChains int `json:"down_chains" example:"0"`
|
||||
UnknownChains int `json:"unknown_chains" example:"1"`
|
||||
DisabledChains int `json:"disabled_chains" example:"1"`
|
||||
SuccessCount int64 `json:"success_count" example:"123"`
|
||||
FailureCount int64 `json:"failure_count" example:"2"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||
}
|
||||
|
||||
// RpcStatsChainResponse is one chain's in-process RPC runtime stats.
|
||||
type RpcStatsChainResponse struct {
|
||||
Network string `json:"network" example:"tron"`
|
||||
DisplayName string `json:"display_name" example:"TRON"`
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
HasData bool `json:"has_data" example:"true"`
|
||||
Status string `json:"status" example:"ok" enums:"ok,warning,down,unknown,disabled"`
|
||||
SuccessCount int64 `json:"success_count" example:"123"`
|
||||
FailureCount int64 `json:"failure_count" example:"2"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||
LatestBlockHeight int64 `json:"latest_block_height" example:"64532100"`
|
||||
LastSyncAt string `json:"last_sync_at" example:"2026-06-09T12:00:00+08:00"`
|
||||
}
|
||||
|
||||
type rpcStatsStreamEnvelope struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Message string `json:"message"`
|
||||
Data RpcStatsDashboardResponse `json:"data"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// RpcHealthCheckResponse is the response for a health check probe.
|
||||
type RpcHealthCheckResponse struct {
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `json:"status" example:"ok" enums:"unknown,ok,down"`
|
||||
LastLatencyMs int `json:"last_latency_ms" example:"120"`
|
||||
}
|
||||
|
||||
// ApiKeyStatsResponse is the response for API key usage stats.
|
||||
type ApiKeyStatsResponse struct {
|
||||
CallCount int64 `json:"call_count" example:"342"`
|
||||
LastUsedAt string `json:"last_used_at" example:"2026-04-14T08:30:00+00:00"`
|
||||
}
|
||||
|
||||
// RotateSecretResponse is returned when rotating an API key secret.
|
||||
type RotateSecretResponse struct {
|
||||
SecretKey string `json:"secret_key" example:"newSecret123abc456"`
|
||||
}
|
||||
|
||||
// BatchImportResult is one row in the batch import response.
|
||||
type BatchImportResult struct {
|
||||
Address string `json:"address" example:"TTestTronAddress001"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
ErrorCode int `json:"error_code,omitempty" example:"10001"`
|
||||
Error string `json:"error,omitempty" example:"wallet address already exists"`
|
||||
}
|
||||
|
||||
// SettingsUpsertResult is one row in the settings upsert response.
|
||||
type SettingsUpsertResult struct {
|
||||
Key string `json:"key" example:"rate.api_url"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
ErrorCode int `json:"error_code,omitempty" example:"10043"`
|
||||
Error string `json:"error,omitempty" example:"invalid setting item"`
|
||||
}
|
||||
|
||||
// Overview returns the top-card numbers for the dashboard landing page.
|
||||
// All values are computed with on-demand SQL — no cache — per the design.
|
||||
// The selected time range (range / start_at / end_at) is applied to
|
||||
// volume, order_count, success_rate and active_addresses.
|
||||
// seven_day_volume always covers the rolling past 7 days.
|
||||
// total_asset and online_chains are time-independent.
|
||||
// @Summary Dashboard overview
|
||||
// @Description Returns top-card numbers: total asset, volume, success rate, active addresses, etc. Supports ?range= for filtering.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OverviewResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/overview [get]
|
||||
func (c *BaseAdminController) Overview(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
|
||||
now := time.Now()
|
||||
sevenDaysAgo := startOfDay(now).AddDate(0, 0, -6)
|
||||
|
||||
totalActual, _ := data.SumPaidActualAmount()
|
||||
|
||||
orderCount, successCount, volume, _ := data.PaidStatsInRange(start, end)
|
||||
var successRate float64
|
||||
if orderCount > 0 {
|
||||
successRate = float64(successCount) / float64(orderCount)
|
||||
}
|
||||
|
||||
_, _, sevenDayActual, _ := data.PaidStatsInRange(sevenDaysAgo, now)
|
||||
|
||||
activeAddr, _ := data.ActiveAddressCountInRange(start, end)
|
||||
onlineChains, _ := data.CountEnabledChains()
|
||||
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"total_asset": totalActual,
|
||||
"volume": volume,
|
||||
"order_count": orderCount,
|
||||
"success_rate": successRate,
|
||||
"seven_day_volume": sevenDayActual,
|
||||
"active_addresses": activeAddr,
|
||||
"online_chains": onlineChains,
|
||||
})
|
||||
}
|
||||
|
||||
// isHourlyRange returns true when the time window spans ≤ 1 day,
|
||||
// so callers can choose hourly vs daily granularity.
|
||||
func isHourlyRange(start, end time.Time) bool {
|
||||
return end.Sub(start) <= 24*time.Hour
|
||||
}
|
||||
|
||||
// AssetTrend returns per-day actual_amount for paid orders. group_by=
|
||||
// "total" yields one series; "address" yields rows keyed by
|
||||
// receive_address for the stacked chart.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Asset trend
|
||||
// @Description Returns per-period actual_amount for paid orders. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Param group_by query string false "Group by: total or address" default(total)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/asset-trend [get]
|
||||
func (c *BaseAdminController) AssetTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
groupBy := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group_by")))
|
||||
hourly := isHourlyRange(start, end)
|
||||
if groupBy == "address" {
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyAssetByAddress(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyAssetByAddress(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// RevenueTrend mirrors the daily stats view used for revenue charts.
|
||||
// Same data shape as AssetTrend(group_by=total) but kept as its own
|
||||
// endpoint so the frontend can evolve each chart independently.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Revenue trend
|
||||
// @Description Returns per-period revenue stats for charts. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/revenue-trend [get]
|
||||
func (c *BaseAdminController) RevenueTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
var rows interface{}
|
||||
var err error
|
||||
if isHourlyRange(start, end) {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// OrderStats returns aggregate order figures for the selected window:
|
||||
// total count, success rate, average payment duration (seconds), and
|
||||
// expired-unpaid count.
|
||||
// @Summary Order statistics
|
||||
// @Description Returns aggregate order figures: count, success rate, avg payment time, expired count
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/order-stats [get]
|
||||
func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
total, success, _, err := data.PaidStatsInRange(start, end)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
avg, _ := data.AveragePaymentDurationSeconds(start, end)
|
||||
expired, _ := data.CountExpiredInRange(start, end)
|
||||
|
||||
var rate float64
|
||||
if total > 0 {
|
||||
rate = float64(success) / float64(total)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"order_count": total,
|
||||
"success_count": success,
|
||||
"success_rate": rate,
|
||||
"avg_payment_seconds": avg,
|
||||
"expired_unpaid_count": expired,
|
||||
})
|
||||
}
|
||||
|
||||
// RpcStats returns process-local RPC runtime counters grouped by chain.
|
||||
// Values reset on process restart and are not persisted to the database.
|
||||
// @Summary RPC runtime statistics
|
||||
// @Description Streams in-process RPC success/failure counts, success rate, latest block height and last sync time by chain over one authenticated SSE connection.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce text/event-stream
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RpcStatsDashboardResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/rpc-stats [get]
|
||||
func (c *BaseAdminController) RpcStats(ctx echo.Context) error {
|
||||
return c.streamRpcStats(ctx)
|
||||
}
|
||||
|
||||
func buildRpcStatsResponse() (RpcStatsDashboardResponse, error) {
|
||||
chains, err := data.ListChains()
|
||||
if err != nil {
|
||||
return RpcStatsDashboardResponse{}, err
|
||||
}
|
||||
stats := data.SnapshotRpcRuntimeStats()
|
||||
now := time.Now()
|
||||
resp := RpcStatsDashboardResponse{
|
||||
GeneratedAt: now.Format(time.RFC3339),
|
||||
Chains: make([]RpcStatsChainResponse, 0, len(chains)),
|
||||
}
|
||||
for _, chain := range chains {
|
||||
network := strings.ToLower(strings.TrimSpace(chain.Network))
|
||||
stat := stats[network]
|
||||
successRate := rpcStatsSuccessRate(stat.SuccessCount, stat.FailureCount)
|
||||
hasData := stat.SuccessCount+stat.FailureCount > 0
|
||||
status := rpcStatsStatus(chain.Enabled, stat.SuccessCount, stat.FailureCount)
|
||||
lastSyncAt := ""
|
||||
if !stat.LastSyncAt.IsZero() {
|
||||
lastSyncAt = stat.LastSyncAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
resp.Summary.TotalChains++
|
||||
if chain.Enabled {
|
||||
resp.Summary.EnabledChains++
|
||||
}
|
||||
if hasData {
|
||||
resp.Summary.ActiveChains++
|
||||
}
|
||||
switch status {
|
||||
case "ok":
|
||||
resp.Summary.OkChains++
|
||||
case "warning":
|
||||
resp.Summary.WarningChains++
|
||||
case "down":
|
||||
resp.Summary.DownChains++
|
||||
case "disabled":
|
||||
resp.Summary.DisabledChains++
|
||||
default:
|
||||
resp.Summary.UnknownChains++
|
||||
}
|
||||
resp.Summary.SuccessCount += stat.SuccessCount
|
||||
resp.Summary.FailureCount += stat.FailureCount
|
||||
|
||||
resp.Chains = append(resp.Chains, RpcStatsChainResponse{
|
||||
Network: chain.Network,
|
||||
DisplayName: rpcStatsDisplayName(chain.Network, chain.DisplayName),
|
||||
Enabled: chain.Enabled,
|
||||
HasData: hasData,
|
||||
Status: status,
|
||||
SuccessCount: stat.SuccessCount,
|
||||
FailureCount: stat.FailureCount,
|
||||
SuccessRate: successRate,
|
||||
LatestBlockHeight: stat.LatestBlockHeight,
|
||||
LastSyncAt: lastSyncAt,
|
||||
})
|
||||
}
|
||||
resp.Summary.SuccessRate = rpcStatsSuccessRate(resp.Summary.SuccessCount, resp.Summary.FailureCount)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *BaseAdminController) streamRpcStats(ctx echo.Context) error {
|
||||
initial, err := buildRpcStatsResponse()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
resp := ctx.Response()
|
||||
resp.Header().Set(echo.HeaderContentType, "text/event-stream; charset=utf-8")
|
||||
resp.Header().Set("Cache-Control", "no-cache")
|
||||
resp.Header().Set("Connection", "keep-alive")
|
||||
resp.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := resp.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
if _, err := fmt.Fprint(resp.Writer, "retry: 5000\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRpcStatsSSE(ctx, flusher, initial); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Request().Context().Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
payload, err := buildRpcStatsResponse()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRpcStatsSSE(ctx, flusher, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeRpcStatsSSE(ctx echo.Context, flusher http.Flusher, payload RpcStatsDashboardResponse) error {
|
||||
envelope := rpcStatsStreamEnvelope{
|
||||
StatusCode: http.StatusOK,
|
||||
Message: "success",
|
||||
Data: payload,
|
||||
RequestID: ctx.Request().Header.Get(echo.HeaderXRequestID),
|
||||
}
|
||||
b, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(ctx.Response().Writer, "event: rpc_stats\ndata: %s\n\n", b); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcStatsDisplayName(network string, displayName string) string {
|
||||
if name := strings.TrimSpace(displayName); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(network)
|
||||
}
|
||||
|
||||
func rpcStatsSuccessRate(successCount, failureCount int64) float64 {
|
||||
total := successCount + failureCount
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(successCount) / float64(total)
|
||||
}
|
||||
|
||||
func rpcStatsStatus(enabled bool, successCount, failureCount int64) string {
|
||||
if !enabled {
|
||||
return "disabled"
|
||||
}
|
||||
if successCount+failureCount == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
if successCount == 0 {
|
||||
return "down"
|
||||
}
|
||||
if failureCount > 0 {
|
||||
return "warning"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// RecentOrders returns the last N orders for the dashboard list card.
|
||||
// @Summary Recent orders
|
||||
// @Description Returns the last N orders for the dashboard
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of orders to return" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/recent-orders [get]
|
||||
func (c *BaseAdminController) RecentOrders(ctx echo.Context) error {
|
||||
limit := 20
|
||||
if s := ctx.QueryParam("limit"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := data.RecentOrders(limit)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// parseRange maps the ?range=... shorthand to a concrete [start, end].
|
||||
// Accepted: today / 7d / 30d / custom (with start_at/end_at Unix secs).
|
||||
// Defaults to today.
|
||||
func parseRange(ctx echo.Context) (time.Time, time.Time) {
|
||||
rng := strings.ToLower(strings.TrimSpace(ctx.QueryParam("range")))
|
||||
now := time.Now()
|
||||
todayStart := startOfDay(now)
|
||||
switch rng {
|
||||
case "7d":
|
||||
return todayStart.AddDate(0, 0, -6), now
|
||||
case "30d":
|
||||
return todayStart.AddDate(0, 0, -29), now
|
||||
case "custom":
|
||||
start := todayStart
|
||||
end := now
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
start = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
end = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
default:
|
||||
return todayStart, now
|
||||
}
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateNotificationChannelRequest is the payload for creating a notification channel.
|
||||
type CreateNotificationChannelRequest struct {
|
||||
Type string `json:"type" validate:"required|in:telegram,webhook,email" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"主通知群"`
|
||||
Config map[string]interface{} `json:"config" validate:"required"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelRequest is the payload for updating a notification channel.
|
||||
type UpdateNotificationChannelRequest struct {
|
||||
Name *string `json:"name" example:"更新后的名称"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ChangeChannelStatusRequest is the payload for toggling notification channel status.
|
||||
type ChangeChannelStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListNotificationChannels lists rows, optionally filtered by type.
|
||||
// @Summary List notification channels
|
||||
// @Description Returns notification channels, optionally filtered by type
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param type query string false "Channel type: telegram, webhook, email"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [get]
|
||||
func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
|
||||
channelType := strings.ToLower(strings.TrimSpace(ctx.QueryParam("type")))
|
||||
rows, err := data.ListNotificationChannels(channelType)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateNotificationChannel validates Config shape per type (only
|
||||
// telegram is strongly validated today) and inserts a row.
|
||||
// @Summary Create notification channel
|
||||
// @Description Create a new notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateNotificationChannelRequest true "Channel payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [post]
|
||||
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||
req := new(CreateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := validateChannelConfig(req.Type, req.Config); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.NotificationChannel{
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := data.CreateNotificationChannel(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(row.Type)) == mdb.NotificationTypeTelegram {
|
||||
telegram.ReloadBotAsync("admin create telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel patches name/config/events/enabled.
|
||||
// @Summary Update notification channel
|
||||
// @Description Patch notification channel fields
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.UpdateNotificationChannelRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(UpdateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.Config != nil {
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
fields["config"] = string(configJSON)
|
||||
}
|
||||
if req.Events != nil {
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
fields["events"] = string(eventsJSON)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin update telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeNotificationChannelStatus toggles enabled.
|
||||
// @Summary Change notification channel status
|
||||
// @Description Toggle enable/disable for a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.ChangeChannelStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(ChangeChannelStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin change telegram channel status")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel soft-deletes the row.
|
||||
// @Summary Delete notification channel
|
||||
// @Description Soft-delete a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
if err := data.DeleteNotificationChannelByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin delete telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// validateChannelConfig enforces minimum shape. Telegram needs bot_token
|
||||
// + chat_id; webhook/email kept loose until those senders exist.
|
||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||
if cfg == nil {
|
||||
return constant.NotificationConfigErr
|
||||
}
|
||||
if channelType == mdb.NotificationTypeTelegram {
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||
return constant.NotificationConfigErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
if raw == nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
toFlag := func(v interface{}) (bool, error) {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, nil
|
||||
case float64:
|
||||
return t != 0, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
switch s {
|
||||
case "", "0", "false", "off", "no":
|
||||
return false, nil
|
||||
case "1", "true", "on", "yes":
|
||||
return true, nil
|
||||
default:
|
||||
return false, constant.NotificationEventsErr
|
||||
}
|
||||
default:
|
||||
return false, constant.NotificationEventsErr
|
||||
}
|
||||
}
|
||||
|
||||
trimKey := func(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
out := make(map[string]bool)
|
||||
switch t := raw.(type) {
|
||||
case map[string]bool:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
case map[string]interface{}:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
flag, err := toFlag(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = flag
|
||||
}
|
||||
return out, nil
|
||||
case []interface{}:
|
||||
for _, v := range t {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, constant.NotificationEventsErr
|
||||
}
|
||||
s = trimKey(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
out[s] = true
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, constant.NotificationEventsErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OrderListResponse wraps the paginated order list.
|
||||
type OrderListResponse struct {
|
||||
List []mdb.Orders `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// OrderWithSub embeds a parent order together with its child orders.
|
||||
type OrderWithSub struct {
|
||||
mdb.Orders
|
||||
SubOrders []mdb.Orders `json:"sub_orders"`
|
||||
}
|
||||
|
||||
// OrderWithSubListResponse wraps the paginated list-with-sub result.
|
||||
type OrderWithSubListResponse struct {
|
||||
List []OrderWithSub `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// ListOrders handles the admin order list with filters + pagination.
|
||||
// @Summary List orders
|
||||
// @Description Returns paginated orders with optional filters
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders [get]
|
||||
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"list": rows,
|
||||
"total": total,
|
||||
"page": f.Page,
|
||||
"page_size": f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrder returns full order detail by trade_id.
|
||||
// @Summary Get order
|
||||
// @Description Returns full order detail by trade_id
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id} [get]
|
||||
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
return c.SucJson(ctx, order)
|
||||
}
|
||||
|
||||
// CloseOrder flips a waiting order to expired and releases its lock.
|
||||
// @Summary Close order
|
||||
// @Description Manually close a waiting order (mark as expired)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/close [post]
|
||||
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
|
||||
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
||||
}
|
||||
ok, err := data.CloseOrderManually(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, constant.OrderStatusConflict)
|
||||
}
|
||||
// Release only concrete on-chain locks. Placeholder orders and provider-only
|
||||
// orders do not reserve a local wallet amount.
|
||||
if order.Status == mdb.StatusWaitPay &&
|
||||
(strings.TrimSpace(order.PayProvider) == "" || order.PayProvider == mdb.PaymentProviderOnChain) &&
|
||||
strings.TrimSpace(order.Network) != "" &&
|
||||
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
||||
strings.TrimSpace(order.Token) != "" &&
|
||||
order.ActualAmount > 0 {
|
||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
|
||||
// same OrderProcessing path as on-chain confirmation so downstream
|
||||
// callback + notification fire normally.
|
||||
// @Summary Mark order paid
|
||||
// @Description Manually mark a waiting order as paid (operator补单)
|
||||
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body request.ManualPaymentRequest true "Block transaction ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
adminUserID := currentAdminUserID(ctx)
|
||||
req := new(request.ManualPaymentRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] mark-paid failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, resp.BlockTransactionId)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ResendCallback requeues the order callback by flipping
|
||||
// callback_confirm back to NO. The mq worker will grab it on the
|
||||
// next poll tick.
|
||||
// @Summary Resend callback
|
||||
// @Description Re-queue the order callback notification
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
adminUserID := currentAdminUserID(ctx)
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] resend-callback load failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
err = constant.OrderNotExists
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.Status != mdb.StatusPaySuccess {
|
||||
err = constant.OrderCallbackNotApplicable
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if strings.TrimSpace(order.NotifyUrl) == "" {
|
||||
err = constant.OrderNotifyURLEmptyErr
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
ok, err := data.ReopenOrderCallback(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[admin-order] resend-callback reopen failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
err = constant.OrderResendCallbackErr
|
||||
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
log.Sugar.Infof("[admin-order] resend-callback queued admin_user_id=%d trade_id=%s", adminUserID, tradeID)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ExportOrders streams matching orders as CSV. No pagination — the
|
||||
// caller drives what rows to include via filter params. Capped at
|
||||
// 10k rows to keep the response bounded.
|
||||
// @Summary Export orders as CSV
|
||||
// @Description Stream matching orders as a CSV file (max 10k rows)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce text/csv
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Success 200 {string} string "CSV file"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/export [get]
|
||||
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
f.Page = 1
|
||||
f.PageSize = 10000
|
||||
rows, _, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp := ctx.Response()
|
||||
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
|
||||
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
|
||||
w := csv.NewWriter(resp.Writer)
|
||||
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.TradeId,
|
||||
r.OrderId,
|
||||
fmt.Sprintf("%.4f", r.Amount),
|
||||
fmt.Sprintf("%.4f", r.ActualAmount),
|
||||
r.Currency,
|
||||
r.Token,
|
||||
r.Network,
|
||||
r.ReceiveAddress,
|
||||
strconv.Itoa(r.Status),
|
||||
r.BlockTransactionId,
|
||||
r.CreatedAt.ToDateTimeString(),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOrderFilter extracts filter + pagination values from the query
|
||||
// string. Time params are Unix seconds.
|
||||
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
|
||||
f := data.OrderListFilter{
|
||||
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
|
||||
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
|
||||
Address: strings.TrimSpace(ctx.QueryParam("address")),
|
||||
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
|
||||
}
|
||||
if s := ctx.QueryParam("status"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Status = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.StartAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.EndAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Page = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page_size"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.PageSize = n
|
||||
}
|
||||
}
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.PageSize < 1 {
|
||||
f.PageSize = 20
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ListOrdersWithSub returns the same paginated order list as ListOrders but
|
||||
// each row has a sub_orders field containing all child orders under it.
|
||||
// @Summary List orders with sub-orders
|
||||
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/list-with-sub [get]
|
||||
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
|
||||
f.ParentOnly = true
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Batch-fetch all sub-orders for the current page in one query.
|
||||
tradeIds := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tradeIds = append(tradeIds, r.TradeId)
|
||||
}
|
||||
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Group sub-orders by parent_trade_id.
|
||||
subMap := make(map[string][]mdb.Orders, len(subs))
|
||||
for _, s := range subs {
|
||||
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
|
||||
}
|
||||
|
||||
list := make([]OrderWithSub, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
children := subMap[r.TradeId]
|
||||
if children == nil {
|
||||
children = []mdb.Orders{}
|
||||
}
|
||||
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, OrderWithSubListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: f.Page,
|
||||
PageSize: f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderWithSub returns a single parent order together with all its child
|
||||
// orders (any status), regardless of whether the caller passes a parent or
|
||||
// child trade_id — if it is a child, the parent is resolved first.
|
||||
// @Summary Get order with sub-orders
|
||||
// @Description Returns an order and its child orders embedded in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
|
||||
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, constant.OrderNotExists)
|
||||
}
|
||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if subOrders == nil {
|
||||
subOrders = []mdb.Orders{}
|
||||
}
|
||||
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||
type CreateRpcNodeRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||
Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" example:"http"`
|
||||
Weight int `json:"weight" example:"1"`
|
||||
ApiKey string `json:"api_key" example:""`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||
Purpose string `json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||
}
|
||||
|
||||
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||
type UpdateRpcNodeRequest struct {
|
||||
Url *string `json:"url" example:"https://api.trongrid.io"`
|
||||
Weight *int `json:"weight" example:"1"`
|
||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
Purpose *string `json:"purpose" enums:"general,manual_verify,both" example:"manual_verify"`
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
// @Summary List RPC nodes
|
||||
// @Description Returns RPC nodes, optionally filtered by network
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [get]
|
||||
func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListRpcNodes(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row. Status starts as "unknown" until the
|
||||
// health-check task runs.
|
||||
// @Summary Create RPC node
|
||||
// @Description Create a new RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateRpcNodeRequest true "RPC node payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [post]
|
||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
req := new(CreateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
weight := req.Weight
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
purpose, err := rpcNodePurposeFromRequest(req.Purpose)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
nodeURL := strings.TrimSpace(req.Url)
|
||||
nodeType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := validateRpcNodeURLForType(nodeURL, nodeType); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row := &mdb.RpcNode{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Url: nodeURL,
|
||||
Type: nodeType,
|
||||
Weight: weight,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: enabled,
|
||||
Purpose: purpose,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
LastLatencyMs: -1,
|
||||
}
|
||||
if err := data.CreateRpcNode(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateRpcNode patches url/weight/api_key/enabled/purpose.
|
||||
// @Summary Update RPC node
|
||||
// @Description Patch RPC node fields
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Param request body admin.UpdateRpcNodeRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(UpdateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Url != nil {
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||
}
|
||||
nodeURL := strings.TrimSpace(*req.Url)
|
||||
if err := validateRpcNodeURLForType(nodeURL, row.Type); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields["url"] = nodeURL
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
}
|
||||
if req.ApiKey != nil {
|
||||
fields["api_key"] = *req.ApiKey
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.Purpose != nil {
|
||||
purpose, err := rpcNodePurposeFromRequest(*req.Purpose)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields["purpose"] = purpose
|
||||
}
|
||||
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteRpcNode soft-deletes the row.
|
||||
// @Summary Delete RPC node
|
||||
// @Description Soft-delete an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||
// For HTTP/WS/lite endpoints this is a TCP-level check against the configured
|
||||
// URL host. TON lite rows usually point to a global.config.json HTTPS URL.
|
||||
// @Summary Health check RPC node
|
||||
// @Description Perform an on-demand health probe on an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RpcHealthCheckResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id}/health-check [post]
|
||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||
}
|
||||
status, latency := task.ProbeNode(row.Url)
|
||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latency,
|
||||
})
|
||||
}
|
||||
|
||||
func rpcNodePurposeFromRequest(raw string) (string, error) {
|
||||
purpose := strings.ToLower(strings.TrimSpace(raw))
|
||||
if purpose == "" {
|
||||
return mdb.RpcNodePurposeGeneral, nil
|
||||
}
|
||||
switch purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeManualVerify, mdb.RpcNodePurposeBoth:
|
||||
return purpose, nil
|
||||
default:
|
||||
return "", constant.RpcNodePurposeErr
|
||||
}
|
||||
}
|
||||
|
||||
func validateRpcNodeURLForType(rawURL string, nodeType string) error {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
nodeType = strings.ToLower(strings.TrimSpace(nodeType))
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return constant.RpcNodeURLErr
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch nodeType {
|
||||
case mdb.RpcNodeTypeHttp:
|
||||
if scheme == "http" || scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
return constant.RpcNodeHTTPURLErr
|
||||
case mdb.RpcNodeTypeWs:
|
||||
if scheme == "ws" || scheme == "wss" {
|
||||
return nil
|
||||
}
|
||||
return constant.RpcNodeWebSocketURLErr
|
||||
case mdb.RpcNodeTypeLite:
|
||||
if scheme == "http" || scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
return constant.RpcNodeHTTPURLErr
|
||||
default:
|
||||
return constant.RpcNodeTypeErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/security"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// SettingUpsertItem is a single setting entry for batch upsert.
|
||||
// Supported groups and keys:
|
||||
//
|
||||
// - group=rate:
|
||||
// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase
|
||||
// rate.api_url (string) — external rate API URL used when no positive forced rate exists
|
||||
// rate.adjust_percent (float) — rate adjustment percentage
|
||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||
//
|
||||
// - group=epay:
|
||||
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
|
||||
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
|
||||
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
|
||||
//
|
||||
// - group=okpay:
|
||||
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
||||
// okpay.shop_id (string) — OkPay merchant/shop identifier
|
||||
// okpay.shop_token (string) — OkPay signing token
|
||||
// okpay.api_url (string) — OkPay API base URL
|
||||
// okpay.callback_url (string) — server callback URL used for OkPay notify
|
||||
// okpay.return_url (string) — optional default browser return URL after payment
|
||||
// okpay.timeout_seconds (int) — outbound OkPay API timeout in seconds
|
||||
// okpay.allow_tokens (string) — comma-separated allowed tokens, e.g. "USDT,TRX"
|
||||
//
|
||||
// - group=brand:
|
||||
// brand.checkout_name (string) — cashier display name (preferred)
|
||||
// brand.logo_url (string) — logo image URL
|
||||
// brand.site_title (string) — payment page title / website title (preferred)
|
||||
// brand.success_copy (string) — text shown on payment success (preferred)
|
||||
// brand.support_url (string) — support / help URL
|
||||
// brand.background_color (string) — checkout background color
|
||||
// brand.background_image_url (string) — checkout background image URL
|
||||
// brand.site_name (string) — legacy cashier/site display name
|
||||
// brand.page_title (string) — legacy payment page title
|
||||
// brand.pay_success_text (string) — legacy success text
|
||||
//
|
||||
// - group=system:
|
||||
// system.order_expiration_time (int) — order expiry in minutes
|
||||
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
||||
// system.log_level (string) — runtime log level: debug, info, warn, error (default error)
|
||||
type SettingUpsertItem struct {
|
||||
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value interface{} `json:"value"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
}
|
||||
|
||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||
type SettingsUpsertRequest struct {
|
||||
Items []SettingUpsertItem `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
// ListSettings returns all rows, optionally filtered by group.
|
||||
// @Summary List settings
|
||||
// @Description Returns all settings, optionally filtered by group.
|
||||
// @Description Available groups: brand, rate, system, epay, okpay.
|
||||
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param group query string false "Group filter (brand|rate|system|epay|okpay)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [get]
|
||||
func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
group := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group")))
|
||||
rows, err := data.ListSettingsByGroup(group)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpsertSettings batch-inserts / updates rows. Each item is treated
|
||||
// independently so a malformed row in the middle doesn't drop earlier
|
||||
// ones. Errors are returned per-key so the UI can surface them.
|
||||
// @Summary Upsert settings
|
||||
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
||||
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders).
|
||||
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
|
||||
// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
||||
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2), system.log_level (debug|info|warn|error, default error).
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.SettingsUpsertRequest true "Settings payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.SettingsUpsertResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [put]
|
||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||
req := new(SettingsUpsertRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Key string `json:"key"`
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Items))
|
||||
errorResult := func(key string, err error) result {
|
||||
code, msg := constant.ResolveErrno(err)
|
||||
return result{Key: key, OK: false, ErrorCode: code, Error: msg}
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
out = append(out, errorResult(item.Key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
value, err := normalizeSettingValue(item.Value)
|
||||
if err != nil {
|
||||
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
value, err = normalizeAndValidateSettingItem(item.Group, key, value)
|
||||
if err != nil {
|
||||
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||
continue
|
||||
}
|
||||
if key == mdb.SettingKeyAmountPrecision {
|
||||
item.Group = mdb.SettingGroupSystem
|
||||
item.Type = mdb.SettingTypeInt
|
||||
}
|
||||
if key == mdb.SettingKeyRateForcedRateList {
|
||||
item.Group = mdb.SettingGroupRate
|
||||
item.Type = mdb.SettingTypeJSON
|
||||
}
|
||||
if key == mdb.SettingKeySystemLogLevel {
|
||||
item.Group = mdb.SettingGroupSystem
|
||||
item.Type = mdb.SettingTypeString
|
||||
}
|
||||
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
||||
out = append(out, errorResult(key, err))
|
||||
continue
|
||||
}
|
||||
if key == mdb.SettingKeySystemLogLevel {
|
||||
if err := appLog.SetLevel(value); err != nil {
|
||||
out = append(out, errorResult(key, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, result{Key: key, OK: true})
|
||||
}
|
||||
|
||||
// When telegram credentials are updated via settings, reload the
|
||||
// command bot so operators don't need to restart the process, and
|
||||
// sync the notification_channels row so the notify dispatcher picks
|
||||
// up the new values immediately.
|
||||
telegramKeys := map[string]bool{
|
||||
"system.telegram_bot_token": true,
|
||||
"system.telegram_chat_id": true,
|
||||
"system.telegram_payment_notice_enabled": true,
|
||||
"system.telegram_abnormal_notice_enabled": true,
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
if telegramKeys[strings.TrimSpace(item.Key)] {
|
||||
telegram.ReloadBotAsync("settings upsert")
|
||||
go dao.SyncTelegramChannelFromSettings()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
func normalizeSettingValue(value interface{}) (string, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case string:
|
||||
return v, nil
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("value must be JSON serializable")
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
|
||||
switch key {
|
||||
case mdb.SettingKeyAmountPrecision:
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||
}
|
||||
precision, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return value, fmt.Errorf("%s must be an integer", key)
|
||||
}
|
||||
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
|
||||
return value, fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
|
||||
}
|
||||
case mdb.SettingKeyRateForcedRateList:
|
||||
normalized, err := normalizeForcedRateListSetting(group, key, value)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
return normalized, nil
|
||||
case mdb.SettingKeyRateApiUrl:
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||
}
|
||||
if err := security.ValidatePublicHTTPURL(value); err != nil {
|
||||
return value, fmt.Errorf("%s invalid: %w", key, err)
|
||||
}
|
||||
case mdb.SettingKeySystemLogLevel:
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||
}
|
||||
normalized, err := appLog.NormalizeLevel(value)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeForcedRateListSetting(group, key, value string) (string, error) {
|
||||
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var rates map[string]map[string]float64
|
||||
if err := json.Unmarshal([]byte(value), &rates); err != nil {
|
||||
return value, fmt.Errorf("%s must be valid JSON object", key)
|
||||
}
|
||||
|
||||
normalized := make(map[string]map[string]float64, len(rates))
|
||||
for base, coins := range rates {
|
||||
normalizedBase := strings.ToLower(strings.TrimSpace(base))
|
||||
if normalizedBase == "" {
|
||||
return value, fmt.Errorf("%s base currency must not be empty", key)
|
||||
}
|
||||
if coins == nil {
|
||||
return value, fmt.Errorf("%s.%s must be an object", key, base)
|
||||
}
|
||||
normalizedCoins, ok := normalized[normalizedBase]
|
||||
if !ok {
|
||||
normalizedCoins = make(map[string]float64, len(coins))
|
||||
normalized[normalizedBase] = normalizedCoins
|
||||
}
|
||||
for coin, rate := range coins {
|
||||
normalizedCoin := strings.ToLower(strings.TrimSpace(coin))
|
||||
if normalizedCoin == "" {
|
||||
return value, fmt.Errorf("%s coin must not be empty", key)
|
||||
}
|
||||
if rate < 0 {
|
||||
return value, fmt.Errorf("%s.%s.%s must be >= 0", key, base, coin)
|
||||
}
|
||||
if _, exists := normalizedCoins[normalizedCoin]; exists {
|
||||
return value, fmt.Errorf("%s.%s.%s is duplicated after normalization", key, normalizedBase, normalizedCoin)
|
||||
}
|
||||
normalizedCoins[normalizedCoin] = rate
|
||||
}
|
||||
}
|
||||
|
||||
normalizedValue, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return value, fmt.Errorf("%s must be JSON serializable", key)
|
||||
}
|
||||
return string(normalizedValue), nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes one row. The next read of that key will fall
|
||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||
// @Summary Delete setting
|
||||
// @Description Remove a setting by key (falls back to default). Deleting system.log_level immediately resets runtime logging to error.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param key path string true "Setting key"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings/{key} [delete]
|
||||
func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
||||
key := strings.TrimSpace(ctx.Param("key"))
|
||||
if key == "" {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := data.DeleteSetting(key); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if key == mdb.SettingKeySystemLogLevel {
|
||||
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AdminAddWalletRequest is the payload for adding a wallet via admin.
|
||||
type AdminAddWalletRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
|
||||
Remark string `json:"remark" example:"主钱包"`
|
||||
}
|
||||
|
||||
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
|
||||
type AdminUpdateWalletRequest struct {
|
||||
Remark *string `json:"remark" example:"已更新的备注"`
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
|
||||
type AdminChangeWalletStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// AdminBatchImportRequest is the payload for batch importing wallets.
|
||||
type AdminBatchImportRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
// Addresses listed as observation-only wallets. Private-key import
|
||||
// and API-managed modes are out of scope for v1.
|
||||
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
|
||||
}
|
||||
|
||||
// WalletListItem extends WalletAddress with order count.
|
||||
type WalletListItem struct {
|
||||
mdb.WalletAddress
|
||||
OrderCount int64 `json:"order_count" example:"5"`
|
||||
}
|
||||
|
||||
// AdminListWallets extends the read model with per-wallet order counts
|
||||
// so the UI can render the "订单数" column without an N+1 follow-up.
|
||||
// @Summary List wallets (admin)
|
||||
// @Description Returns wallets with per-wallet order counts
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
|
||||
// @Param keyword query string false "Search by address or remark"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [get]
|
||||
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
status := strings.TrimSpace(ctx.QueryParam("status"))
|
||||
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
|
||||
|
||||
tx := dao.Mdb.Model(&mdb.WalletAddress{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", network)
|
||||
}
|
||||
if status != "" {
|
||||
if s, err := strconv.Atoi(status); err == nil {
|
||||
tx = tx.Where("status = ?", s)
|
||||
}
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := "%" + keyword + "%"
|
||||
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
|
||||
}
|
||||
var rows []mdb.WalletAddress
|
||||
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
counts, err := data.CountOrdersByAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
out := make([]WalletListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
// AdminAddWallet wraps the existing create with admin-exposed Remark +
|
||||
// Source=manual so audit fields are populated.
|
||||
// @Summary Add wallet (admin)
|
||||
// @Description Create a wallet with admin-specific fields (remark, source=manual)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [post]
|
||||
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||
req := new(AdminAddWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
// Patch remark/source separately to avoid changing the existing
|
||||
// package-level AddWalletAddressWithNetwork signature (used by
|
||||
// Telegram bot + legacy endpoint).
|
||||
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
|
||||
if req.Remark != "" {
|
||||
fields["remark"] = req.Remark
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
|
||||
row.Remark = req.Remark
|
||||
row.Source = mdb.WalletSourceManual
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// AdminGetWallet returns detail including order count for one wallet.
|
||||
// @Summary Get wallet (admin)
|
||||
// @Description Returns wallet detail with order count
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [get]
|
||||
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
wallet, err := data.GetWalletAddressById(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if wallet.ID == 0 {
|
||||
return c.FailJson(ctx, constant.WalletNotFoundErr)
|
||||
}
|
||||
counts, _ := data.CountOrdersByAddress()
|
||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||
}
|
||||
|
||||
// AdminUpdateWallet only patches remark (status has its own endpoint).
|
||||
// @Summary Update wallet (admin)
|
||||
// @Description Patch wallet remark
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [patch]
|
||||
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(AdminUpdateWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Remark != nil {
|
||||
fields["remark"] = *req.Remark
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatus reuses existing helper.
|
||||
// @Summary Change wallet status (admin)
|
||||
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id}/status [post]
|
||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(AdminChangeWalletStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminDeleteWallet soft-deletes the wallet.
|
||||
// @Summary Delete wallet (admin)
|
||||
// @Description Soft-delete a wallet
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [delete]
|
||||
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminBatchImportWallets accepts a list of addresses and creates
|
||||
// them all as observation wallets. Per-row failures are collected and
|
||||
// returned so the UI can surface which addresses already existed.
|
||||
// @Summary Batch import wallets
|
||||
// @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/batch-import [post]
|
||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||
req := new(AdminBatchImportRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Address string `json:"address"`
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Addresses))
|
||||
for _, addr := range req.Addresses {
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||
if err != nil {
|
||||
code, msg := constant.ResolveErrno(err)
|
||||
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
|
||||
continue
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||
Update("source", mdb.WalletSourceImport).Error
|
||||
out = append(out, result{Address: addr, OK: true})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/assimon/luuu/util/http"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/gookit/validate"
|
||||
"github.com/gookit/validate/locales/zhcn"
|
||||
"github.com/gookit/validate/locales/zhtw"
|
||||
@@ -46,6 +46,6 @@ func (c *BaseController) ValidateStruct(ctx echo.Context, i interface{}, scene .
|
||||
if v.Validate() {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New(v.Errors.One())
|
||||
return constant.ParamsMarshalErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package comm
|
||||
|
||||
import "github.com/assimon/luuu/controller"
|
||||
import "github.com/GMWalletApp/epusdt/controller"
|
||||
|
||||
var Ctrl = &BaseCommController{}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OkPayNotify receives the server-to-server OkPay payment callback.
|
||||
// The handler accepts JSON or form-urlencoded payloads and returns plain-text
|
||||
// acknowledgements expected by the upstream provider.
|
||||
// @Summary OkPay notify callback
|
||||
// @Description Receives OkPay deposit callbacks for hosted checkout child orders.
|
||||
// @Description The callback is verified against the configured OkPay shop token and, on success, marks the matching child order as paid.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "success"
|
||||
// @Failure 400 {string} string "fail"
|
||||
// @Router /payments/okpay/v1/notify [post]
|
||||
func (c *BaseCommController) OkPayNotify(ctx echo.Context) error {
|
||||
req := ctx.Request()
|
||||
rawBody, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[okpay] read notify body failed err=%v", err)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||
|
||||
form := make(map[string]string)
|
||||
copyForm := func(values url.Values) {
|
||||
for key, items := range values {
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
form[key] = items[0]
|
||||
}
|
||||
}
|
||||
|
||||
copyForm(req.URL.Query())
|
||||
|
||||
contentType := strings.ToLower(req.Header.Get(echo.HeaderContentType))
|
||||
|
||||
if strings.Contains(contentType, echo.MIMEApplicationJSON) && len(rawBody) > 0 {
|
||||
if jsonForm, jsonErr := flattenOkPayJSONBody(rawBody); jsonErr == nil {
|
||||
for key, value := range jsonForm {
|
||||
form[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match the PHP example's $_POST semantics: let Echo/net/http parse both
|
||||
// application/x-www-form-urlencoded and multipart/form-data.
|
||||
if len(form) == 0 {
|
||||
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||
if parsedForm, formErr := ctx.FormParams(); formErr == nil {
|
||||
copyForm(parsedForm)
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback for raw query-string style bodies.
|
||||
if len(form) == 0 && len(rawBody) > 0 && looksLikeQueryPayload(string(rawBody)) {
|
||||
if parsed, parseErr := url.ParseQuery(string(rawBody)); parseErr == nil && len(parsed) > 0 {
|
||||
copyForm(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
rawFormData := string(rawBody)
|
||||
if rawFormData == "" {
|
||||
rawFormData = req.URL.RawQuery
|
||||
}
|
||||
|
||||
if len(form) == 0 {
|
||||
log.Sugar.Warnf("[okpay] empty notify payload method=%s content_type=%s remote=%s raw_query=%q raw_body=%q", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), req.URL.RawQuery, rawFormData)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
|
||||
if err = service.HandleOkPayNotify(form, rawFormData); err != nil {
|
||||
log.Sugar.Warnf("[okpay] notify handle failed method=%s content_type=%s remote=%s form=%v raw_query=%q raw_body=%q err=%v", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), form, req.URL.RawQuery, rawFormData, err)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
return ctx.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
func flattenOkPayJSONBody(rawBody []byte) (map[string]string, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.UseNumber()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flat := make(map[string]string)
|
||||
for key, value := range payload {
|
||||
if key == "data" {
|
||||
dataMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for dataKey, dataValue := range dataMap {
|
||||
flat["data["+dataKey+"]"] = stringifyOkPayJSONValue(dataValue)
|
||||
}
|
||||
continue
|
||||
}
|
||||
flat[key] = stringifyOkPayJSONValue(value)
|
||||
}
|
||||
return flat, nil
|
||||
}
|
||||
|
||||
func stringifyOkPayJSONValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case float64:
|
||||
if v == math.Trunc(v) {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeQueryPayload(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(trimmed, "=")
|
||||
}
|
||||
@@ -2,16 +2,49 @@ package comm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
|
||||
// Returns nil when the middleware didn't run (should not happen on authed routes).
|
||||
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
||||
if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTransaction 创建交易
|
||||
// @Summary Create transaction
|
||||
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||
// @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid.
|
||||
// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||
// @Param order_id formData string false "Merchant order ID"
|
||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||
// @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
|
||||
// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
|
||||
// @Param amount formData number false "Amount"
|
||||
// @Param notify_url formData string false "Callback URL"
|
||||
// @Param signature formData string false "MD5 signature"
|
||||
// @Param redirect_url formData string false "Redirect URL"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
@@ -20,14 +53,25 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.CreateTransaction(req)
|
||||
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// SwitchNetwork 切换支付网络,创建或返回子订单
|
||||
// SwitchNetwork 切换支付网络,补全占位父单或创建/返回子订单
|
||||
// @Summary Switch payment network
|
||||
// @Description Switch to a different payment target. A status=4 placeholder is completed in place and returns the same parent trade_id with is_selected=false; an already concrete status=1 parent creates or returns the only sub-order when switching to a different target.
|
||||
// @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
|
||||
// @Description For status=4 placeholders from GMPay or EPay submit.php, both on-chain targets and okpay complete the parent in place without creating a child order. Sub-orders cannot be switched again.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10011 sub-order limit, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
||||
// @Router /pay/switch-network [post]
|
||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
req := new(request.SwitchNetworkRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
@@ -43,36 +87,63 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return c.FailJson(ctx, constant.SystemErr)
|
||||
}
|
||||
|
||||
fmt.Printf("switch network response: \n%s", string(jsonBytes))
|
||||
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
||||
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// CreateTransactionAndRedirect creates a transaction and redirects to
|
||||
// the checkout counter. The route accepts BOTH GET (query string) and
|
||||
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
||||
// the canonical form — the GET variant is identical save the transport.
|
||||
// @Summary Create transaction and redirect (EPAY compat)
|
||||
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
|
||||
// @Tags Payment
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param pid query integer false "API key PID (GET query)"
|
||||
// @Param money query number false "Amount (fiat, GET query)"
|
||||
// @Param out_trade_no query string false "Merchant order ID (GET query)"
|
||||
// @Param notify_url query string false "Callback URL (GET query)"
|
||||
// @Param return_url query string false "Redirect URL after payment (GET query)"
|
||||
// @Param name query string false "Order name (GET query)"
|
||||
// @Param type query string false "Payment type (e.g. alipay, GET query)"
|
||||
// @Param sign query string false "MD5 signature (GET query)"
|
||||
// @Param sign_type query string false "Signature type (MD5, GET query)"
|
||||
// @Param pid formData integer true "API key PID"
|
||||
// @Param money formData number true "Amount (fiat)"
|
||||
// @Param out_trade_no formData string true "Merchant order ID"
|
||||
// @Param notify_url formData string true "Callback URL"
|
||||
// @Param return_url formData string false "Redirect URL after payment"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param type formData string false "Payment type (e.g. alipay)"
|
||||
// @Param sign formData string true "MD5 signature"
|
||||
// @Param sign_type formData string false "Signature type (MD5)"
|
||||
// @Success 302 "Redirect to checkout counter"
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
log.Println("bind request error:", err)
|
||||
log.Sugar.Errorf("bind request error: %v", err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
log.Println("validate request error:", err)
|
||||
log.Sugar.Errorf("validate request error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.CreateTransaction(req)
|
||||
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||
if err != nil {
|
||||
log.Println("create transaction error:", err)
|
||||
log.Sugar.Errorf("create transaction error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
fmt.Printf("create transaction response: %+v\n", resp)
|
||||
log.Sugar.Debugf("create transaction response: %+v", resp)
|
||||
|
||||
tradeID := resp.TradeId
|
||||
|
||||
ctx.Redirect(302, "/pay/checkout-counter/"+tradeID)
|
||||
|
||||
return nil
|
||||
|
||||
return ctx.Redirect(http.StatusFound, "/pay/checkout-counter/"+tradeID)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,44 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckoutCounter 收银台
|
||||
// @Summary Checkout counter page
|
||||
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection).
|
||||
// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||
if err != nil {
|
||||
if err == service.ErrOrder {
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
}
|
||||
emptyResp := response.CheckoutCounterResponse{}
|
||||
return tmpl.Execute(ctx.Response(), emptyResp)
|
||||
}
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
}
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
jsonByte, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
}
|
||||
fmt.Printf("%v\n", string(jsonByte))
|
||||
|
||||
return tmpl.Execute(ctx.Response(), resp)
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// CheckStatus 支付状态检测
|
||||
// @Summary Check payment status
|
||||
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||
// @Router /pay/check-status/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
order, err := service.GetOrderInfoByTradeId(tradeId)
|
||||
@@ -55,3 +51,33 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// SubmitTxHash 手动提交交易 hash 补单
|
||||
// @Summary Submit transaction hash
|
||||
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
|
||||
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body request.ManualPaymentRequest true "Transaction hash payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.ManualPaymentResponse}
|
||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10009 invalid params, 10013 not waiting payment, 10038 verification failed, 10039 unsupported provider, 10007 hash already processed"
|
||||
// @Router /pay/submit-tx-hash/{trade_id} [post]
|
||||
func (c *BaseCommController) SubmitTxHash(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
req := new(request.ManualPaymentRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
resp, err := service.SubmitCashierManualPayment(tradeId, req.BlockTransactionId)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
@@ -3,145 +3,145 @@ package comm
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type addSupportedAssetRequest struct {
|
||||
Network string `json:"network" validate:"required"`
|
||||
Token string `json:"token" validate:"required"`
|
||||
Status int64 `json:"status" validate:"required|in:1,2"`
|
||||
}
|
||||
|
||||
type updateSupportedAssetRequest struct {
|
||||
Network string `json:"network" validate:"required"`
|
||||
Token string `json:"token" validate:"required"`
|
||||
Status int64 `json:"status" validate:"required|in:1,2"`
|
||||
}
|
||||
|
||||
// GetSupportedAssets 对外公开可用链与 token 列表(无需鉴权,仅返回已启用项)。
|
||||
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
||||
list, err := data.ListEnabledSupportedAssets()
|
||||
func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
||||
chains, err := data.ListEnabledChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
wallets, err := data.GetAvailableWalletAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
networkSet := make(map[string]struct{})
|
||||
networkHasWallet := make(map[string]struct{})
|
||||
for _, w := range wallets {
|
||||
networkSet[w.Network] = struct{}{}
|
||||
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
||||
}
|
||||
|
||||
grouped := make(map[string][]string)
|
||||
for _, item := range list {
|
||||
if _, ok := networkSet[item.Network]; !ok {
|
||||
supports := make([]response.NetworkTokenSupport, 0, len(chains))
|
||||
for _, ch := range chains {
|
||||
network := strings.ToLower(ch.Network)
|
||||
if _, ok := networkHasWallet[network]; !ok {
|
||||
continue
|
||||
}
|
||||
grouped[item.Network] = append(grouped[item.Network], item.Token)
|
||||
}
|
||||
|
||||
networks := make([]string, 0, len(grouped))
|
||||
for network := range grouped {
|
||||
networks = append(networks, network)
|
||||
}
|
||||
sort.Strings(networks)
|
||||
|
||||
supports := make([]response.NetworkTokenSupport, 0, len(networks))
|
||||
for _, network := range networks {
|
||||
tokens := grouped[network]
|
||||
sort.Strings(tokens)
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
symbols := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
|
||||
if sym == "" {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, sym)
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(symbols)
|
||||
displayName := strings.TrimSpace(ch.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = network
|
||||
}
|
||||
supports = append(supports, response.NetworkTokenSupport{
|
||||
Network: network,
|
||||
Tokens: tokens,
|
||||
Network: network,
|
||||
DisplayName: displayName,
|
||||
Tokens: symbols,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
||||
return supports, nil
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
|
||||
// GetPublicConfig returns payment/site config consumed by cashier/frontends.
|
||||
// The public route returns brand/site display config plus non-sensitive
|
||||
// epay/okpay knobs, while the authenticated admin route also includes OkPay
|
||||
// credentials and internal URLs.
|
||||
// @Summary Get public payment config
|
||||
// @Description Returns supported_assets plus site, epay and okpay config used by cashier/frontends.
|
||||
// @Description The authenticated admin variant (/admin/api/v1/config) also includes OkPay shop credentials and internal callback settings.
|
||||
// @Tags Payment Config
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=response.PublicConfigResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/config [get]
|
||||
// @Router /admin/api/v1/config [get]
|
||||
func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
||||
supports, err := buildSupportedAssets()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
okpay := response.OkPayPublicConfig{
|
||||
Enabled: data.GetOkPayEnabled(),
|
||||
AllowTokens: data.GetOkPayAllowTokens(),
|
||||
}
|
||||
if ctx.Get(middleware.AdminUserIDKey) != nil {
|
||||
okpay.ShopID = data.GetOkPayShopID()
|
||||
okpay.ShopToken = data.GetOkPayShopToken()
|
||||
okpay.APIURL = data.GetOkPayAPIURL()
|
||||
okpay.CallbackURL = data.GetOkPayCallbackURL()
|
||||
okpay.ReturnURL = data.GetOkPayReturnURL()
|
||||
okpay.TimeoutSeconds = data.GetOkPayTimeoutSeconds()
|
||||
}
|
||||
return c.SucJson(ctx, response.PublicConfigResponse{
|
||||
SupportedAssets: supports,
|
||||
Site: response.SitePublicConfig{
|
||||
CashierName: data.GetBrandCashierName(),
|
||||
LogoURL: data.GetBrandLogoURL(),
|
||||
WebsiteTitle: data.GetBrandWebsiteTitle(),
|
||||
SupportLink: data.GetBrandSupportURL(),
|
||||
BackgroundColor: data.GetBrandBackgroundColor(),
|
||||
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
||||
},
|
||||
Epay: response.EpayPublicConfig{
|
||||
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
|
||||
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
|
||||
},
|
||||
OkPay: okpay,
|
||||
Version: config.GetAppVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords is currently not wired to a public route.
|
||||
// Kept as an internal helper candidate for future admin/debug use.
|
||||
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||
network := ctx.QueryParam("network")
|
||||
list, err := data.ListSupportedAssets(network)
|
||||
list, err := data.ListEnabledChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, list)
|
||||
}
|
||||
|
||||
// GetSupportedAsset 查询单条支持项(无需鉴权)。
|
||||
// GetSupportedAsset is currently not wired to a public route.
|
||||
// Kept as an internal helper candidate for future admin/debug use.
|
||||
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
asset, err := data.GetSupportedAssetByID(id)
|
||||
token, err := data.GetChainTokenByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if asset.ID <= 0 {
|
||||
if token.ID <= 0 || !token.Enabled {
|
||||
return c.FailJson(ctx, constant.SupportedAssetNotFound)
|
||||
}
|
||||
return c.SucJson(ctx, asset)
|
||||
}
|
||||
|
||||
// AddSupportedAsset 新增支持项(鉴权)。
|
||||
func (c *BaseCommController) AddSupportedAsset(ctx echo.Context) error {
|
||||
req := new(addSupportedAssetRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
asset, err := data.AddSupportedAsset(req.Network, req.Token, req.Status)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, asset)
|
||||
}
|
||||
|
||||
// UpdateSupportedAsset 修改支持项(鉴权)。
|
||||
func (c *BaseCommController) UpdateSupportedAsset(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(updateSupportedAssetRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
asset, err := data.UpdateSupportedAsset(id, req.Network, req.Token, req.Status)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, asset)
|
||||
}
|
||||
|
||||
// DeleteSupportedAsset 删除支持项(鉴权)。
|
||||
func (c *BaseCommController) DeleteSupportedAsset(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
asset, err := data.GetSupportedAssetByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if asset.ID <= 0 {
|
||||
return c.FailJson(ctx, constant.SupportedAssetNotFound)
|
||||
}
|
||||
if err := data.DeleteSupportedAssetByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
return c.SucJson(ctx, token)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type addWalletRequest struct {
|
||||
Network string `json:"network" validate:"required"`
|
||||
Address string `json:"address" validate:"required"`
|
||||
}
|
||||
|
||||
type changeStatusRequest struct {
|
||||
Status int `json:"status" validate:"required|in:1,2"`
|
||||
}
|
||||
|
||||
// AddWallet 添加钱包地址
|
||||
func (c *BaseCommController) AddWallet(ctx echo.Context) error {
|
||||
req := new(addWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
wallet, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, wallet)
|
||||
}
|
||||
|
||||
// ListWallets 获取钱包列表
|
||||
func (c *BaseCommController) ListWallets(ctx echo.Context) error {
|
||||
network := ctx.QueryParam("network")
|
||||
var wallets []mdb.WalletAddress
|
||||
var err error
|
||||
if network != "" {
|
||||
wallets, err = data.GetAllWalletAddressByNetwork(network)
|
||||
} else {
|
||||
wallets, err = data.GetAllWalletAddress()
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, wallets)
|
||||
}
|
||||
|
||||
// GetWallet 获取单个钱包
|
||||
func (c *BaseCommController) GetWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
wallet, err := data.GetWalletAddressById(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if wallet.ID <= 0 {
|
||||
return c.FailJson(ctx, constant.NotAvailableWalletAddress)
|
||||
}
|
||||
return c.SucJson(ctx, wallet)
|
||||
}
|
||||
|
||||
// ChangeWalletStatus 启用/禁用钱包
|
||||
func (c *BaseCommController) ChangeWalletStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
req := new(changeStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteWallet 删除钱包
|
||||
func (c *BaseCommController) DeleteWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/shengdoushi/base58"
|
||||
)
|
||||
@@ -28,3 +30,24 @@ func EncodeCheck(input []byte) string {
|
||||
|
||||
return Encode(inputCheck)
|
||||
}
|
||||
|
||||
// DecodeCheck decodes a Base58Check string and validates its 4-byte checksum.
|
||||
func DecodeCheck(input string) ([]byte, error) {
|
||||
raw, err := base58.Decode(input, base58.BitcoinAlphabet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) < 4 {
|
||||
return nil, fmt.Errorf("base58check payload too short")
|
||||
}
|
||||
|
||||
payload := raw[:len(raw)-4]
|
||||
checksum := raw[len(raw)-4:]
|
||||
|
||||
h0 := sha256.Sum256(payload)
|
||||
h1 := sha256.Sum256(h0[:])
|
||||
if !bytes.Equal(checksum, h1[:4]) {
|
||||
return nil, fmt.Errorf("base58check checksum mismatch")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
module github.com/assimon/luuu
|
||||
module github.com/GMWalletApp/epusdt
|
||||
|
||||
go 1.25.0
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/ethereum/go-ethereum v1.15.11
|
||||
github.com/gagliardetto/solana-go v1.16.0
|
||||
github.com/go-resty/resty/v2 v2.11.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||
github.com/gookit/color v1.5.0
|
||||
github.com/gookit/goutil v0.4.6
|
||||
github.com/gookit/validate v1.3.1
|
||||
@@ -22,7 +23,9 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/xssnick/tonutils-go v1.16.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gopkg.in/telebot.v3 v3.0.0
|
||||
gorm.io/driver/mysql v1.5.1
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
@@ -99,12 +102,11 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
|
||||
+10
-8
@@ -348,6 +348,8 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xssnick/tonutils-go v1.16.0 h1:MLYVZiiMB0Q+XudLo7WyjOli4gMuenNmJpBqETqa5DE=
|
||||
github.com/xssnick/tonutils-go v1.16.0/go.mod h1:CNSM+L0FYufOFhprg8otc9Y/ecFfLrWqFMq00Gh9yk4=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
@@ -375,8 +377,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@@ -397,8 +399,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -440,8 +442,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -450,8 +452,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
// Package install provides a first-run setup REST API.
|
||||
//
|
||||
// When the .env config file is absent (or has install=true) the HTTP start
|
||||
// command calls RunInstallServer, which listens on the same address the main
|
||||
// server will eventually use (default :8000) and mounts two JSON endpoints
|
||||
// under /install consumed by the frontend install UI:
|
||||
//
|
||||
// GET /install/defaults — default field values for the form
|
||||
// POST /install — validate + write .env, then shut down
|
||||
//
|
||||
// The HTTP listen address is submitted as two separate fields (http_bind_addr
|
||||
// and http_bind_port) and combined internally as "ADDR:PORT" before writing
|
||||
// the http_listen key in .env. This makes the form easier for users who only
|
||||
// want to change the port without touching the bind address.
|
||||
//
|
||||
// Once the .env is written the install server stops and normal bootstrap
|
||||
// proceeds on the same port without a restart.
|
||||
package install
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/gookit/color"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// DefaultInstallAddr is the listen address used by the install API.
|
||||
// Matches the default http_listen so no extra port is needed.
|
||||
const DefaultInstallAddr = ":8000"
|
||||
|
||||
// InstallRequest is the payload submitted by the install form.
|
||||
// All fields are optional except AppURI; omitted fields fall back to InstallDefaults().
|
||||
type InstallRequest struct {
|
||||
// Application display name (default: epusdt)
|
||||
AppName string `json:"app_name" form:"app_name" example:"epusdt"`
|
||||
// Public base URL of the service, e.g. https://pay.example.com (required)
|
||||
AppURI string `json:"app_uri" form:"app_uri" example:"https://pay.example.com"`
|
||||
// Bind address for the HTTP server (default: 127.0.0.1)
|
||||
HttpBindAddr string `json:"http_bind_addr" form:"http_bind_addr" example:"127.0.0.1"`
|
||||
// Bind port for the HTTP server (default: 8000)
|
||||
HttpBindPort int `json:"http_bind_port" form:"http_bind_port" example:"8000"`
|
||||
// Runtime directory for SQLite DB and temp files (default: ./runtime)
|
||||
RuntimeRootPath string `json:"runtime_root_path" form:"runtime_root_path" example:"./runtime"`
|
||||
// Directory for application log files (default: ./logs)
|
||||
LogSavePath string `json:"log_save_path" form:"log_save_path" example:"./logs"`
|
||||
// Minutes before an unpaid order expires (default: 10)
|
||||
OrderExpirationTime int `json:"order_expiration_time" form:"order_expiration_time" example:"10"`
|
||||
// Maximum webhook retry attempts (default: 1)
|
||||
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
|
||||
}
|
||||
|
||||
// InstallDefaults returns sensible default values for the install form.
|
||||
func InstallDefaults() InstallRequest {
|
||||
return InstallRequest{
|
||||
AppName: "epusdt",
|
||||
AppURI: "",
|
||||
HttpBindAddr: "127.0.0.1",
|
||||
HttpBindPort: 8000,
|
||||
RuntimeRootPath: "./runtime",
|
||||
LogSavePath: "./logs",
|
||||
OrderExpirationTime: 10,
|
||||
OrderNoticeMaxRetry: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// installHandler holds the per-invocation state shared between handlers.
|
||||
type installHandler struct {
|
||||
envFilePath string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
|
||||
return c.Redirect(http.StatusFound, "/install")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveInstallWWWRoot() string {
|
||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||
// of the working directory. main.go extracts www/ next to the binary.
|
||||
wwwRoot := "./www"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||
}
|
||||
}
|
||||
return wwwRoot
|
||||
}
|
||||
|
||||
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
|
||||
h := &installHandler{
|
||||
envFilePath: envFilePath,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
// api routes for the install frontend
|
||||
api := e.Group("/api")
|
||||
|
||||
api.GET("/install/defaults", h.GetDefaults)
|
||||
api.POST("/install", h.Submit)
|
||||
|
||||
// Redirect browser visits on root to /install so first-run users land
|
||||
// on the wizard directly. This must run before the static middleware,
|
||||
// otherwise "/" is intercepted by the SPA index.html fallback.
|
||||
e.Use(installRootRedirectMiddleware)
|
||||
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
|
||||
},
|
||||
HTML5: true,
|
||||
Index: "index.html",
|
||||
Root: wwwRoot,
|
||||
}))
|
||||
|
||||
return e, h
|
||||
}
|
||||
|
||||
// GetDefaults returns default values for the install form.
|
||||
//
|
||||
// @Summary Install — get default values
|
||||
// @Description Returns sensible default field values for the first-run install form.
|
||||
//
|
||||
// Available only before the .env config file has been written.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Produce json
|
||||
// @Success 200 {object} InstallRequest "Default install values"
|
||||
// @Router /api/install/defaults [get]
|
||||
func (h *installHandler) GetDefaults(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, InstallDefaults())
|
||||
}
|
||||
|
||||
// Submit validates the install payload, writes the .env file, and signals
|
||||
// the install server to shut down so the main bootstrap can proceed.
|
||||
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
|
||||
// the http_listen config key (e.g. 0.0.0.0:8000).
|
||||
//
|
||||
// @Summary Install — submit configuration
|
||||
// @Description Validates the submitted configuration and writes the .env file.
|
||||
//
|
||||
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
|
||||
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
|
||||
// http_bind_port must be in the range 1–65535 if provided.
|
||||
// app_uri is required. All other fields are optional and fall back to
|
||||
// the defaults returned by GET /api/install/defaults.
|
||||
// Sets install=false in the written .env, then shuts down the install
|
||||
// server so that normal application bootstrap starts on the same port.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body InstallRequest true "Install configuration"
|
||||
// @Success 200 {object} map[string]string "message"
|
||||
// @Failure 400 {object} map[string]string "error"
|
||||
// @Failure 500 {object} map[string]string "error"
|
||||
// @Router /api/install [post]
|
||||
func (h *installHandler) Submit(c echo.Context) error {
|
||||
req := new(InstallRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.AppURI = strings.TrimSpace(req.AppURI)
|
||||
if req.AppURI == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "app_uri is required"})
|
||||
}
|
||||
if req.HttpBindPort != 0 && (req.HttpBindPort < 1 || req.HttpBindPort > 65535) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "http_bind_port must be between 1 and 65535"})
|
||||
}
|
||||
d := InstallDefaults()
|
||||
if strings.TrimSpace(req.AppName) == "" {
|
||||
req.AppName = d.AppName
|
||||
}
|
||||
if strings.TrimSpace(req.HttpBindAddr) == "" {
|
||||
req.HttpBindAddr = d.HttpBindAddr
|
||||
}
|
||||
if req.HttpBindPort <= 0 {
|
||||
req.HttpBindPort = d.HttpBindPort
|
||||
}
|
||||
if strings.TrimSpace(req.RuntimeRootPath) == "" {
|
||||
req.RuntimeRootPath = d.RuntimeRootPath
|
||||
}
|
||||
if strings.TrimSpace(req.LogSavePath) == "" {
|
||||
req.LogSavePath = d.LogSavePath
|
||||
}
|
||||
if req.OrderExpirationTime <= 0 {
|
||||
req.OrderExpirationTime = d.OrderExpirationTime
|
||||
}
|
||||
if req.OrderNoticeMaxRetry < 0 {
|
||||
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
|
||||
}
|
||||
|
||||
if err := writeEnvFile(h.envFilePath, req); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
go func() { close(h.done) }()
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"})
|
||||
}
|
||||
|
||||
// RunInstallServer starts the install REST API on listenAddr (default :8000)
|
||||
// under the /install path and blocks until the .env file has been written.
|
||||
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
|
||||
func RunInstallServer(listenAddr, envFilePath string) {
|
||||
if listenAddr == "" {
|
||||
listenAddr = DefaultInstallAddr
|
||||
}
|
||||
|
||||
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
|
||||
|
||||
// Build a human-readable URL for the console hint.
|
||||
installHost := listenAddr
|
||||
if strings.HasPrefix(installHost, ":") {
|
||||
installHost = "localhost" + installHost
|
||||
}
|
||||
color.Green.Printf("[install] no config found — install API available at http://%s/install\n", installHost)
|
||||
|
||||
go func() {
|
||||
<-h.done
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = e.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
if err := e.Start(listenAddr); err != nil && err != http.ErrServerClosed {
|
||||
color.Red.Printf("[install] server error: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
color.Green.Printf("[install] configuration saved to %s, starting…\n", envFilePath)
|
||||
}
|
||||
|
||||
// formControlledKeys are keys whose values always come from the install form
|
||||
// (or are set unconditionally by the template). Existing config values for
|
||||
// these keys must NOT be preserved — the form submission takes precedence.
|
||||
var formControlledKeys = map[string]bool{
|
||||
"app_name": true,
|
||||
"app_uri": true,
|
||||
"http_listen": true,
|
||||
"runtime_root_path": true,
|
||||
"log_save_path": true,
|
||||
"order_expiration_time": true,
|
||||
"order_notice_max_retry": true,
|
||||
"install": true,
|
||||
}
|
||||
|
||||
// writeEnvFile renders and writes a minimal .env file.
|
||||
// If the file already exists, values for keys that are NOT controlled by the
|
||||
// install form are preserved from the existing file so that operator-specific
|
||||
// settings (tg_bot_token, db_type, etc.) survive a re-install.
|
||||
// Keys that the form controls (app_uri, http_listen, …) always use the
|
||||
// submitted values.
|
||||
func writeEnvFile(path string, r *InstallRequest) error {
|
||||
// Collect existing non-empty key→value pairs for non-form keys.
|
||||
existingValues := map[string]string{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(line, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(line[:idx])
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
if v != "" && !formControlledKeys[k] {
|
||||
existingValues[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Render the template into a buffer first.
|
||||
var buf bytes.Buffer
|
||||
if err := envTemplate.Execute(&buf, r); err != nil {
|
||||
return fmt.Errorf("render env template: %w", err)
|
||||
}
|
||||
|
||||
// For non-form keys that already had a value, substitute the existing value
|
||||
// so the template default does not clobber operator configuration.
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(trimmed, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(trimmed[:idx])
|
||||
if existing, ok := existingValues[k]; ok {
|
||||
lines[i] = k + "=" + existing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open config file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
|
||||
app_uri={{.AppURI}}
|
||||
log_level=info
|
||||
http_access_log=false
|
||||
sql_debug=false
|
||||
http_listen={{.HttpBindAddr}}:{{.HttpBindPort}}
|
||||
|
||||
static_path=/static
|
||||
runtime_root_path={{.RuntimeRootPath}}
|
||||
|
||||
log_save_path={{.LogSavePath}}
|
||||
log_max_size=32
|
||||
log_max_age=7
|
||||
max_backups=3
|
||||
|
||||
# supported values: postgres,mysql,sqlite
|
||||
db_type=sqlite
|
||||
|
||||
# sqlite primary database config
|
||||
sqlite_database_filename=
|
||||
sqlite_table_prefix=
|
||||
|
||||
# sqlite runtime store config
|
||||
runtime_sqlite_filename=epusdt-runtime.db
|
||||
|
||||
# background scheduler config
|
||||
queue_concurrency=10
|
||||
queue_poll_interval_ms=1000
|
||||
callback_retry_base_seconds=5
|
||||
|
||||
order_expiration_time={{.OrderExpirationTime}}
|
||||
order_notice_max_retry={{.OrderNoticeMaxRetry}}
|
||||
|
||||
api_rate_url=
|
||||
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=false
|
||||
`))
|
||||
@@ -0,0 +1,224 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestInstallDefaults(t *testing.T) {
|
||||
d := InstallDefaults()
|
||||
if d.AppName != "epusdt" {
|
||||
t.Errorf("AppName = %q, want epusdt", d.AppName)
|
||||
}
|
||||
if d.HttpBindAddr != "127.0.0.1" {
|
||||
t.Errorf("HttpBindAddr = %q, want 127.0.0.1", d.HttpBindAddr)
|
||||
}
|
||||
if d.HttpBindPort != 8000 {
|
||||
t.Errorf("HttpBindPort = %d, want 8000", d.HttpBindPort)
|
||||
}
|
||||
if d.OrderExpirationTime != 10 {
|
||||
t.Errorf("OrderExpirationTime = %d, want 10", d.OrderExpirationTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
|
||||
req := &InstallRequest{
|
||||
AppName: "myapp",
|
||||
AppURI: "http://1.2.3.4:8000",
|
||||
HttpBindAddr: "0.0.0.0",
|
||||
HttpBindPort: 9000,
|
||||
RuntimeRootPath: "./runtime",
|
||||
LogSavePath: "./logs",
|
||||
OrderExpirationTime: 15,
|
||||
OrderNoticeMaxRetry: 3,
|
||||
}
|
||||
if err := writeEnvFile(path, req); err != nil {
|
||||
t.Fatalf("writeEnvFile: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read env: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"app_name=myapp",
|
||||
"app_uri=http://1.2.3.4:8000",
|
||||
"http_listen=0.0.0.0:9000",
|
||||
"order_expiration_time=15",
|
||||
"order_notice_max_retry=3",
|
||||
"db_type=sqlite",
|
||||
"install=false",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("env file missing %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPIDefaults(t *testing.T) {
|
||||
h := &installHandler{done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.GET("/install/defaults", h.GetDefaults)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/install/defaults", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["app_name"] != "epusdt" {
|
||||
t.Errorf("app_name = %v, want epusdt", body["app_name"])
|
||||
}
|
||||
if body["http_bind_addr"] != "127.0.0.1" {
|
||||
t.Errorf("http_bind_addr = %v, want 127.0.0.1", body["http_bind_addr"])
|
||||
}
|
||||
if body["http_bind_port"] != float64(8000) {
|
||||
t.Errorf("http_bind_port = %v, want 8000", body["http_bind_port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wwwRoot := filepath.Join(dir, "www")
|
||||
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||
t.Fatalf("mkdir www root: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
|
||||
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/install" {
|
||||
t.Fatalf("Location = %q, want /install", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wwwRoot := filepath.Join(dir, "www")
|
||||
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||
t.Fatalf("mkdir www root: %v", err)
|
||||
}
|
||||
const wantBody = "install-ui"
|
||||
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
|
||||
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/install", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// done channel should be closed after successful submit
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("handler did not close done channel within timeout")
|
||||
}
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("env file not written: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "app_uri=http://10.0.0.1:8000") {
|
||||
t.Errorf("env file missing app_uri; content:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
|
||||
t.Errorf("env file missing http_listen; content:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmitMissingURI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
t.Error("env file should not have been written for invalid request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmitInvalidPort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
t.Error("env file should not have been written for invalid port")
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
appLog "github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
@@ -20,35 +21,92 @@ func SetupTestDatabases(t testing.TB) func() {
|
||||
t.Helper()
|
||||
|
||||
viper.Reset()
|
||||
viper.Set("forced_usdt_rate", 1.0)
|
||||
viper.Set("app_uri", "https://example.com")
|
||||
viper.Set("order_expiration_time", 10)
|
||||
viper.Set("order_notice_max_retry", 2)
|
||||
viper.Set("callback_retry_base_seconds", 1)
|
||||
viper.Set("queue_concurrency", 4)
|
||||
viper.Set("queue_poll_interval_ms", 50)
|
||||
viper.Set("api_auth_token", "test-token")
|
||||
|
||||
config.HTTPAccessLog = false
|
||||
config.SQLDebug = false
|
||||
config.LogLevel = "error"
|
||||
config.UsdtRate = 0
|
||||
appLog.Sugar = zap.NewNop().Sugar()
|
||||
|
||||
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
||||
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
|
||||
|
||||
mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{})
|
||||
mustMigrate(t, mainDB,
|
||||
&mdb.Orders{},
|
||||
&mdb.ProviderOrder{},
|
||||
&mdb.WalletAddress{},
|
||||
&mdb.ApiKey{},
|
||||
&mdb.Setting{},
|
||||
&mdb.NotificationChannel{},
|
||||
&mdb.Chain{},
|
||||
&mdb.ChainToken{},
|
||||
&mdb.RpcNode{},
|
||||
&mdb.AdminUser{},
|
||||
)
|
||||
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
|
||||
|
||||
dao.Mdb = mainDB
|
||||
dao.RuntimeDB = runtimeDB
|
||||
config.SettingsGetString = func(key string) string {
|
||||
if dao.Mdb == nil {
|
||||
return ""
|
||||
}
|
||||
var row mdb.Setting
|
||||
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return row.Value
|
||||
}
|
||||
|
||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||
for _, network := range []string{
|
||||
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
||||
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon,
|
||||
} {
|
||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||
}
|
||||
|
||||
mainDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||
})
|
||||
|
||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||
// `pid=1001` still match.
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-default",
|
||||
Pid: "test-token", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-pid-1001",
|
||||
Pid: "1001", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: "rate",
|
||||
Key: "rate.forced_rate_list",
|
||||
Value: `{"cny":{"usdt":1}}`,
|
||||
Type: "json",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed rate.forced_rate_list: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
closeDB(t, runtimeDB)
|
||||
closeDB(t, mainDB)
|
||||
dao.Mdb = nil
|
||||
dao.RuntimeDB = nil
|
||||
config.SettingsGetString = nil
|
||||
viper.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
+91
-1
@@ -1,11 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/command"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/command"
|
||||
"github.com/gookit/color"
|
||||
)
|
||||
|
||||
//go:embed all:www
|
||||
var wwwDir embed.FS
|
||||
|
||||
func executableDir() (string, error) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(exePath), nil
|
||||
}
|
||||
|
||||
func extractEmbeddedDir(src embed.FS, embeddedRoot, dstRoot string) error {
|
||||
sub, err := fs.Sub(src, embeddedRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dstRoot, path)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in, err := sub.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func releaseStatic(fs embed.FS, target string) (string, error) {
|
||||
baseDir, err := executableDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(baseDir, target)
|
||||
|
||||
if err := os.RemoveAll(targetDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := extractEmbeddedDir(fs, target, targetDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return targetDir, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
wwwwPath, err := releaseStatic(wwwDir, "www")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("www released to:", wwwwPath)
|
||||
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
color.Error.Println("[Start Server Err!!!] ", err)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// AdminUserIDKey is the echo.Context key holding the authenticated
|
||||
// admin user's ID after successful JWT validation.
|
||||
AdminUserIDKey = "admin_user_id"
|
||||
// AdminUsernameKey mirrors the username for convenience.
|
||||
AdminUsernameKey = "admin_username"
|
||||
)
|
||||
|
||||
// CheckAdminJWT validates an Authorization: Bearer <jwt> header against
|
||||
// the HS256 secret stored in system.jwt_secret. On success it injects
|
||||
// the admin user ID and username into the echo context.
|
||||
//
|
||||
// Accepts both "Authorization: Bearer <jwt>" and a bare "<jwt>" for
|
||||
// developer convenience (curl / postman). The token itself is always
|
||||
// cryptographically validated — the relaxed header parsing doesn't
|
||||
// widen the trust surface.
|
||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claims, err := appjwt.Parse(token)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||
}
|
||||
ctx.Set(AdminUserIDKey, claims.AdminUserID)
|
||||
ctx.Set(AdminUsernameKey, claims.Username)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func adminTokenFromAuthorization(header string) (string, error) {
|
||||
raw := strings.TrimSpace(header)
|
||||
if raw == "" {
|
||||
return "", echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
token := raw
|
||||
if strings.HasPrefix(raw, bearerPrefix) {
|
||||
token = strings.TrimSpace(raw[len(bearerPrefix):])
|
||||
} else if raw == strings.TrimSpace(bearerPrefix) {
|
||||
token = ""
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestAdminTokenFromAuthorization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "bearer token",
|
||||
header: "Bearer jwt-token",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "bare token",
|
||||
header: "jwt-token",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "trimmed bare token",
|
||||
header: " jwt-token ",
|
||||
want: "jwt-token",
|
||||
},
|
||||
{
|
||||
name: "missing header",
|
||||
header: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty bearer token",
|
||||
header: "Bearer ",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := adminTokenFromAuthorization(tt.header)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("token = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAdminJWTAcceptsBearerAndBareTokens(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, "test-jwt-secret", mdb.SettingTypeString); err != nil {
|
||||
t.Fatalf("seed jwt secret: %v", err)
|
||||
}
|
||||
token, err := appjwt.Sign(42, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{
|
||||
name: "bearer token",
|
||||
header: "Bearer " + token,
|
||||
},
|
||||
{
|
||||
name: "bare token",
|
||||
header: token,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e := echo.New()
|
||||
e.Use(CheckAdminJWT())
|
||||
e.GET("/", func(ctx echo.Context) error {
|
||||
if got := ctx.Get(AdminUserIDKey); got != uint64(42) {
|
||||
t.Fatalf("%s = %v, want 42", AdminUserIDKey, got)
|
||||
}
|
||||
if got := ctx.Get(AdminUsernameKey); got != "admin" {
|
||||
t.Fatalf("%s = %v, want admin", AdminUsernameKey, got)
|
||||
}
|
||||
return ctx.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(echo.HeaderAuthorization, tt.header)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+115
-10
@@ -2,37 +2,142 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"crypto/subtle"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/json"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/json"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Context keys populated by CheckApiSign after successful verification.
|
||||
// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id.
|
||||
const (
|
||||
ApiKeyIDKey = "api_key_id"
|
||||
ApiKeyRowKey = "api_key_row"
|
||||
)
|
||||
|
||||
// CheckApiSign validates the body signature against the secret_key of
|
||||
// the api_keys row matching the submitted "pid" field. A single row is
|
||||
// valid for all gateway flows — identification is always by pid.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Extract the pid from the request body.
|
||||
// 2. Look up the enabled row by pid; if missing, return signature error.
|
||||
// 3. Verify signature == MD5(sorted_params + secret_key).
|
||||
// 4. Enforce IP whitelist (empty = allow any).
|
||||
// 5. Bump call_count / last_used_at (best-effort).
|
||||
// 6. Stash api_key_id + row in context and rewind the body.
|
||||
func CheckApiSign() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
params, err := ioutil.ReadAll(ctx.Request().Body)
|
||||
params, err := io.ReadAll(ctx.Request().Body)
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
// Rewind the body for downstream bindings regardless of outcome.
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(params))
|
||||
|
||||
m := make(map[string]interface{})
|
||||
err = json.Cjson.Unmarshal(params, &m)
|
||||
contentType := ctx.Request().Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
values, parseErr := url.ParseQuery(string(params))
|
||||
if parseErr != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
for k, vs := range values {
|
||||
if len(vs) > 0 {
|
||||
m[k] = vs[0]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err = json.Cjson.Unmarshal(params, &m); err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
}
|
||||
signature, ok := m["signature"]
|
||||
if !ok {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
checkSignature, err := sign.Get(m, config.GetApiAuthToken())
|
||||
identifier := extractPid(m)
|
||||
if identifier == "" {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
row, err := data.GetEnabledApiKey(identifier)
|
||||
if err != nil || row.ID == 0 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
checkSignature, err := sign.Get(m, row.SecretKey)
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
if checkSignature != signature {
|
||||
signatureStr, _ := signature.(string)
|
||||
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
ctx.Request().Body = ioutil.NopCloser(bytes.NewBuffer(params))
|
||||
|
||||
if !IsIPWhitelisted(row.IpWhitelist, ctx.RealIP()) {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
_ = data.TouchApiKeyUsage(row.ID)
|
||||
|
||||
ctx.Set(ApiKeyIDKey, row.ID)
|
||||
ctx.Set(ApiKeyRowKey, row)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractPid reads the "pid" field from the request body. JSON numbers
|
||||
// unmarshal to float64 (EPAY merchants typically send numeric pid); we
|
||||
// format with -1 precision to drop trailing zeros so "1000" matches
|
||||
// regardless of whether the client sent a string or a number.
|
||||
func extractPid(m map[string]interface{}) string {
|
||||
v, ok := m["pid"]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsIPWhitelisted checks the CSV IP list. Empty list = open.
|
||||
// Entries may be single IPs or CIDR blocks.
|
||||
func IsIPWhitelisted(csv, remote string) bool {
|
||||
csv = strings.TrimSpace(csv)
|
||||
if csv == "" {
|
||||
return true
|
||||
}
|
||||
remoteIP := net.ParseIP(remote)
|
||||
for _, raw := range strings.Split(csv, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
_, cidr, err := net.ParseCIDR(entry)
|
||||
if err == nil && remoteIP != nil && cidr.Contains(remoteIP) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if entry == remote {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckApiToken validates the Authorization header against the configured api_auth_token.
|
||||
// Use this for management APIs (GET/POST) where body-based signature is not practical.
|
||||
func CheckApiToken() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
token := ctx.Request().Header.Get("Authorization")
|
||||
if token == "" {
|
||||
token = ctx.QueryParam("api_token")
|
||||
}
|
||||
if token == "" || token != config.GetApiAuthToken() {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package dao
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/postgres"
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
|
||||
+264
-39
@@ -1,60 +1,285 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/gookit/color"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
|
||||
// 自动建表
|
||||
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
|
||||
// the minimum static rows: chains, chain tokens, default settings, and
|
||||
// a Telegram notification channel migrated from legacy settings keys.
|
||||
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
|
||||
// established. The install wizard (see install/installer.go) writes the
|
||||
// .env file before bootstrap runs, so this function always finds the DB
|
||||
// available.
|
||||
func MdbTableInit() {
|
||||
once.Do(func() {
|
||||
if err := Mdb.AutoMigrate(&mdb.Orders{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
||||
// panic(err)
|
||||
return
|
||||
migrations := []struct {
|
||||
name string
|
||||
model interface{}
|
||||
}{
|
||||
{"Orders", &mdb.Orders{}},
|
||||
{"WalletAddress", &mdb.WalletAddress{}},
|
||||
{"AdminUser", &mdb.AdminUser{}},
|
||||
{"ApiKey", &mdb.ApiKey{}},
|
||||
{"Setting", &mdb.Setting{}},
|
||||
{"NotificationChannel", &mdb.NotificationChannel{}},
|
||||
{"Chain", &mdb.Chain{}},
|
||||
{"ChainToken", &mdb.ChainToken{}},
|
||||
{"RpcNode", &mdb.RpcNode{}},
|
||||
{"ProviderOrder", &mdb.ProviderOrder{}},
|
||||
}
|
||||
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
||||
// panic(err)
|
||||
return
|
||||
}
|
||||
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
|
||||
return
|
||||
for _, m := range migrations {
|
||||
if err := Mdb.AutoMigrate(m.model); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
seedSupportedAssets()
|
||||
seedChains()
|
||||
backfillRpcNodePurpose()
|
||||
seedRpcNodes()
|
||||
seedChainTokens()
|
||||
seedDefaultSettings()
|
||||
seedTelegramChannelFromSettings()
|
||||
})
|
||||
}
|
||||
|
||||
func seedSupportedAssets() {
|
||||
var cnt int64
|
||||
if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil {
|
||||
color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err)
|
||||
return
|
||||
// seedChains inserts the built-in networks as enabled rows. Uses
|
||||
// ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist.
|
||||
func seedChains() {
|
||||
defaults := []mdb.Chain{
|
||||
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
}
|
||||
if cnt > 0 {
|
||||
return
|
||||
}
|
||||
defaults := []mdb.SupportedAsset{
|
||||
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
}
|
||||
if err := Mdb.Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err)
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedChainTokens inserts default token configurations for each chain.
|
||||
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
|
||||
// re-runs are no-ops and admin edits to contract_address / decimals /
|
||||
// min_amount persist across restarts.
|
||||
func seedChainTokens() {
|
||||
// Contract-address rows are matched by scanners via exact contract
|
||||
// lookup. Native-token rows (empty contract_address) are matched by
|
||||
// symbol — these act as admin toggles for the native payment path.
|
||||
defaults := []mdb.ChainToken{
|
||||
// TRON
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
// Ethereum
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
|
||||
// Solana
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
|
||||
// Polygon
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||
// Plasma
|
||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||
// TON
|
||||
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||
func seedRpcNodes() {
|
||||
defaults := defaultRpcNodes()
|
||||
for _, d := range defaults {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ? AND url = ?", d.Network, d.Url).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := Mdb.Create(&d).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRpcNodes() []mdb.RpcNode {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func backfillRpcNodePurpose() {
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("purpose = '' OR purpose IS NULL").
|
||||
Update("purpose", mdb.RpcNodePurposeGeneral).Error; err != nil {
|
||||
color.Red.Printf("[store_db] backfill rpc_nodes purpose err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultSettings inserts built-in default settings.
|
||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||
func seedDefaultSettings() {
|
||||
okPayCallbackURL := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
|
||||
if okPayCallbackURL != "" {
|
||||
okPayCallbackURL += "/payments/okpay/v1/notify"
|
||||
}
|
||||
defaults := []mdb.Setting{
|
||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayTimeoutSeconds, Value: "10", Type: mdb.SettingTypeInt},
|
||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAllowTokens, Value: "USDT,TRX", Type: mdb.SettingTypeString},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
|
||||
// Telegram config into a notification_channels row so the notify dispatcher
|
||||
// can use it. Idempotent: no-op when any telegram channel already exists.
|
||||
//
|
||||
// Legacy keys read from settings:
|
||||
//
|
||||
// system.telegram_bot_token → TelegramConfig.BotToken
|
||||
// system.telegram_chat_id → TelegramConfig.ChatID
|
||||
// system.telegram_payment_notice_enabled → events.pay_success
|
||||
// system.telegram_abnormal_notice_enabled → events.order_expired
|
||||
func seedTelegramChannelFromSettings() {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
SyncTelegramChannelFromSettings()
|
||||
}
|
||||
|
||||
// SyncTelegramChannelFromSettings reads the four Telegram keys from the
|
||||
// settings table and upserts the matching notification_channels row:
|
||||
// - creates a new row when none exists yet
|
||||
// - updates config+events on the first existing row when settings change
|
||||
//
|
||||
// This is called at startup (via seedTelegramChannelFromSettings) and by
|
||||
// the settings controller whenever any Telegram key is changed at runtime.
|
||||
func SyncTelegramChannelFromSettings() {
|
||||
keys := []string{
|
||||
"system.telegram_bot_token",
|
||||
"system.telegram_chat_id",
|
||||
"system.telegram_payment_notice_enabled",
|
||||
"system.telegram_abnormal_notice_enabled",
|
||||
}
|
||||
var rows []mdb.Setting
|
||||
if err := Mdb.Where("key IN ?", keys).Find(&rows).Error; err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel: read settings err=%s\n", err)
|
||||
return
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.Key] = r.Value
|
||||
}
|
||||
|
||||
botToken := strings.TrimSpace(m["system.telegram_bot_token"])
|
||||
chatIDStr := strings.TrimSpace(m["system.telegram_chat_id"])
|
||||
if botToken == "" || chatIDStr == "" {
|
||||
return // No telegram config to sync.
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel: invalid chat_id=%q\n", chatIDStr)
|
||||
return
|
||||
}
|
||||
|
||||
paymentEnabled, _ := strconv.ParseBool(m["system.telegram_payment_notice_enabled"])
|
||||
abnormalEnabled, _ := strconv.ParseBool(m["system.telegram_abnormal_notice_enabled"])
|
||||
|
||||
configJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"bot_token": botToken,
|
||||
"chat_id": chatID,
|
||||
"proxy": "",
|
||||
})
|
||||
eventsJSON, _ := json.Marshal(map[string]bool{
|
||||
mdb.NotifyEventPaySuccess: paymentEnabled,
|
||||
mdb.NotifyEventOrderExpired: abnormalEnabled,
|
||||
mdb.NotifyEventDailyReport: false,
|
||||
})
|
||||
|
||||
// Try to update an existing row first.
|
||||
// Use Find (not First) to avoid GORM printing a spurious
|
||||
// "record not found" log when the table is empty on first boot.
|
||||
var existing mdb.NotificationChannel
|
||||
Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Order("id ASC").Limit(1).Find(&existing)
|
||||
if existing.ID != 0 {
|
||||
// Row exists — patch config and events.
|
||||
if err2 := Mdb.Model(&existing).Updates(map[string]interface{}{
|
||||
"config": string(configJSON),
|
||||
"events": string(eventsJSON),
|
||||
}).Error; err2 != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel update err=%s\n", err2)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No row yet — create one.
|
||||
ch := mdb.NotificationChannel{
|
||||
Type: mdb.NotificationTypeTelegram,
|
||||
Name: "Telegram",
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := Mdb.Create(&ch).Error; err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel create err=%s\n", err)
|
||||
} else {
|
||||
color.Green.Printf("[store_db] created telegram notification_channel from settings (id=%d)\n", ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
||||
want := map[string]string{
|
||||
mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum",
|
||||
mdb.NetworkBsc: "https://rpc.epusdt.com/binance",
|
||||
mdb.NetworkPolygon: "https://rpc.epusdt.com/polygon",
|
||||
}
|
||||
got := make(map[string]mdb.RpcNode)
|
||||
for _, node := range defaultRpcNodes() {
|
||||
if node.Purpose != mdb.RpcNodePurposeManualVerify {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[node.Network]; ok {
|
||||
got[node.Network] = node
|
||||
}
|
||||
}
|
||||
|
||||
for network, url := range want {
|
||||
node, ok := got[network]
|
||||
if !ok {
|
||||
t.Fatalf("missing manual_verify seed rpc node for %s", network)
|
||||
}
|
||||
if node.Url != url {
|
||||
t.Fatalf("%s manual_verify seed url = %q, want %q", network, node.Url, url)
|
||||
}
|
||||
if node.Type != mdb.RpcNodeTypeHttp {
|
||||
t.Fatalf("%s manual_verify seed type = %q, want %q", network, node.Type, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
if !node.Enabled {
|
||||
t.Fatalf("%s manual_verify seed enabled = false, want true", network)
|
||||
}
|
||||
if node.Status != mdb.RpcNodeStatusUnknown {
|
||||
t.Fatalf("%s manual_verify seed status = %q, want %q", network, node.Status, mdb.RpcNodeStatusUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
|
||||
var got *mdb.RpcNode
|
||||
nodes := defaultRpcNodes()
|
||||
for i := range nodes {
|
||||
node := nodes[i]
|
||||
if node.Network == mdb.NetworkTon && node.Type == mdb.RpcNodeTypeLite {
|
||||
got = &node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("missing TON lite seed rpc node")
|
||||
}
|
||||
if got.Url != "https://ton-blockchain.github.io/global.config.json" {
|
||||
t.Fatalf("TON lite seed url = %q", got.Url)
|
||||
}
|
||||
if got.Purpose != mdb.RpcNodePurposeGeneral {
|
||||
t.Fatalf("TON lite seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
|
||||
}
|
||||
if !got.Enabled {
|
||||
t.Fatal("TON lite seed enabled = false, want true")
|
||||
}
|
||||
if got.Status != mdb.RpcNodeStatusUnknown {
|
||||
t.Fatalf("TON lite seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
||||
db := setupSeedSettingsTestDB(t)
|
||||
Mdb = db
|
||||
|
||||
seedDefaultSettings()
|
||||
|
||||
var row mdb.Setting
|
||||
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||
t.Fatalf("load system.log_level seed: %v", err)
|
||||
}
|
||||
if row.Group != mdb.SettingGroupSystem {
|
||||
t.Fatalf("system.log_level group = %q, want %q", row.Group, mdb.SettingGroupSystem)
|
||||
}
|
||||
if row.Value != mdb.SettingDefaultSystemLogLevel {
|
||||
t.Fatalf("system.log_level value = %q, want %q", row.Value, mdb.SettingDefaultSystemLogLevel)
|
||||
}
|
||||
if row.Type != mdb.SettingTypeString {
|
||||
t.Fatalf("system.log_level type = %q, want %q", row.Type, mdb.SettingTypeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
|
||||
db := setupSeedSettingsTestDB(t)
|
||||
Mdb = db
|
||||
|
||||
seedDefaultSettings()
|
||||
|
||||
rows := make(map[string]mdb.Setting)
|
||||
for _, key := range []string{
|
||||
mdb.SettingKeyEpayDefaultToken,
|
||||
mdb.SettingKeyEpayDefaultCurrency,
|
||||
mdb.SettingKeyEpayDefaultNetwork,
|
||||
} {
|
||||
var row mdb.Setting
|
||||
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||
t.Fatalf("load %s seed: %v", key, err)
|
||||
}
|
||||
rows[key] = row
|
||||
}
|
||||
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
|
||||
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
|
||||
}
|
||||
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
|
||||
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
|
||||
}
|
||||
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
|
||||
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
|
||||
db := setupSeedSettingsTestDB(t)
|
||||
Mdb = db
|
||||
for _, row := range []mdb.Setting{
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||
} {
|
||||
if err := Mdb.Create(&row).Error; err != nil {
|
||||
t.Fatalf("precreate %s: %v", row.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
seedDefaultSettings()
|
||||
|
||||
for key, want := range map[string]string{
|
||||
mdb.SettingKeyEpayDefaultToken: "usdt",
|
||||
mdb.SettingKeyEpayDefaultNetwork: "tron",
|
||||
} {
|
||||
var row mdb.Setting
|
||||
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||
t.Fatalf("load %s seed: %v", key, err)
|
||||
}
|
||||
if row.Value != want {
|
||||
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
|
||||
db := setupSeedSettingsTestDB(t)
|
||||
Mdb = db
|
||||
if err := Mdb.Create(&mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeySystemLogLevel,
|
||||
Value: "debug",
|
||||
Type: mdb.SettingTypeString,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("precreate system.log_level: %v", err)
|
||||
}
|
||||
|
||||
seedDefaultSettings()
|
||||
|
||||
var row mdb.Setting
|
||||
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||
t.Fatalf("load system.log_level seed: %v", err)
|
||||
}
|
||||
if row.Value != "debug" {
|
||||
t.Fatalf("system.log_level value = %q, want existing debug", row.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
oldDB := Mdb
|
||||
viper.Reset()
|
||||
viper.Set("app_uri", "https://example.com")
|
||||
t.Cleanup(func() {
|
||||
Mdb = oldDB
|
||||
viper.Reset()
|
||||
})
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-settings.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&mdb.Setting{}); err != nil {
|
||||
t.Fatalf("migrate settings: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -44,6 +44,9 @@ func RuntimeInit() error {
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_network_address_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
const InitialAdminPasswordHashAlgorithm = "sha256"
|
||||
|
||||
var (
|
||||
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
|
||||
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
|
||||
)
|
||||
|
||||
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
|
||||
// so the frontend can detect whether the operator is still using the
|
||||
// initial password.
|
||||
type InitialAdminPasswordHashInfo struct {
|
||||
Algorithm string `json:"algorithm" example:"sha256"`
|
||||
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
|
||||
PasswordChanged bool `json:"password_changed" example:"false"`
|
||||
Available bool `json:"available" example:"true"`
|
||||
}
|
||||
|
||||
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
||||
// exists. The password is randomly generated and returned so the caller
|
||||
// can print it to the console. Idempotent — subsequent calls return
|
||||
// ("", false, nil).
|
||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
password = randomAdminPassword()
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
user := &mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(user).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := initAdminPasswordState(password); err != nil {
|
||||
// Account was already created; surface both for visibility while
|
||||
// preserving created=true and password for emergency fallback.
|
||||
return password, true, err
|
||||
}
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func purgeDeletedInitialAdminPasswordPlain() error {
|
||||
return dao.Mdb.Unscoped().
|
||||
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Delete(&mdb.Setting{}).Error
|
||||
}
|
||||
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashInitialAdminPassword fingerprints the initial plaintext password so
|
||||
// the frontend can compare user input locally without exposing plaintext.
|
||||
func HashInitialAdminPassword(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
func initAdminPasswordState(plain string) error {
|
||||
hash := HashInitialAdminPassword(plain)
|
||||
settings := []mdb.Setting{
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||
Value: plain, Type: mdb.SettingTypeString,
|
||||
Description: "One-time readable initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
|
||||
Value: hash, Type: mdb.SettingTypeString,
|
||||
Description: "SHA-256 fingerprint for initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
},
|
||||
}
|
||||
for _, row := range settings {
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Keep in-process cache coherent for the current process.
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||
updates := clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"})
|
||||
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: updates,
|
||||
}).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.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrInitAdminPasswordAlreadyFetched
|
||||
}
|
||||
return upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "true",
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||
settingsCacheMu.Unlock()
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
||||
// and the changed-state flag used by the admin frontend.
|
||||
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: "",
|
||||
PasswordChanged: true,
|
||||
Available: false,
|
||||
}, nil
|
||||
}
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: hash,
|
||||
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsUsingInitialAdminPassword reports whether the current admin password is
|
||||
// still considered the seeded initial password.
|
||||
func IsUsingInitialAdminPassword() bool {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return false
|
||||
}
|
||||
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
|
||||
}
|
||||
|
||||
// HashPassword bcrypts a plaintext password.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a plaintext password against a bcrypt hash.
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// GetAdminUserByUsername returns the row for a username (case-insensitive).
|
||||
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).
|
||||
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
|
||||
Limit(1).Find(u).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetAdminUserByID returns the row for an ID.
|
||||
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// UpdateAdminUserPassword rehashes and persists a new password.
|
||||
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||
hash, err := HashPassword(newPlain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue := ""
|
||||
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Old installs may not have initial-password metadata; skip in
|
||||
// that case to keep password updates backward compatible.
|
||||
var initHashRow mdb.Setting
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
|
||||
Limit(1).
|
||||
Find(&initHashRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
initHash := strings.TrimSpace(initHashRow.Value)
|
||||
if initHash == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
newHash := HashInitialAdminPassword(newPlain)
|
||||
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
|
||||
changedValue := "true"
|
||||
if !changed {
|
||||
changedValue = "false"
|
||||
}
|
||||
if err := upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: changedValue,
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue = changedValue
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changedCacheValue != "" {
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
||||
settingsCacheMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchAdminUserLastLogin stamps last_login_at to now.
|
||||
func TouchAdminUserLastLogin(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_login_at", carbon.Now().StdTime()).Error
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
row := mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: "false",
|
||||
Type: mdb.SettingTypeBool,
|
||||
}
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
t.Fatalf("seed setting: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Where("`key` = ?", row.Key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
t.Fatalf("delete setting: %v", err)
|
||||
}
|
||||
|
||||
row.Value = "true"
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
t.Fatalf("restore setting: %v", err)
|
||||
}
|
||||
|
||||
var restored mdb.Setting
|
||||
if err := dao.Mdb.Where("`key` = ?", row.Key).Take(&restored).Error; err != nil {
|
||||
t.Fatalf("load restored setting: %v", err)
|
||||
}
|
||||
if restored.Value != "true" {
|
||||
t.Fatalf("restored value = %q, want true", restored.Value)
|
||||
}
|
||||
if restored.DeletedAt.Valid {
|
||||
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const password = "init-pass-plain"
|
||||
if err := initAdminPasswordState(password); err != nil {
|
||||
t.Fatalf("seed initial password state: %v", err)
|
||||
}
|
||||
|
||||
got, err := ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("consume initial password: %v", err)
|
||||
}
|
||||
if got != password {
|
||||
t.Fatalf("password = %q, want %q", got, password)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dao.Mdb.Unscoped().
|
||||
Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count plaintext setting: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("plaintext setting rows after consume = %d, want 0", count)
|
||||
}
|
||||
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
|
||||
t.Fatal("expected fetched flag to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := HashPassword("existing-password")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||
Value: "legacy-soft-deleted-plain",
|
||||
Type: mdb.SettingTypeString,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed plaintext setting: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
t.Fatalf("soft delete plaintext setting: %v", err)
|
||||
}
|
||||
|
||||
password, created, err := EnsureDefaultAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("ensure default admin: %v", err)
|
||||
}
|
||||
if created || password != "" {
|
||||
t.Fatalf("created=%v password=%q, want existing admin unchanged", created, password)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := dao.Mdb.Unscoped().
|
||||
Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count plaintext setting: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetEnabledApiKey looks up an enabled merchant credential row by PID.
|
||||
// A single row is valid for both EPAY and GMPAY flows; the route
|
||||
// determines the flow. Returns (row, nil) on success; (zero row, nil)
|
||||
// when no match; error only on true DB failure.
|
||||
func GetEnabledApiKey(pid string) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("pid = ?", strings.TrimSpace(pid)).
|
||||
Where("status = ?", mdb.ApiKeyStatusEnable).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetApiKeyByID fetches a row by primary key (including disabled).
|
||||
func GetApiKeyByID(id uint64) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListApiKeys returns all rows ordered by ID descending.
|
||||
func ListApiKeys() ([]mdb.ApiKey, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Model(&mdb.ApiKey{}).Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CreateApiKey persists a new row.
|
||||
func CreateApiKey(row *mdb.ApiKey) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateApiKeyFields updates a whitelist of mutable fields on a row.
|
||||
func UpdateApiKeyFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteApiKeyByID removes (soft-deletes) a row.
|
||||
func DeleteApiKeyByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ApiKey{}).Error
|
||||
}
|
||||
|
||||
// TouchApiKeyUsage increments call_count and stamps last_used_at.
|
||||
// Called from the signature middleware after a successful verify.
|
||||
func TouchApiKeyUsage(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"call_count": gorm.Expr("call_count + 1"),
|
||||
"last_used_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// basePid is the starting PID value for api_keys.
|
||||
const basePid = 1000
|
||||
|
||||
// NextPid returns the next available numeric PID for api_keys. Scans
|
||||
// ALL rows (including soft-deleted) to avoid reusing a PID whose row
|
||||
// is still in the table with a non-null deleted_at — the unique index
|
||||
// on pid doesn't know about soft deletes, so reuse would trigger a
|
||||
// DB-level unique violation on Create.
|
||||
func NextPid() (int, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Unscoped().Model(&mdb.ApiKey{}).Select("pid").Find(&rows).Error
|
||||
if err != nil {
|
||||
return basePid, err
|
||||
}
|
||||
maxPid := basePid - 1
|
||||
for _, r := range rows {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(r.Pid)); err == nil && pid > maxPid {
|
||||
maxPid = pid
|
||||
}
|
||||
}
|
||||
return maxPid + 1, nil
|
||||
}
|
||||
|
||||
// SeededApiKey carries the details of a newly created default key so
|
||||
// the caller can print them to the console (secret is never surfaced
|
||||
// again via the admin API — it has to be viewed via /api-keys/:id/secret
|
||||
// after login).
|
||||
type SeededApiKey struct {
|
||||
Pid string
|
||||
Name string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
// EnsureDefaultApiKey seeds ONE universal API key on a fresh install.
|
||||
// The seeded key is valid for all three gateway flows — the admin can
|
||||
// create additional keys later, each also universal. Idempotent: if
|
||||
// any api_keys row already exists, returns nil without seeding.
|
||||
func EnsureDefaultApiKey() (*SeededApiKey, error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.ApiKey{}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
secret := generateHex(32)
|
||||
row := &mdb.ApiKey{
|
||||
Name: "default",
|
||||
Pid: strconv.Itoa(basePid),
|
||||
SecretKey: secret,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SeededApiKey{
|
||||
Pid: row.Pid,
|
||||
Name: row.Name,
|
||||
SecretKey: secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListChains returns all chain rows ordered by id.
|
||||
func ListChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChains returns only rows with Enabled=true.
|
||||
func ListEnabledChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetChainByNetwork fetches one row by network key.
|
||||
func GetChainByNetwork(network string) (*mdb.Chain, error) {
|
||||
row := new(mdb.Chain)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// IsChainEnabled returns whether the row exists and Enabled=true. A
|
||||
// missing row is treated as disabled — scanners must be able to
|
||||
// short-circuit even before the admin creates the chain entry.
|
||||
func IsChainEnabled(network string) bool {
|
||||
row, err := GetChainByNetwork(network)
|
||||
if err != nil || row.ID == 0 {
|
||||
return false
|
||||
}
|
||||
return row.Enabled
|
||||
}
|
||||
|
||||
// UpdateChainFields patches mutable columns.
|
||||
func UpdateChainFields(network string, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Updates(fields).Error
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
|
||||
// An empty network returns all enabled tokens across all networks.
|
||||
func ListEnabledChainTokens(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
tx := dao.Mdb.Model(&mdb.ChainToken{}).Where("enabled = ?", true)
|
||||
if n := strings.ToLower(strings.TrimSpace(network)); n != "" {
|
||||
tx = tx.Where("network = ?", n)
|
||||
}
|
||||
err := tx.Order("network ASC, id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListChainTokens returns rows optionally filtered by network.
|
||||
func ListChainTokens(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
tx := dao.Mdb.Model(&mdb.ChainToken{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChainTokensByNetwork returns the enabled tokens for a network.
|
||||
// Scanners call this to learn which contract addresses to subscribe to.
|
||||
func ListEnabledChainTokensByNetwork(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetEnabledChainTokenByContract finds one enabled token by (network,
|
||||
// contract_address). Used by EVM/TRON scanners to resolve the symbol
|
||||
// and decimals of an incoming transfer. Returns (zero row, nil) if no
|
||||
// match. The contract address match is case-insensitive.
|
||||
func GetEnabledChainTokenByContract(network, contractAddress string) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
addr := strings.TrimSpace(contractAddress)
|
||||
if addr == "" {
|
||||
return row, nil
|
||||
}
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("enabled = ?", true).
|
||||
Where("LOWER(contract_address) = ?", strings.ToLower(addr)).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetEnabledChainTokenBySymbol finds one enabled token by (network, symbol).
|
||||
// Used by Solana native-SOL path where there's no contract address.
|
||||
func GetEnabledChainTokenBySymbol(network, symbol string) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
sym := strings.TrimSpace(symbol)
|
||||
if sym == "" {
|
||||
return row, nil
|
||||
}
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("enabled = ?", true).
|
||||
Where("UPPER(symbol) = ?", strings.ToUpper(sym)).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetChainTokenByID fetches by primary key.
|
||||
func GetChainTokenByID(id uint64) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateChainToken inserts a row.
|
||||
// If a soft-deleted row with the same (network, symbol) exists it is
|
||||
// restored with the incoming field values rather than creating a duplicate
|
||||
// that would violate the unique index.
|
||||
func CreateChainToken(row *mdb.ChainToken) error {
|
||||
deleted := new(mdb.ChainToken)
|
||||
err := dao.Mdb.Unscoped().
|
||||
Where("network = ? AND symbol = ? AND deleted_at IS NOT NULL", row.Network, row.Symbol).
|
||||
Limit(1).Find(deleted).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted.ID > 0 {
|
||||
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"contract_address": row.ContractAddress,
|
||||
"decimals": row.Decimals,
|
||||
"enabled": row.Enabled,
|
||||
"min_amount": row.MinAmount,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.ID = deleted.ID
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateChainTokenFields patches mutable columns.
|
||||
func UpdateChainTokenFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.ChainToken{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteChainTokenByID soft-deletes the row.
|
||||
func DeleteChainTokenByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ChainToken{}).Error
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListNotificationChannels returns all rows, optionally filtered by type.
|
||||
func ListNotificationChannels(channelType string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
tx := dao.Mdb.Model(&mdb.NotificationChannel{})
|
||||
if channelType != "" {
|
||||
tx = tx.Where("type = ?", strings.ToLower(channelType))
|
||||
}
|
||||
err := tx.Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChannelsByEvent returns enabled rows whose Events JSON has
|
||||
// the named event key set to true. Done in Go (rather than JSON_EXTRACT)
|
||||
// because the runtime DB is DB-agnostic (MySQL/Postgres/SQLite).
|
||||
func ListEnabledChannelsByEvent(event string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
if err := dao.Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, r := range rows {
|
||||
events := map[string]bool{}
|
||||
if r.Events != "" {
|
||||
_ = json.Unmarshal([]byte(r.Events), &events)
|
||||
}
|
||||
if events[event] {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetFirstEnabledTelegramChannel returns the oldest enabled telegram
|
||||
// row, or nil ID if none. Telegram bot startup uses this as the primary
|
||||
// instance backing /start and command handlers.
|
||||
func GetFirstEnabledTelegramChannel() (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetNotificationChannelByID fetches by PK.
|
||||
func GetNotificationChannelByID(id uint64) (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateNotificationChannel inserts a new row.
|
||||
func CreateNotificationChannel(row *mdb.NotificationChannel) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelFields patches mutable columns.
|
||||
func UpdateNotificationChannelFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.NotificationChannel{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteNotificationChannelByID soft-deletes a row.
|
||||
func DeleteNotificationChannelByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.NotificationChannel{}).Error
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderListFilter bundles every filter supported by the admin orders
|
||||
// list page. Zero values are ignored so callers can pass only what the
|
||||
// user actually filtered on.
|
||||
type OrderListFilter struct {
|
||||
Status int
|
||||
Network string
|
||||
Token string
|
||||
Address string
|
||||
Keyword string // matches trade_id / order_id / block_transaction_id
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
// ParentOnly restricts the result to top-level orders only
|
||||
// (parent_trade_id = ''). Sub-orders are excluded from the listing.
|
||||
ParentOnly bool
|
||||
}
|
||||
|
||||
// ListOrders returns a paginated order slice plus the total count under
|
||||
// the same filter (for the UI pagination bar).
|
||||
func ListOrders(f OrderListFilter) ([]mdb.Orders, int64, error) {
|
||||
tx := buildOrderListQuery(f)
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page := f.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := f.PageSize
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 200 {
|
||||
size = 200
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := tx.Order("id DESC").
|
||||
Offset((page - 1) * size).Limit(size).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func buildOrderListQuery(f OrderListFilter) *gorm.DB {
|
||||
tx := dao.Mdb.Model(&mdb.Orders{})
|
||||
if f.ParentOnly {
|
||||
tx = tx.Where("parent_trade_id = ?", "")
|
||||
}
|
||||
if f.Status > 0 {
|
||||
tx = tx.Where("status = ?", f.Status)
|
||||
}
|
||||
if f.Network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(f.Network))
|
||||
}
|
||||
if f.Token != "" {
|
||||
tx = tx.Where("token = ?", strings.ToUpper(f.Token))
|
||||
}
|
||||
if f.Address != "" {
|
||||
tx = tx.Where("receive_address = ?", f.Address)
|
||||
}
|
||||
if f.Keyword != "" {
|
||||
kw := "%" + strings.TrimSpace(f.Keyword) + "%"
|
||||
tx = tx.Where("trade_id LIKE ? OR order_id LIKE ? OR block_transaction_id LIKE ?", kw, kw, kw)
|
||||
}
|
||||
if f.StartAt != nil {
|
||||
tx = tx.Where("created_at >= ?", *f.StartAt)
|
||||
}
|
||||
if f.EndAt != nil {
|
||||
tx = tx.Where("created_at <= ?", *f.EndAt)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// CountOrdersByStatus returns how many orders exist in each status.
|
||||
// Used by the dashboard overview card.
|
||||
func CountOrdersByStatus() (map[int]int64, error) {
|
||||
type row struct {
|
||||
Status int
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("status, COUNT(*) AS total").
|
||||
Group("status").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Status] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CloseOrderManually transitions a pending order to expired. It covers both
|
||||
// payable orders and placeholder orders waiting for token/network selection.
|
||||
func CloseOrderManually(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ReopenOrderCallback flips callback_confirm back to NO so the mq
|
||||
// worker picks it up on the next tick. Used by "resend callback".
|
||||
func ReopenOrderCallback(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Updates(map[string]interface{}{
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"callback_num": 0,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// CountOrdersByAddress returns order counts grouped by receive_address.
|
||||
// The admin wallet list annotates each wallet row with this number.
|
||||
func CountOrdersByAddress() (map[string]int64, error) {
|
||||
type row struct {
|
||||
Address string `gorm:"column:receive_address"`
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("receive_address, COUNT(*) AS total").
|
||||
Group("receive_address").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Address] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+295
-47
@@ -5,9 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
@@ -23,15 +24,71 @@ type PendingCallbackOrder struct {
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func normalizeAmount(amount float64, precision int) (int64, string) {
|
||||
precision = NormalizeAmountPrecision(precision)
|
||||
value := decimal.NewFromFloat(amount).Round(int32(precision))
|
||||
return value.Shift(int32(precision)).IntPart(), value.StringFixed(int32(precision))
|
||||
}
|
||||
|
||||
func normalizeLockAmount(amount float64) (int64, string) {
|
||||
value := decimal.NewFromFloat(amount).Round(2)
|
||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||
return normalizeAmount(amount, GetAmountPrecision())
|
||||
}
|
||||
|
||||
func normalizeLockNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeLockAddress(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
network = normalizeLockNetwork(network)
|
||||
if isEVMNetwork(network) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
if network == mdb.NetworkTon {
|
||||
if raw, err := addressutil.TonRawAddressKey(address); err == nil {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeLockToken(token string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
if isEVMNetwork(network) {
|
||||
return tx.Where("lower(address) = ?", address)
|
||||
}
|
||||
return tx.Where("address = ?", address)
|
||||
}
|
||||
|
||||
func activeLocksForAddress(tx *gorm.DB, network, address, token string, now time.Time) *gorm.DB {
|
||||
query := tx.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", normalizeLockNetwork(network)).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("expires_at > ?", now)
|
||||
return applyLockAddressFilter(query, network, address)
|
||||
}
|
||||
|
||||
func lockMatchesAmount(lock mdb.TransactionLock, amount float64) bool {
|
||||
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||
scaledAmount, _ := normalizeAmount(amount, precision)
|
||||
return lock.AmountScaled == scaledAmount
|
||||
}
|
||||
|
||||
func lockAmountDecimal(lock mdb.TransactionLock) decimal.Decimal {
|
||||
if strings.TrimSpace(lock.AmountText) != "" {
|
||||
if value, err := decimal.NewFromString(lock.AmountText); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||
return decimal.NewFromInt(lock.AmountScaled).Shift(int32(-precision))
|
||||
}
|
||||
|
||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
@@ -58,6 +115,56 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetOrderByBlockTransactionIDs fetches the first order whose stored tx id
|
||||
// matches any equivalent spelling supplied by the caller.
|
||||
func GetOrderByBlockTransactionIDs(blockIDs []string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
seen := make(map[string]struct{}, len(blockIDs))
|
||||
candidates := make([]string, 0, len(blockIDs))
|
||||
for _, blockID := range blockIDs {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
candidates = append(candidates, blockID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return order, nil
|
||||
}
|
||||
err := dao.Mdb.Model(order).Where("block_transaction_id IN ?", candidates).Limit(1).Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetOrderByBlockTransactionIDsCaseInsensitive fetches the first order whose
|
||||
// stored tx id matches any candidate after ASCII case folding. This is used
|
||||
// only for hex-based chain tx ids; case-sensitive signatures must keep using
|
||||
// GetOrderByBlockTransactionIDs.
|
||||
func GetOrderByBlockTransactionIDsCaseInsensitive(blockIDs []string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
seen := make(map[string]struct{}, len(blockIDs))
|
||||
candidates := make([]string, 0, len(blockIDs))
|
||||
for _, blockID := range blockIDs {
|
||||
blockID = strings.ToLower(strings.TrimSpace(blockID))
|
||||
if blockID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
candidates = append(candidates, blockID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return order, nil
|
||||
}
|
||||
err := dao.Mdb.Model(order).Where("LOWER(block_transaction_id) IN ?", candidates).Limit(1).Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
@@ -102,7 +209,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
|
||||
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
||||
result := dao.Mdb.Model(mdb.Orders{}).
|
||||
Where("id = ?", id).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||
Where("created_at <= ?", expirationCutoff).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
@@ -118,6 +225,15 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountSubOrders counts all sub-orders ever created under a parent.
|
||||
func CountSubOrders(parentTradeId string) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
||||
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
@@ -142,22 +258,38 @@ func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Ord
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccess updates the parent order with the sub-order's payment details.
|
||||
// Token and network are NOT overwritten — the parent keeps its original values.
|
||||
// MarkParentOrderSuccess marks the parent order as paid and records which sub-order
|
||||
// settled it. Only the status, callback_confirm, and pay_by_sub_id fields are updated;
|
||||
// the parent's own block_transaction_id, actual_amount, and receive_address are
|
||||
// intentionally left unchanged because the parent was not directly paid.
|
||||
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
return MarkParentOrderSuccessWithTransaction(dao.Mdb, parentTradeId, sub)
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccessWithTransaction is the transactional variant of
|
||||
// MarkParentOrderSuccess.
|
||||
func MarkParentOrderSuccessWithTransaction(tx *gorm.DB, parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"block_transaction_id": sub.BlockTransactionId,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"actual_amount": sub.ActualAmount,
|
||||
"receive_address": sub.ReceiveAddress,
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"pay_by_sub_id": sub.ID,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ExpireSiblingSubOrdersWithTransaction expires all waiting sibling
|
||||
// sub-orders under the same parent in one statement.
|
||||
func ExpireSiblingSubOrdersWithTransaction(tx *gorm.DB, parentTradeId string, excludeTradeId string) error {
|
||||
return tx.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("trade_id != ?", excludeTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired).Error
|
||||
}
|
||||
|
||||
// MarkOrderSelected sets is_selected=true for the given trade_id.
|
||||
func MarkOrderSelected(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
@@ -165,6 +297,73 @@ func MarkOrderSelected(tradeId string) error {
|
||||
Update("is_selected", true).Error
|
||||
}
|
||||
|
||||
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
|
||||
// and makes it payable. It only updates status=WaitSelect rows so concurrent
|
||||
// switch-network attempts cannot overwrite a payable order.
|
||||
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusWaitSelect).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusWaitPay,
|
||||
"network": strings.ToLower(strings.TrimSpace(network)),
|
||||
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||
"receive_address": receiveAddress,
|
||||
"actual_amount": actualAmount,
|
||||
"is_selected": false,
|
||||
"created_at": time.Now(),
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
|
||||
// directly into an OkPay order. It intentionally does not create a local chain
|
||||
// lock because OkPay owns the hosted payment target.
|
||||
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusWaitSelect).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusWaitPay,
|
||||
"network": mdb.PaymentProviderOkPay,
|
||||
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||
"receive_address": "OKPAY",
|
||||
"actual_amount": actualAmount,
|
||||
"is_selected": false,
|
||||
"pay_provider": mdb.PaymentProviderOkPay,
|
||||
"created_at": time.Now(),
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
|
||||
// as selected for a hosted payment provider. It intentionally does not fill
|
||||
// chain fields or create a transaction lock; the provider child order carries
|
||||
// the concrete payment target.
|
||||
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusWaitPay,
|
||||
"is_selected": true,
|
||||
"created_at": time.Now(),
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
||||
// child-order attempts that should not remain selectable/reusable.
|
||||
func ExpireOrderByTradeID(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusExpired,
|
||||
"is_selected": false,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
|
||||
// Called on the parent order when a sub-order is created or returned.
|
||||
func RefreshOrderExpiration(tradeId string) error {
|
||||
@@ -191,6 +390,32 @@ func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetAllSubOrders returns all sub-orders (any status) under a parent order,
|
||||
// ordered by creation time ascending.
|
||||
func GetAllSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Order("created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetSubOrdersByParentTradeIds batch-fetches all sub-orders (any status)
|
||||
// whose parent_trade_id is in the given set. Ordered by parent_trade_id
|
||||
// then created_at ASC so callers can build a grouped map in one pass.
|
||||
func GetSubOrdersByParentTradeIds(parentTradeIds []string) ([]mdb.Orders, error) {
|
||||
if len(parentTradeIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id IN ?", parentTradeIds).
|
||||
Order("parent_trade_id ASC, created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// ExpireOrderByTradeId marks a single order as expired if still waiting.
|
||||
func ExpireOrderByTradeId(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
@@ -201,53 +426,66 @@ func ExpireOrderByTradeId(tradeId string) error {
|
||||
|
||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
|
||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
var lock mdb.TransactionLock
|
||||
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at > ?", time.Now()).
|
||||
Limit(1).
|
||||
Find(&lock).Error
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
var locks []mdb.TransactionLock
|
||||
err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||
Order("created_at ASC").
|
||||
Find(&locks).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lock.ID <= 0 {
|
||||
return "", nil
|
||||
for _, lock := range locks {
|
||||
if lockMatchesAmount(lock, amount) {
|
||||
return lock.TradeId, nil
|
||||
}
|
||||
}
|
||||
return lock.TradeId, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
precision := GetAmountPrecision()
|
||||
scaledAmount, amountText := normalizeAmount(amount, precision)
|
||||
normalizedToken := normalizeLockToken(token)
|
||||
now := time.Now()
|
||||
lock := &mdb.TransactionLock{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
AmountText: amountText,
|
||||
TradeId: tradeID,
|
||||
ExpiresAt: now.Add(expirationTime),
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
AmountText: amountText,
|
||||
AmountPrecision: precision,
|
||||
TradeId: tradeID,
|
||||
ExpiresAt: now.Add(expirationTime),
|
||||
}
|
||||
|
||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
expiredQuery := tx.Where("network = ?", network).
|
||||
Where("token = ?", normalizedToken).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at <= ?", now).
|
||||
Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
Where("expires_at <= ?", now)
|
||||
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var existing []mdb.TransactionLock
|
||||
if err := activeLocksForAddress(tx, network, address, normalizedToken, now).
|
||||
Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
candidateAmount := lockAmountDecimal(*lock)
|
||||
for _, item := range existing {
|
||||
if lockAmountDecimal(item).Equal(candidateAmount) {
|
||||
return ErrTransactionLocked
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -261,13 +499,23 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
||||
|
||||
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
return dao.RuntimeDB.
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Delete(&mdb.TransactionLock{}).Error
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
var locks []mdb.TransactionLock
|
||||
if err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||
Find(&locks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]uint64, 0, len(locks))
|
||||
for _, lock := range locks {
|
||||
if lockMatchesAmount(lock, amount) {
|
||||
ids = append(ids, lock.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.RuntimeDB.Where("id IN ?", ids).Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func UnLockTransactionByTradeId(tradeID string) error {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// DailyStat is one bucket of day-level aggregation used by the
|
||||
// dashboard trend charts.
|
||||
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
|
||||
// "YYYY-MM-DD HH:00" for hourly buckets.
|
||||
type DailyStat struct {
|
||||
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
|
||||
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
|
||||
}
|
||||
|
||||
// AddressDailyStat buckets actual_amount by (day, receive_address) for
|
||||
// the stacked asset-trend chart.
|
||||
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
|
||||
type AddressDailyStat struct {
|
||||
Day string `json:"day"`
|
||||
Address string `json:"address"`
|
||||
ActualAmount float64 `json:"actual_amount"`
|
||||
}
|
||||
|
||||
func statsBucketExpr(column string, hourly bool) (string, error) {
|
||||
dialect := "sqlite"
|
||||
if dao.Mdb != nil && dao.Mdb.Dialector != nil {
|
||||
dialect = dao.Mdb.Dialector.Name()
|
||||
}
|
||||
return statsBucketExprForDialect(dialect, column, hourly)
|
||||
}
|
||||
|
||||
func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) {
|
||||
switch dialect {
|
||||
case "sqlite", "":
|
||||
// modernc SQLite stores time.Time as Go's time.String() by default.
|
||||
// Prefix buckets work for that format and SQLite datetime text.
|
||||
if hourly {
|
||||
return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil
|
||||
}
|
||||
return fmt.Sprintf("substr(%s, 1, 10)", column), nil
|
||||
case "postgres":
|
||||
if hourly {
|
||||
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil
|
||||
}
|
||||
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect)
|
||||
}
|
||||
}
|
||||
|
||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||
// zero-filling any days in the range that have no orders.
|
||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||
// what the user means by "流水". Pending orders count toward OrderCount
|
||||
// to keep成交率 computable.
|
||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
rows, err := queryOrderStats(start, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||
// zero-filling any hours that have no orders.
|
||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
rows, err := queryOrderStats(start, end, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
bucket, err := statsBucketExpr("created_at", hourly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(fmt.Sprintf(`%s AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket),
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group(bucket).
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byDay := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byDay[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
key := cur.Format("2006-01-02")
|
||||
if r, ok := byDay[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillHourlyStats generates a zero-filled slice covering every whole
|
||||
// hour in [start, end]. Hours present in rows overwrite the zero entries.
|
||||
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byHour := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byHour[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endHour) {
|
||||
key := cur.Format("2006-01-02 15:00")
|
||||
if r, ok := byHour[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing days.
|
||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
rows, err := queryAssetByAddress(start, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing hours.
|
||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
rows, err := queryAssetByAddress(start, end, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
bucket, err := statsBucketExpr("created_at", hourly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(fmt.Sprintf(`%s AS day,
|
||||
receive_address AS address,
|
||||
SUM(actual_amount) AS actual_amount`, bucket)).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group(bucket).
|
||||
Group("receive_address").
|
||||
Order("day ASC, address ASC").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||
// All addresses that appear in rows are represented for every day in range.
|
||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, false)
|
||||
}
|
||||
|
||||
// fillAddressHourlyStats zero-fills missing hour×address combinations.
|
||||
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, true)
|
||||
}
|
||||
|
||||
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
|
||||
type key struct{ day, addr string }
|
||||
byKey := make(map[key]float64, len(rows))
|
||||
addrSet := make(map[string]struct{}, 4)
|
||||
for _, r := range rows {
|
||||
byKey[key{r.Day, r.Address}] = r.ActualAmount
|
||||
addrSet[r.Address] = struct{}{}
|
||||
}
|
||||
if len(addrSet) == 0 {
|
||||
return rows
|
||||
}
|
||||
var periods []string
|
||||
if hourly {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endH) {
|
||||
periods = append(periods, cur.Format("2006-01-02 15:00"))
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
} else {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
periods = append(periods, cur.Format("2006-01-02"))
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
var out []AddressDailyStat
|
||||
for _, day := range periods {
|
||||
for addr := range addrSet {
|
||||
out = append(out, AddressDailyStat{
|
||||
Day: day,
|
||||
Address: addr,
|
||||
ActualAmount: byKey[key{day, addr}],
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SumPaidActualAmount returns the total paid actual_amount across all
|
||||
// time. Used by the overview "total asset" card.
|
||||
func SumPaidActualAmount() (float64, error) {
|
||||
var sum float64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("COALESCE(SUM(actual_amount), 0)").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Scan(&sum).Error
|
||||
return sum, err
|
||||
}
|
||||
|
||||
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
|
||||
// a given time range. Caller chooses the range (today / last 7 days).
|
||||
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
|
||||
type row struct {
|
||||
OrderCount int64
|
||||
SuccessCount int64
|
||||
ActualSum float64
|
||||
}
|
||||
var r row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&r).Error
|
||||
return r.OrderCount, r.SuccessCount, r.ActualSum, err
|
||||
}
|
||||
|
||||
// ActiveAddressCountInRange returns the number of distinct wallet addresses
|
||||
// that appear in at least one order within [start, end].
|
||||
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Distinct("receive_address").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// AveragePaymentDurationSeconds returns the mean elapsed time between
|
||||
// created_at and updated_at for orders that reached StatusPaySuccess
|
||||
// in the given range. Zero-safe when no rows match.
|
||||
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
|
||||
type row struct {
|
||||
Avg float64
|
||||
}
|
||||
var r row
|
||||
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
|
||||
// columns and compute in Go — trend windows are small enough.
|
||||
type pair struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
var pairs []pair
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("created_at, updated_at").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&pairs).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
total := 0.0
|
||||
for _, p := range pairs {
|
||||
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
|
||||
}
|
||||
r.Avg = total / float64(len(pairs))
|
||||
return r.Avg, nil
|
||||
}
|
||||
|
||||
// CountExpiredInRange returns the number of StatusExpired orders in range.
|
||||
func CountExpiredInRange(start, end time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("status = ?", mdb.StatusExpired).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentOrders returns the latest N orders regardless of status.
|
||||
func RecentOrders(limit int) ([]mdb.Orders, error) {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CountEnabledChains counts chains where Enabled=true.
|
||||
func CountEnabledChains() (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/xssnick/tonutils-go/address"
|
||||
)
|
||||
|
||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-evm-case"
|
||||
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
|
||||
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != tradeID {
|
||||
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
|
||||
}
|
||||
|
||||
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
|
||||
t.Fatalf("unlock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after unlock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsBucketExprForDialect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect string
|
||||
hourly bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "sqlite daily",
|
||||
dialect: "sqlite",
|
||||
want: "substr(created_at, 1, 10)",
|
||||
},
|
||||
{
|
||||
name: "sqlite hourly",
|
||||
dialect: "sqlite",
|
||||
hourly: true,
|
||||
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
|
||||
},
|
||||
{
|
||||
name: "postgres daily",
|
||||
dialect: "postgres",
|
||||
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
|
||||
},
|
||||
{
|
||||
name: "postgres hourly",
|
||||
dialect: "postgres",
|
||||
hourly: true,
|
||||
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
|
||||
if err != nil {
|
||||
t.Fatalf("statsBucketExprForDialect error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
|
||||
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
|
||||
t.Fatal("expected unsupported mysql dialect error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 2: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-old", 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock old transaction: %v", err)
|
||||
}
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 4: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-equivalent", 1.2300, time.Hour); !errors.Is(err, ErrTransactionLocked) {
|
||||
t.Fatalf("equivalent lock error = %v, want %v", err, ErrTransactionLocked)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-new", 1.2301, time.Hour); err != nil {
|
||||
t.Fatalf("distinct precision lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionLockLookupUsesStoredPrecision(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 4: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress002", "USDT", "trade-precise", 1.2345, time.Hour); err != nil {
|
||||
t.Fatalf("lock precise transaction: %v", err)
|
||||
}
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 2: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, "TPrecisionAddress002", "USDT", 1.2345)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "trade-precise" {
|
||||
t.Fatalf("trade id = %q, want trade-precise", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-tron-case"
|
||||
address := "TCaseSensitiveAddress001"
|
||||
|
||||
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||
addr := address.MustParseAddr(bounceable)
|
||||
nonBounce := addr.Bounce(false).String()
|
||||
|
||||
if err := LockTransaction(mdb.NetworkTon, bounceable, "TON", "trade-ton", 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock ton transaction: %v", err)
|
||||
}
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, nonBounce, "TON", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup ton lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "trade-ton" {
|
||||
t.Fatalf("ton lock lookup = %q, want trade-ton", gotTradeID)
|
||||
}
|
||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup ton raw lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "trade-ton" {
|
||||
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
||||
row := new(mdb.ProviderOrder)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Limit(1).
|
||||
Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
||||
return tx.Model(row).Create(row).Error
|
||||
}
|
||||
|
||||
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"provider_order_id",
|
||||
"pay_url",
|
||||
"amount",
|
||||
"coin",
|
||||
"status",
|
||||
"notify_raw",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(row).Error
|
||||
}
|
||||
|
||||
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
|
||||
}
|
||||
|
||||
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||
return tx.Model(&mdb.ProviderOrder{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Updates(map[string]interface{}{
|
||||
"provider_order_id": providerOrderID,
|
||||
"pay_url": payURL,
|
||||
"status": mdb.ProviderOrderStatusPending,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func MarkProviderOrderFailed(tradeID string, provider string) error {
|
||||
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Update("status", mdb.ProviderOrderStatusFailed).Error
|
||||
}
|
||||
|
||||
func SaveProviderOrderNotify(tradeID string, provider string, notifyRaw string) error {
|
||||
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Update("notify_raw", notifyRaw).Error
|
||||
}
|
||||
|
||||
func MarkProviderOrderPaid(tradeID string, provider string, notifyRaw string) error {
|
||||
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.ProviderOrderStatusPaid,
|
||||
"notify_raw": notifyRaw,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func MarkProviderOrderExpired(tradeID string, provider string) error {
|
||||
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("provider = ?", provider).
|
||||
Where("status IN ?", []string{mdb.ProviderOrderStatusCreating, mdb.ProviderOrderStatusPending}).
|
||||
Update("status", mdb.ProviderOrderStatusExpired).Error
|
||||
}
|
||||
|
||||
func GetSubOrderByTokenPayProvider(parentTradeID string, token string, payProvider string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
err := dao.Mdb.Model(order).
|
||||
Where("parent_trade_id = ?", parentTradeID).
|
||||
Where("token = ?", token).
|
||||
Where("pay_provider = ?", payProvider).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Limit(1).
|
||||
Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcFailoverThreshold = 3
|
||||
RpcFailoverCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
var gRpcFailover = struct {
|
||||
sync.Mutex
|
||||
failures map[uint64]int
|
||||
cooldownUntil map[uint64]time.Time
|
||||
}{
|
||||
failures: make(map[uint64]int),
|
||||
cooldownUntil: make(map[uint64]time.Time),
|
||||
}
|
||||
|
||||
func NormalizeRpcNodePurpose(purpose string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(purpose)) {
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
return mdb.RpcNodePurposeManualVerify
|
||||
case mdb.RpcNodePurposeBoth:
|
||||
return mdb.RpcNodePurposeBoth
|
||||
default:
|
||||
return mdb.RpcNodePurposeGeneral
|
||||
}
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
tx := dao.Mdb.Model(&mdb.RpcNode{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListRpcNodesForHealth returns nodes that should be probed by the periodic
|
||||
// health job. Manual verification nodes are intentionally excluded because
|
||||
// they may be paid/high-limit endpoints that should only be used on demand.
|
||||
func ListRpcNodesForHealth() ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListGeneralRpcCandidates returns enabled non-manual nodes for ordinary
|
||||
// scanners/listeners. Down nodes are skipped; ok nodes are ordered before
|
||||
// unknown/bootstrap nodes.
|
||||
func ListGeneralRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||
Where("enabled = ?", true).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buckets := make([][]mdb.RpcNode, 2)
|
||||
for _, row := range rows {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], row)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], row)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, bucket := range buckets {
|
||||
sortRpcNodes(bucket)
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SelectGeneralRpcNode(network, nodeType string, excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||
rows, err := ListGeneralRpcCandidates(network, nodeType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
excluded := make(map[uint64]struct{}, len(excludeIDs))
|
||||
for _, id := range excludeIDs {
|
||||
if id > 0 {
|
||||
excluded[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
if _, ok := excluded[rows[i].ID]; ok {
|
||||
continue
|
||||
}
|
||||
if IsRpcNodeCoolingDown(rows[i].ID) {
|
||||
continue
|
||||
}
|
||||
return &rows[i], nil
|
||||
}
|
||||
if len(excluded) == 0 {
|
||||
for i := range rows {
|
||||
return &rows[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetRpcNodeByID fetches one row.
|
||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
row := new(mdb.RpcNode)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row.
|
||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateRpcNodeFields patches mutable columns.
|
||||
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if purpose, ok := fields["purpose"].(string); ok {
|
||||
fields["purpose"] = NormalizeRpcNodePurpose(purpose)
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteRpcNodeByID soft-deletes the row.
|
||||
func DeleteRpcNodeByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).Error
|
||||
}
|
||||
|
||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||
// Falls back to enabled rows with status=unknown when no health check has
|
||||
// run yet. Explicitly down rows are not selected. Manual verification nodes
|
||||
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
|
||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("type = ?", strings.ToLower(nodeType)).
|
||||
Where("enabled = ?", true).
|
||||
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
healthy := make([]mdb.RpcNode, 0, len(rows))
|
||||
bootstrap := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch r.Status {
|
||||
case mdb.RpcNodeStatusOk:
|
||||
healthy = append(healthy, r)
|
||||
case "", mdb.RpcNodeStatusUnknown:
|
||||
bootstrap = append(bootstrap, r)
|
||||
}
|
||||
}
|
||||
candidates := healthy
|
||||
if len(candidates) == 0 {
|
||||
candidates = bootstrap
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return pickWeighted(candidates), nil
|
||||
}
|
||||
|
||||
// ListManualPaymentRpcCandidates returns enabled RPC nodes for manual payment
|
||||
// verification. Ordinary RPCs are tried first to avoid paid endpoint usage;
|
||||
// manual_verify nodes are used only as a fallback.
|
||||
func ListManualPaymentRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||
Where("enabled = ?", true).
|
||||
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
buckets := make([][]mdb.RpcNode, 4)
|
||||
for _, row := range rows {
|
||||
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||
switch row.Purpose {
|
||||
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[0] = append(buckets[0], row)
|
||||
} else {
|
||||
buckets[1] = append(buckets[1], row)
|
||||
}
|
||||
case mdb.RpcNodePurposeManualVerify:
|
||||
if row.Status == mdb.RpcNodeStatusOk {
|
||||
buckets[2] = append(buckets[2], row)
|
||||
} else {
|
||||
buckets[3] = append(buckets[3], row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, bucket := range buckets {
|
||||
sortRpcNodes(bucket)
|
||||
out = append(out, bucket...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortRpcNodes(rows []mdb.RpcNode) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Weight != rows[j].Weight {
|
||||
return rows[i].Weight > rows[j].Weight
|
||||
}
|
||||
return rows[i].ID < rows[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func RecordRpcNodeSuccess(nodeID uint64) {
|
||||
if nodeID == 0 {
|
||||
return
|
||||
}
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
}
|
||||
|
||||
func RecordRpcNodeFailure(nodeID uint64) (int, bool) {
|
||||
if nodeID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
now := time.Now()
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
if until, ok := gRpcFailover.cooldownUntil[nodeID]; ok {
|
||||
if now.Before(until) {
|
||||
return gRpcFailover.failures[nodeID], true
|
||||
}
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
}
|
||||
|
||||
gRpcFailover.failures[nodeID]++
|
||||
failures := gRpcFailover.failures[nodeID]
|
||||
if failures >= RpcFailoverThreshold {
|
||||
gRpcFailover.failures[nodeID] = 0
|
||||
gRpcFailover.cooldownUntil[nodeID] = now.Add(RpcFailoverCooldown)
|
||||
return failures, true
|
||||
}
|
||||
return failures, false
|
||||
}
|
||||
|
||||
func IsRpcNodeCoolingDown(nodeID uint64) bool {
|
||||
if nodeID == 0 {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
until, ok := gRpcFailover.cooldownUntil[nodeID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if now.Before(until) {
|
||||
return true
|
||||
}
|
||||
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||
delete(gRpcFailover.failures, nodeID)
|
||||
return false
|
||||
}
|
||||
|
||||
func RpcNodeLogLabel(node mdb.RpcNode) string {
|
||||
return fmt.Sprintf("id=%d network=%s type=%s endpoint=%s", node.ID, node.Network, node.Type, safeRpcEndpoint(node.Url))
|
||||
}
|
||||
|
||||
func safeRpcEndpoint(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil && parsed.Host != "" {
|
||||
if parsed.Scheme != "" {
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
return parsed.Host
|
||||
}
|
||||
cut := strings.IndexAny(raw, "/?")
|
||||
if cut >= 0 {
|
||||
return raw[:cut]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func ResetRpcFailoverForTest() {
|
||||
gRpcFailover.Lock()
|
||||
defer gRpcFailover.Unlock()
|
||||
gRpcFailover.failures = make(map[uint64]int)
|
||||
gRpcFailover.cooldownUntil = make(map[uint64]time.Time)
|
||||
}
|
||||
|
||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||
// to truly disable).
|
||||
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
|
||||
total := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
total += w
|
||||
}
|
||||
if total <= 0 {
|
||||
return &rows[0]
|
||||
}
|
||||
pick := rand.Intn(total)
|
||||
acc := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if pick < acc {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return &rows[len(rows)-1]
|
||||
}
|
||||
|
||||
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
|
||||
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latencyMs,
|
||||
"last_checked_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeExcludesManualVerifyRows(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://manual.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://general.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed general rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://general.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want general row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodePurposeFilterAppliesToAnyNodeType(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const nodeType = "grpc"
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "grpc://manual.example.com",
|
||||
Type: nodeType,
|
||||
Weight: 100,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "grpc://general.example.com",
|
||||
Type: nodeType,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed general rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, nodeType)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "grpc://general.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want general grpc row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeManualVerifyOnlyReturnsNil(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://manual-only.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed manual rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListManualPaymentRpcCandidatesGeneralBeforeManualVerify(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://general-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusDown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||
}
|
||||
var urls []string
|
||||
for _, row := range got {
|
||||
urls = append(urls, row.Url)
|
||||
}
|
||||
want := []string{
|
||||
"https://general-ok.example.com",
|
||||
"https://general-unknown.example.com",
|
||||
"https://manual-ok.example.com",
|
||||
"https://manual-unknown.example.com",
|
||||
}
|
||||
if len(urls) != len(want) {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if urls[i] != want[i] {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListManualPaymentRpcCandidatesAllowsManualVerifyFallbackOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual-down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusDown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Url != "https://manual-ok.example.com" {
|
||||
t.Fatalf("candidate rows = %#v, want only manual ok fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeSkipsCoolingNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
first, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if first == nil || first.Url != "https://primary.example.com" {
|
||||
t.Fatalf("first node = %#v, want primary", first)
|
||||
}
|
||||
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(first.ID)
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://backup.example.com" {
|
||||
t.Fatalf("node after cooldown = %#v, want backup", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeFallsBackWhenAllNodesCooling(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
for i := range rows {
|
||||
for n := 0; n < RpcFailoverThreshold; n++ {
|
||||
RecordRpcNodeFailure(rows[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://primary.example.com" {
|
||||
t.Fatalf("SelectGeneralRpcNode() = %#v, want primary when every node is cooling", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGeneralRpcNodeReturnsNilWhenExcludedAlternatesCooling(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(rows[1].ID)
|
||||
}
|
||||
|
||||
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("SelectGeneralRpcNode() = %#v, want nil when only alternate is cooling", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRpcNodeSuccessClearsCooldown(t *testing.T) {
|
||||
ResetRpcFailoverForTest()
|
||||
t.Cleanup(ResetRpcFailoverForTest)
|
||||
|
||||
const nodeID uint64 = 42
|
||||
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||
RecordRpcNodeFailure(nodeID)
|
||||
}
|
||||
if !IsRpcNodeCoolingDown(nodeID) {
|
||||
t.Fatal("node should be cooling after threshold")
|
||||
}
|
||||
RecordRpcNodeSuccess(nodeID)
|
||||
if IsRpcNodeCoolingDown(nodeID) {
|
||||
t.Fatal("node should not be cooling after success")
|
||||
}
|
||||
failures, cooling := RecordRpcNodeFailure(nodeID)
|
||||
if cooling || failures != 1 {
|
||||
t.Fatalf("failure state after success = failures=%d cooling=%v, want failures=1 cooling=false", failures, cooling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRpcNodeLogLabelHidesPathAndQuery(t *testing.T) {
|
||||
label := RpcNodeLogLabel(mdb.RpcNode{
|
||||
BaseModel: mdb.BaseModel{ID: 7},
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeWs,
|
||||
Url: "wss://rpc.example.com/v3/secret-key?token=secret",
|
||||
})
|
||||
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||
t.Fatalf("RpcNodeLogLabel() leaked sensitive URL parts: %s", label)
|
||||
}
|
||||
if !strings.Contains(label, "wss://rpc.example.com") {
|
||||
t.Fatalf("RpcNodeLogLabel() = %s, want host", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRpcNodesForHealthExcludesManualVerify(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://general.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkTron, Url: "https://manual.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
got, err := ListRpcNodesForHealth()
|
||||
if err != nil {
|
||||
t.Fatalf("ListRpcNodesForHealth(): %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Url != "https://general.example.com" {
|
||||
t.Fatalf("ListRpcNodesForHealth() = %#v, want only general row", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RpcRuntimeStatsSnapshot is an in-process snapshot of RPC activity for one
|
||||
// chain. It is intentionally not persisted; restarting the process resets it.
|
||||
type RpcRuntimeStatsSnapshot struct {
|
||||
Network string
|
||||
SuccessCount int64
|
||||
FailureCount int64
|
||||
LatestBlockHeight int64
|
||||
LastSyncAt time.Time
|
||||
}
|
||||
|
||||
type rpcRuntimeStatsCounter struct {
|
||||
successCount int64
|
||||
failureCount int64
|
||||
latestBlockHeight int64
|
||||
lastSyncAt time.Time
|
||||
}
|
||||
|
||||
var gRpcRuntimeStats = struct {
|
||||
sync.RWMutex
|
||||
byNetwork map[string]*rpcRuntimeStatsCounter
|
||||
}{
|
||||
byNetwork: make(map[string]*rpcRuntimeStatsCounter),
|
||||
}
|
||||
|
||||
func normalizeRpcStatsNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func rpcStatsCounterLocked(network string) *rpcRuntimeStatsCounter {
|
||||
counter := gRpcRuntimeStats.byNetwork[network]
|
||||
if counter == nil {
|
||||
counter = &rpcRuntimeStatsCounter{}
|
||||
gRpcRuntimeStats.byNetwork[network] = counter
|
||||
}
|
||||
return counter
|
||||
}
|
||||
|
||||
// RecordRpcSuccess records one successful application-level RPC operation.
|
||||
func RecordRpcSuccess(network string) {
|
||||
network = normalizeRpcStatsNetwork(network)
|
||||
if network == "" {
|
||||
return
|
||||
}
|
||||
gRpcRuntimeStats.Lock()
|
||||
defer gRpcRuntimeStats.Unlock()
|
||||
counter := rpcStatsCounterLocked(network)
|
||||
counter.successCount++
|
||||
counter.lastSyncAt = time.Now()
|
||||
}
|
||||
|
||||
// RecordRpcFailure records one failed application-level RPC operation.
|
||||
func RecordRpcFailure(network string) {
|
||||
network = normalizeRpcStatsNetwork(network)
|
||||
if network == "" {
|
||||
return
|
||||
}
|
||||
gRpcRuntimeStats.Lock()
|
||||
defer gRpcRuntimeStats.Unlock()
|
||||
counter := rpcStatsCounterLocked(network)
|
||||
counter.failureCount++
|
||||
}
|
||||
|
||||
// RecordRpcBlockHeight records the latest observed chain height.
|
||||
func RecordRpcBlockHeight(network string, height int64) {
|
||||
network = normalizeRpcStatsNetwork(network)
|
||||
if network == "" || height <= 0 {
|
||||
return
|
||||
}
|
||||
gRpcRuntimeStats.Lock()
|
||||
defer gRpcRuntimeStats.Unlock()
|
||||
counter := rpcStatsCounterLocked(network)
|
||||
counter.lastSyncAt = time.Now()
|
||||
if height > counter.latestBlockHeight {
|
||||
counter.latestBlockHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotRpcRuntimeStats returns a copy of the current per-chain counters.
|
||||
func SnapshotRpcRuntimeStats() map[string]RpcRuntimeStatsSnapshot {
|
||||
gRpcRuntimeStats.RLock()
|
||||
defer gRpcRuntimeStats.RUnlock()
|
||||
out := make(map[string]RpcRuntimeStatsSnapshot, len(gRpcRuntimeStats.byNetwork))
|
||||
for network, counter := range gRpcRuntimeStats.byNetwork {
|
||||
out[network] = RpcRuntimeStatsSnapshot{
|
||||
Network: network,
|
||||
SuccessCount: counter.successCount,
|
||||
FailureCount: counter.failureCount,
|
||||
LatestBlockHeight: counter.latestBlockHeight,
|
||||
LastSyncAt: counter.lastSyncAt,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResetRpcRuntimeStatsForTest clears process-local stats for isolated tests.
|
||||
func ResetRpcRuntimeStatsForTest() {
|
||||
gRpcRuntimeStats.Lock()
|
||||
defer gRpcRuntimeStats.Unlock()
|
||||
gRpcRuntimeStats.byNetwork = make(map[string]*rpcRuntimeStatsCounter)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRpcRuntimeStatsRecordsCountsAndLatestHeight(t *testing.T) {
|
||||
ResetRpcRuntimeStatsForTest()
|
||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||
|
||||
start := time.Now()
|
||||
RecordRpcSuccess(" TRON ")
|
||||
RecordRpcSuccess("tron")
|
||||
RecordRpcFailure("TRON")
|
||||
RecordRpcBlockHeight("Tron", 100)
|
||||
RecordRpcBlockHeight("tron", 90)
|
||||
|
||||
stats := SnapshotRpcRuntimeStats()
|
||||
got, ok := stats["tron"]
|
||||
if !ok {
|
||||
t.Fatalf("missing tron stats: %#v", stats)
|
||||
}
|
||||
if got.Network != "tron" {
|
||||
t.Fatalf("network = %q, want tron", got.Network)
|
||||
}
|
||||
if got.SuccessCount != 2 {
|
||||
t.Fatalf("success count = %d, want 2", got.SuccessCount)
|
||||
}
|
||||
if got.FailureCount != 1 {
|
||||
t.Fatalf("failure count = %d, want 1", got.FailureCount)
|
||||
}
|
||||
if got.LatestBlockHeight != 100 {
|
||||
t.Fatalf("latest block height = %d, want 100", got.LatestBlockHeight)
|
||||
}
|
||||
if got.LastSyncAt.Before(start) {
|
||||
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRpcRuntimeStatsIgnoresEmptyNetworkAndInvalidHeight(t *testing.T) {
|
||||
ResetRpcRuntimeStatsForTest()
|
||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||
|
||||
RecordRpcSuccess("")
|
||||
RecordRpcFailure(" ")
|
||||
RecordRpcBlockHeight("tron", 0)
|
||||
|
||||
stats := SnapshotRpcRuntimeStats()
|
||||
if len(stats) != 0 {
|
||||
t.Fatalf("stats = %#v, want empty", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRpcRuntimeStatsBlockHeightUpdatesLastSyncAt(t *testing.T) {
|
||||
ResetRpcRuntimeStatsForTest()
|
||||
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||
|
||||
start := time.Now()
|
||||
RecordRpcBlockHeight("ethereum", 123)
|
||||
|
||||
stats := SnapshotRpcRuntimeStats()
|
||||
got := stats["ethereum"]
|
||||
if got.SuccessCount != 0 {
|
||||
t.Fatalf("success count = %d, want 0", got.SuccessCount)
|
||||
}
|
||||
if got.LastSyncAt.Before(start) {
|
||||
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetRpcRuntimeStatsForTest(t *testing.T) {
|
||||
ResetRpcRuntimeStatsForTest()
|
||||
RecordRpcSuccess("tron")
|
||||
ResetRpcRuntimeStatsForTest()
|
||||
|
||||
stats := SnapshotRpcRuntimeStats()
|
||||
if len(stats) != 0 {
|
||||
t.Fatalf("stats = %#v, want empty after reset", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Simple in-process cache. Settings are read frequently (on every order
|
||||
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
|
||||
// is invalidated explicitly via SetString/Delete and expires on process
|
||||
// restart — that's enough for admin-edit semantics without TTL complexity.
|
||||
var (
|
||||
settingsCache = map[string]string{}
|
||||
settingsCacheMu sync.RWMutex
|
||||
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
|
||||
settingsLoaded bool
|
||||
)
|
||||
|
||||
func loadAllSettings() error {
|
||||
var rows []mdb.Setting
|
||||
if err := dao.Mdb.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
defer settingsCacheMu.Unlock()
|
||||
settingsCache = make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
settingsCache[r.Key] = r.Value
|
||||
}
|
||||
settingsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
|
||||
// a dedicated mutex + double-checked flag so two concurrent callers
|
||||
// don't both hit the DB. Can't use sync.Once because ReloadSettings
|
||||
// needs to be able to rebuild after a failed initial load.
|
||||
func ensureLoaded() {
|
||||
settingsCacheMu.RLock()
|
||||
loaded := settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
// Recheck under the load mutex — another goroutine may have loaded
|
||||
// while we were blocked.
|
||||
settingsCacheMu.RLock()
|
||||
loaded = settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
_ = loadAllSettings()
|
||||
}
|
||||
|
||||
// ReloadSettings forces a refresh from DB. Call after external writes.
|
||||
func ReloadSettings() error {
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
return loadAllSettings()
|
||||
}
|
||||
|
||||
// GetSettingString returns the string value for key, or fallback if unset.
|
||||
func GetSettingString(key, fallback string) string {
|
||||
ensureLoaded()
|
||||
settingsCacheMu.RLock()
|
||||
defer settingsCacheMu.RUnlock()
|
||||
v, ok := settingsCache[key]
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
|
||||
func GetSettingInt(key string, fallback int) int {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
|
||||
func GetSettingFloat(key string, fallback float64) float64 {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
|
||||
func GetSettingBool(key string, fallback bool) bool {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultAmountPrecision = 2
|
||||
MinAmountPrecision = 2
|
||||
MaxAmountPrecision = 6
|
||||
)
|
||||
|
||||
// NormalizeAmountPrecision clamps external precision values to the supported
|
||||
// range used by order creation and transaction matching.
|
||||
func NormalizeAmountPrecision(precision int) int {
|
||||
if precision < MinAmountPrecision || precision > MaxAmountPrecision {
|
||||
return DefaultAmountPrecision
|
||||
}
|
||||
return precision
|
||||
}
|
||||
|
||||
func GetAmountPrecision() int {
|
||||
return NormalizeAmountPrecision(GetSettingInt(mdb.SettingKeyAmountPrecision, DefaultAmountPrecision))
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||
func SetSetting(group, key, value, valueType string) error {
|
||||
if valueType == "" {
|
||||
valueType = mdb.SettingTypeString
|
||||
}
|
||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||
updates := clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"})
|
||||
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: updates,
|
||||
}).Create(&row).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[key] = value
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes a setting row and drops the cache entry.
|
||||
func DeleteSetting(key string) error {
|
||||
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, key)
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
||||
var sensitiveSettingKeys = []string{
|
||||
mdb.SettingKeyJwtSecret,
|
||||
mdb.SettingKeyInitAdminPasswordPlain,
|
||||
mdb.SettingKeyInitAdminPasswordHash,
|
||||
mdb.SettingKeyInitAdminPasswordFetched,
|
||||
mdb.SettingKeyInitAdminPasswordChanged,
|
||||
}
|
||||
|
||||
func getFirstNonEmptySetting(fallback string, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(GetSettingString(key, ""))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func GetBrandCashierName() string {
|
||||
return getFirstNonEmptySetting("", mdb.SettingKeyBrandCheckoutName, mdb.SettingKeyBrandSiteName)
|
||||
}
|
||||
|
||||
func GetBrandLogoURL() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandLogoUrl, ""))
|
||||
}
|
||||
|
||||
func GetBrandWebsiteTitle() string {
|
||||
return getFirstNonEmptySetting("", mdb.SettingKeyBrandSiteTitle, mdb.SettingKeyBrandPageTitle)
|
||||
}
|
||||
|
||||
func GetBrandSupportURL() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandSupportUrl, ""))
|
||||
}
|
||||
|
||||
func GetBrandBackgroundColor() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundColor, ""))
|
||||
}
|
||||
|
||||
func GetBrandBackgroundImageURL() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundImageUrl, ""))
|
||||
}
|
||||
|
||||
// OkPay settings helpers keep the provider-specific defaults in one place so
|
||||
// business logic does not need to repeat raw settings keys.
|
||||
func GetOkPayEnabled() bool {
|
||||
return GetSettingBool(mdb.SettingKeyOkPayEnabled, false)
|
||||
}
|
||||
|
||||
func GetOkPayShopID() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopID, ""))
|
||||
}
|
||||
|
||||
func GetOkPayShopToken() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopToken, ""))
|
||||
}
|
||||
|
||||
func GetOkPayAPIURL() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayAPIURL, "https://api.okaypay.me/shop/"))
|
||||
}
|
||||
|
||||
func GetOkPayCallbackURL() string {
|
||||
if configured := strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayCallbackURL, "")); configured != "" {
|
||||
return configured
|
||||
}
|
||||
appURI := strings.TrimSpace(config.GetAppUri())
|
||||
if appURI == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(appURI, "/") + "/payments/okpay/v1/notify"
|
||||
}
|
||||
|
||||
func GetOkPayReturnURL() string {
|
||||
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayReturnURL, ""))
|
||||
}
|
||||
|
||||
func GetOkPayTimeoutSeconds() int {
|
||||
return GetSettingInt(mdb.SettingKeyOkPayTimeoutSeconds, 10)
|
||||
}
|
||||
|
||||
func GetOkPayAllowTokens() []string {
|
||||
raw := GetSettingString(mdb.SettingKeyOkPayAllowTokens, "USDT,TRX")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{"USDT", "TRX"}
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
token := strings.ToUpper(strings.TrimSpace(part))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, token)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"USDT", "TRX"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||
// excluding any keys in sensitiveSettingKeys.
|
||||
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
||||
var rows []mdb.Setting
|
||||
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
|
||||
if group != "" {
|
||||
tx = tx.Where("`group` = ?", group)
|
||||
}
|
||||
err := tx.Order("`key` ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
)
|
||||
|
||||
func normalizeNetworkToken(network, token string) (string, string) {
|
||||
return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.SupportedAssetAlreadyExists
|
||||
}
|
||||
|
||||
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.ID > 0 && deleted.DeletedAt.Valid {
|
||||
deleted.Status = status
|
||||
deleted.DeletedAt.Valid = false
|
||||
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
asset := &mdb.SupportedAsset{
|
||||
Network: network,
|
||||
Token: token,
|
||||
Status: status,
|
||||
}
|
||||
err = dao.Mdb.Create(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Unscoped().Model(asset).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", token).
|
||||
Limit(1).Find(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Model(asset).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", token).
|
||||
Limit(1).Find(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) {
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
|
||||
asset, err := GetSupportedAssetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asset.ID <= 0 {
|
||||
return nil, constant.SupportedAssetNotFound
|
||||
}
|
||||
|
||||
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist.ID > 0 && exist.ID != id {
|
||||
return nil, constant.SupportedAssetAlreadyExists
|
||||
}
|
||||
|
||||
asset.Network = network
|
||||
asset.Token = token
|
||||
asset.Status = status
|
||||
if err := dao.Mdb.Save(asset).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func DeleteSupportedAssetByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error
|
||||
}
|
||||
|
||||
func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
var list []mdb.SupportedAsset
|
||||
tx := dao.Mdb.Model(&mdb.SupportedAsset{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", network)
|
||||
}
|
||||
err := tx.Order("network asc, token asc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) {
|
||||
var list []mdb.SupportedAsset
|
||||
err := dao.Mdb.Model(&mdb.SupportedAsset{}).
|
||||
Where("status = ?", mdb.TokenStatusEnable).
|
||||
Order("network asc, token asc").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package data
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
)
|
||||
|
||||
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
|
||||
@@ -22,14 +23,34 @@ func isEVMNetwork(network string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeWalletNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeWalletAddressByNetwork(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeWalletNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = strings.TrimSpace(address)
|
||||
if network == mdb.NetworkTon {
|
||||
return addressutil.NormalizeTonAddress(address)
|
||||
}
|
||||
return normalizeWalletAddressByNetwork(network, address), nil
|
||||
}
|
||||
|
||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
address = strings.TrimSpace(address)
|
||||
|
||||
// evm 网络地址统一小写,tron 和 solana 保持原样
|
||||
if isEVMNetwork(network) {
|
||||
address = strings.ToLower(address)
|
||||
network = normalizeWalletNetwork(network)
|
||||
var err error
|
||||
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||
@@ -39,6 +60,23 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.WalletAddressAlreadyExists
|
||||
}
|
||||
|
||||
// Check for a soft-deleted record with the same (network, address) and restore it.
|
||||
deleted := new(mdb.WalletAddress)
|
||||
err = dao.Mdb.Unscoped().
|
||||
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
|
||||
Limit(1).Find(deleted).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.ID > 0 {
|
||||
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"status": mdb.TokenStatusEnable,
|
||||
}).Error
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
walletAddress := &mdb.WalletAddress{
|
||||
Network: network,
|
||||
Address: address,
|
||||
@@ -50,8 +88,14 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
||||
|
||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var err error
|
||||
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).
|
||||
err = dao.Mdb.Model(walletAddress).
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Limit(1).Find(walletAddress).Error
|
||||
@@ -87,11 +131,26 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
|
||||
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
|
||||
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).
|
||||
Where("status = ?", mdb.TokenStatusEnable).
|
||||
Where("network = ?", network).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
} else if network == mdb.NetworkTon {
|
||||
for i := range list {
|
||||
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||
list[i].Address = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
@@ -104,8 +163,23 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
|
||||
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
|
||||
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
} else if network == mdb.NetworkTon {
|
||||
for i := range list {
|
||||
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||
list[i].Address = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/xssnick/tonutils-go/address"
|
||||
)
|
||||
|
||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
|
||||
if err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
if row.Address != strings.ToLower(input) {
|
||||
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
|
||||
}
|
||||
|
||||
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
|
||||
if err != nil {
|
||||
t.Fatalf("load wallet by mixed-case address: %v", err)
|
||||
}
|
||||
if loaded.ID == 0 {
|
||||
t.Fatal("expected to find wallet by mixed-case query")
|
||||
}
|
||||
if loaded.Address != strings.ToLower(input) {
|
||||
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixed,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
|
||||
if err != nil {
|
||||
t.Fatalf("list wallets: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("wallet count = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Address != strings.ToLower(mixed) {
|
||||
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tronAddress := "TCaseSensitiveTronAddress001"
|
||||
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if tronRow.Address != tronAddress {
|
||||
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
|
||||
}
|
||||
|
||||
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
|
||||
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add solana wallet: %v", err)
|
||||
}
|
||||
if solRow.Address != solAddress {
|
||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||
addr := address.MustParseAddr(bounceable)
|
||||
expected := addr.Bounce(false).String()
|
||||
|
||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
||||
if err != nil {
|
||||
t.Fatalf("add raw ton wallet: %v", err)
|
||||
}
|
||||
if row.Address != expected {
|
||||
t.Fatalf("stored TON address = %q, want %q", row.Address, expected)
|
||||
}
|
||||
if _, err = AddWalletAddressWithNetwork(mdb.NetworkTon, bounceable); err != constant.WalletAddressAlreadyExists {
|
||||
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
AdminUserStatusEnable = 1
|
||||
AdminUserStatusDisable = 2
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
Username string `gorm:"column:username;uniqueIndex:admin_users_username_uindex;size:64" json:"username" example:"admin"`
|
||||
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
LastLoginAt carbon.Time `gorm:"column:last_login_at" json:"last_login_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *AdminUser) TableName() string {
|
||||
return "admin_users"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
ApiKeyStatusEnable = 1
|
||||
ApiKeyStatusDisable = 2
|
||||
)
|
||||
|
||||
// ApiKey stores a universal merchant credential (PID + secret) that is
|
||||
// valid for both EPAY and GMPAY payment flows. Identification at
|
||||
// request time is always by PID; the gateway flow is chosen by the
|
||||
// route (not by any property of the row).
|
||||
type ApiKey struct {
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"My API Key"`
|
||||
Pid string `gorm:"column:pid;size:128;uniqueIndex:api_keys_pid_uindex" json:"pid" example:"1000"`
|
||||
SecretKey string `gorm:"column:secret_key;size:255" json:"-"`
|
||||
IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"`
|
||||
NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"`
|
||||
LastUsedAt carbon.Time `gorm:"column:last_used_at" json:"last_used_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *ApiKey) TableName() string {
|
||||
return "api_keys"
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
)
|
||||
|
||||
type BaseModel struct {
|
||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package mdb
|
||||
|
||||
// Chain represents an enabled blockchain network.
|
||||
// Scanner tasks read this table on each polling tick to decide whether to
|
||||
// process the network. When Enabled=false the scanner skips the chain
|
||||
// entirely without restarting.
|
||||
type Chain struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:chains_network_uindex;size:32" json:"network" example:"tron"`
|
||||
DisplayName string `gorm:"column:display_name;size:64" json:"display_name" example:"Tron"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinConfirmations int `gorm:"column:min_confirmations;default:1" json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec int `gorm:"column:scan_interval_sec;default:5" json:"scan_interval_sec" example:"5"`
|
||||
Extra string `gorm:"column:extra;type:text" json:"extra" example:"{}"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *Chain) TableName() string {
|
||||
return "chains"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
ChainTokenStatusEnable = 1
|
||||
ChainTokenStatusDisable = 2
|
||||
)
|
||||
|
||||
// ChainToken describes a token to watch on a given chain. Replaces the
|
||||
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
|
||||
// load this table on startup (and refresh periodically) to learn which
|
||||
// contracts to subscribe to.
|
||||
type ChainToken struct {
|
||||
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
|
||||
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
|
||||
ContractAddress string `gorm:"column:contract_address;size:128" json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `gorm:"column:decimals;default:6" json:"decimals" example:"6"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinAmount float64 `gorm:"column:min_amount;default:0" json:"min_amount" example:"1.0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *ChainToken) TableName() string {
|
||||
return "chain_tokens"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
NotificationTypeTelegram = "telegram"
|
||||
NotificationTypeWebhook = "webhook"
|
||||
NotificationTypeEmail = "email"
|
||||
)
|
||||
|
||||
const (
|
||||
NotificationStatusEnable = 1
|
||||
NotificationStatusDisable = 2
|
||||
)
|
||||
|
||||
// Notification events (JSON keys in Events column).
|
||||
const (
|
||||
NotifyEventPaySuccess = "pay_success"
|
||||
NotifyEventOrderExpired = "order_expired"
|
||||
NotifyEventDailyReport = "daily_report"
|
||||
)
|
||||
|
||||
// NotificationChannel represents one push target instance.
|
||||
// Config/Events are JSON strings consumed by the notify dispatcher.
|
||||
// For telegram, Config = {"bot_token":"...","chat_id":"...","proxy":"..."}.
|
||||
// For webhook, Config = {"url":"...","headers":{...},"secret":"..."}.
|
||||
type NotificationChannel struct {
|
||||
Type string `gorm:"column:type;size:32;index:notification_channels_type_enabled_index,priority:1" json:"type" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"主通知群"`
|
||||
Config string `gorm:"column:config;type:text" json:"config" example:"{\"bot_token\":\"123:ABC\",\"chat_id\":\"456\"}"`
|
||||
Events string `gorm:"column:events;type:text" json:"events" example:"{\"pay_success\":true,\"order_expired\":true}"`
|
||||
Enabled bool `gorm:"column:enabled;default:true;index:notification_channels_type_enabled_index,priority:2" json:"enabled" example:"true"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (n *NotificationChannel) TableName() string {
|
||||
return "notification_channels"
|
||||
}
|
||||
+61
-20
@@ -1,36 +1,77 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
StatusWaitPay = 1
|
||||
StatusPaySuccess = 2
|
||||
StatusExpired = 3
|
||||
StatusWaitPay = 1
|
||||
StatusPaySuccess = 2
|
||||
StatusExpired = 3
|
||||
// StatusWaitSelect is a placeholder order waiting for the payer to choose
|
||||
// token+network or a hosted provider such as OkPay. It is not payable until
|
||||
// switch-network moves it to StatusWaitPay.
|
||||
StatusWaitSelect = 4
|
||||
CallBackConfirmOk = 1
|
||||
CallBackConfirmNo = 2
|
||||
)
|
||||
|
||||
const (
|
||||
// PaymentTypeEpay is the normalized special payment_type value. Orders
|
||||
// matching it case-insensitively use the legacy EPay callback shape.
|
||||
PaymentTypeEpay = "Epay"
|
||||
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
|
||||
// It uses the default GMPay JSON callback shape.
|
||||
PaymentTypeGmpay = "Gmpay"
|
||||
)
|
||||
|
||||
const (
|
||||
// PaymentProviderOnChain means this concrete order record is settled by
|
||||
// sending funds to a directly assigned wallet address on a supported chain.
|
||||
PaymentProviderOnChain = "on_chain"
|
||||
// PaymentProviderOkPay means this concrete order record is settled through
|
||||
// the third-party OkayPay/OkPay hosted checkout flow.
|
||||
//
|
||||
// It can be used by a status=4 parent completed in place on first
|
||||
// switch-network, or by a switch-network-created child order after the
|
||||
// parent is already concrete. Merchant callback shape is still controlled by
|
||||
// PaymentType, not by this provider value.
|
||||
PaymentProviderOkPay = "okpay"
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
|
||||
Amount float64 `gorm:"column:amount" json:"amount"`
|
||||
Currency string `gorm:"column:currency" json:"currency"`
|
||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
|
||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
|
||||
Token string `gorm:"column:token" json:"token"`
|
||||
Network string `gorm:"column:network" json:"network"`
|
||||
Status int `gorm:"column:status;default:1" json:"status"`
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
|
||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"`
|
||||
PaymentType string `gorm:"column:payment_type" json:"payment_type"`
|
||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||
Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
|
||||
Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
|
||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
|
||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||
// PaymentType is not a channel selector. "Epay" changes callback format
|
||||
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
|
||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||
// PayProvider identifies how this specific order row is collected.
|
||||
//
|
||||
// Semantics:
|
||||
// - chain parent orders and regular chain child orders use on_chain
|
||||
// - third-party hosted checkout rows use their provider name
|
||||
// (for example okpay)
|
||||
//
|
||||
// Existing rows default to on_chain for backward compatibility so upgrades
|
||||
// can rely on AutoMigrate without rewriting old orders.
|
||||
PayProvider string `gorm:"column:pay_provider;size:32;default:on_chain;index:orders_pay_provider_index" json:"pay_provider" example:"on_chain"`
|
||||
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
||||
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
||||
// Zero when the parent order was paid directly (no sub-order involved).
|
||||
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
ProviderOrderStatusCreating = "creating"
|
||||
ProviderOrderStatusPending = "pending"
|
||||
ProviderOrderStatusPaid = "paid"
|
||||
ProviderOrderStatusFailed = "failed"
|
||||
ProviderOrderStatusExpired = "expired"
|
||||
)
|
||||
|
||||
// ProviderOrder stores provider-specific checkout data for one internal order
|
||||
// row. For OkPay this can bind either to a status=4 parent completed in place
|
||||
// or to a concrete child order created by later switch-network calls.
|
||||
//
|
||||
// Binding rules:
|
||||
// - trade_id always points to orders.trade_id
|
||||
// - the bound orders row should use the same pay_provider value
|
||||
// - provider identifies which downstream checkout created the provider row
|
||||
// - provider_order_id is the provider's own order number (for OkPay this is
|
||||
// the returned order_id from /payLink)
|
||||
// - pay_url is the hosted payment URL returned by the provider
|
||||
//
|
||||
// The main orders table remains the source of truth for merchant-facing state,
|
||||
// while this table keeps provider-specific identifiers and callback payloads.
|
||||
type ProviderOrder struct {
|
||||
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
// Provider values should match the bound Orders.PayProvider value.
|
||||
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
|
||||
// ProviderOrderID is the downstream provider's own order identifier for the
|
||||
// bound concrete order row.
|
||||
ProviderOrderID string `gorm:"column:provider_order_id;size:128;not null;default:'';index:provider_orders_provider_order_id_index" json:"provider_order_id" example:"ac7b86615fdb137576ae35879f7ed844"`
|
||||
// PayURL is the hosted checkout URL returned by the downstream provider for
|
||||
// the bound concrete order row.
|
||||
PayURL string `gorm:"column:pay_url;size:512;not null;default:''" json:"pay_url" example:"https://pay.example.com/checkout/abc123"`
|
||||
// Amount/Coin mirror the exact payload submitted to the provider, which may
|
||||
// be useful when validating callbacks and troubleshooting mismatches.
|
||||
Amount float64 `gorm:"column:amount" json:"amount" example:"14.2857"`
|
||||
Coin string `gorm:"column:coin;size:16;not null;default:''" json:"coin" example:"USDT"`
|
||||
Status string `gorm:"column:status;size:32;not null;default:pending;index:provider_orders_provider_status_index,priority:2" json:"status" example:"pending"`
|
||||
// NotifyRaw stores the original provider callback payload for auditing and
|
||||
// replay/debug use. It is provider-specific and intentionally opaque here.
|
||||
NotifyRaw string `gorm:"column:notify_raw;type:text" json:"notify_raw"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (p *ProviderOrder) TableName() string {
|
||||
return "provider_orders"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
RpcNodeTypeHttp = "http"
|
||||
RpcNodeTypeWs = "ws"
|
||||
RpcNodeTypeLite = "lite"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcNodeStatusUnknown = "unknown"
|
||||
RpcNodeStatusOk = "ok"
|
||||
RpcNodeStatusDown = "down"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcNodePurposeGeneral = "general"
|
||||
RpcNodePurposeManualVerify = "manual_verify"
|
||||
RpcNodePurposeBoth = "both"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
|
||||
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
|
||||
type RpcNode struct {
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||
Purpose string `gorm:"column:purpose;size:32;default:general;index:rpc_nodes_purpose_index" json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (r *RpcNode) TableName() string {
|
||||
return "rpc_nodes"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mdb
|
||||
|
||||
// Setting stores non-credential runtime configuration as key/value pairs.
|
||||
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
||||
// secret_key) live in the api_keys table; notification configs live in
|
||||
// NotificationChannel.
|
||||
// Default rows for the system/epay/okpay groups are seeded on first startup
|
||||
// (see model/dao/mdb_table_init.go seedDefaultSettings). Other groups start
|
||||
// empty and fall back to hardcoded defaults until an admin sets them. The
|
||||
// system.jwt_secret key is auto-generated on first startup.
|
||||
const (
|
||||
SettingGroupBrand = "brand"
|
||||
SettingGroupRate = "rate"
|
||||
SettingGroupSystem = "system"
|
||||
SettingGroupEpay = "epay"
|
||||
SettingGroupOkPay = "okpay"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingTypeString = "string"
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeJSON = "json"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingKeyJwtSecret = "system.jwt_secret"
|
||||
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
|
||||
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
|
||||
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||
SettingKeyAmountPrecision = "system.amount_precision"
|
||||
SettingKeySystemLogLevel = "system.log_level"
|
||||
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||
SettingKeyBrandSiteName = "brand.site_name"
|
||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||
SettingKeyBrandSiteTitle = "brand.site_title"
|
||||
SettingKeyBrandPageTitle = "brand.page_title"
|
||||
SettingKeyBrandSuccessCopy = "brand.success_copy"
|
||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||
SettingKeyBrandBackgroundColor = "brand.background_color"
|
||||
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
|
||||
SettingKeyRateForcedRateList = "rate.forced_rate_list"
|
||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||
SettingKeyRateApiUrl = "rate.api_url"
|
||||
|
||||
// EPAY route defaults — can be overridden via admin settings.
|
||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
||||
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
||||
|
||||
// OkPay hosted-checkout settings.
|
||||
SettingKeyOkPayEnabled = "okpay.enabled"
|
||||
SettingKeyOkPayShopID = "okpay.shop_id"
|
||||
SettingKeyOkPayShopToken = "okpay.shop_token"
|
||||
SettingKeyOkPayAPIURL = "okpay.api_url"
|
||||
SettingKeyOkPayCallbackURL = "okpay.callback_url"
|
||||
SettingKeyOkPayReturnURL = "okpay.return_url"
|
||||
SettingKeyOkPayTimeoutSeconds = "okpay.timeout_seconds"
|
||||
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingDefaultSystemLogLevel = "error"
|
||||
)
|
||||
|
||||
type Setting struct {
|
||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_rate_list"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"{\"cny\":{\"usdt\":0.14635}}"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"json"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制汇率列表"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package mdb
|
||||
|
||||
type SupportedAsset struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"`
|
||||
Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"`
|
||||
Status int64 `gorm:"column:status;default:1" json:"status"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *SupportedAsset) TableName() string {
|
||||
return "supported_asset"
|
||||
}
|
||||
@@ -3,16 +3,17 @@ package mdb
|
||||
import "time"
|
||||
|
||||
type TransactionLock struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||
AmountPrecision int `gorm:"column:amount_precision;default:2;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:5" json:"amount_precision"`
|
||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *TransactionLock) TableName() string {
|
||||
|
||||
@@ -9,15 +9,24 @@ const (
|
||||
NetworkTron = "tron"
|
||||
NetworkSolana = "solana"
|
||||
NetworkEthereum = "ethereum"
|
||||
NetworkBsc = "bsc"
|
||||
NetworkBsc = "binance"
|
||||
NetworkPolygon = "polygon"
|
||||
NetworkPlasma = "plasma"
|
||||
NetworkTon = "ton"
|
||||
)
|
||||
|
||||
const (
|
||||
WalletSourceManual = "manual"
|
||||
WalletSourceImport = "import"
|
||||
)
|
||||
|
||||
type WalletAddress struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
|
||||
Status int64 `gorm:"column:status;default:1" json:"status"`
|
||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
|
||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
|
||||
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ const (
|
||||
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
|
||||
|
||||
type BaseRequest struct {
|
||||
Page int `json:"page"` // 页数
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
OrderField string `json:"order_field"` // 排序字段
|
||||
OrderFunc string `json:"order_func"` // 排序方法
|
||||
Page int `json:"page" example:"1"` // 页数
|
||||
PageSize int `json:"page_size" example:"20"` // 每页条数
|
||||
OrderField string `json:"order_field" example:"created_at"` // 排序字段
|
||||
OrderFunc string `json:"order_func" enums:"DESC,ASC" example:"DESC"` // 排序方法
|
||||
}
|
||||
|
||||
@@ -4,16 +4,20 @@ import "github.com/gookit/validate"
|
||||
|
||||
// CreateTransactionRequest 创建交易请求
|
||||
type CreateTransactionRequest struct {
|
||||
OrderId string `json:"order_id" validate:"required|maxLen:32"`
|
||||
Currency string `json:"currency" validate:"required"` // 法币 如:cny
|
||||
Token string `json:"token" validate:"required"` // 币种 如:usdt
|
||||
Network string `json:"network" validate:"required"` // 网络 如:TRON
|
||||
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
|
||||
NotifyUrl string `json:"notify_url" validate:"required"`
|
||||
Signature string `json:"signature" validate:"required"`
|
||||
RedirectUrl string `json:"redirect_url"`
|
||||
Name string `json:"name"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
|
||||
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
|
||||
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||
// PaymentType is a compatibility flag, not a gateway selector. Only
|
||||
// "Epay" (case-insensitive) switches callback format to legacy EPay;
|
||||
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
|
||||
// It is optional for GMPay, but must be included in the signature when sent.
|
||||
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||
}
|
||||
|
||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||
@@ -39,11 +43,22 @@ type OrderProcessingRequest struct {
|
||||
BlockTransactionId string
|
||||
}
|
||||
|
||||
// ManualPaymentRequest 手动提交交易 hash 补单
|
||||
type ManualPaymentRequest struct {
|
||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||
}
|
||||
|
||||
func (r ManualPaymentRequest) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"BlockTransactionId": "交易哈希",
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchNetworkRequest 切换支付网络
|
||||
type SwitchNetworkRequest struct {
|
||||
TradeId string `json:"trade_id" validate:"required"`
|
||||
Token string `json:"token" validate:"required"`
|
||||
Network string `json:"network" validate:"required"`
|
||||
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||
Token string `json:"token" validate:"required" example:"USDT"`
|
||||
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
|
||||
}
|
||||
|
||||
func (r SwitchNetworkRequest) Translates() map[string]string {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package response
|
||||
|
||||
// ApiResponse is the standard JSON envelope used by all API endpoints.
|
||||
// Used for swagger documentation only — the actual response is built
|
||||
// by util/http.Resp.
|
||||
type ApiResponse struct {
|
||||
// Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
|
||||
StatusCode int `json:"status_code" example:"200"`
|
||||
// Default server-side errno text. Frontend should prefer status_code for display translation.
|
||||
Message string `json:"message" example:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
// Request id echoed from X-Request-ID when present.
|
||||
RequestID string `json:"request_id" example:"req-123456"`
|
||||
}
|
||||
@@ -2,39 +2,42 @@ package response
|
||||
|
||||
// CreateTransactionResponse 创建订单成功返回
|
||||
type CreateTransactionResponse struct {
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
OrderId string `json:"order_id"` // 客户交易id
|
||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
||||
Currency string `json:"currency"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url"` // 收银台地址
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
||||
}
|
||||
|
||||
// OrderNotifyResponse 订单异步回调结构体
|
||||
type OrderNotifyResponse struct {
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
OrderId string `json:"order_id"` // 客户交易id
|
||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
||||
BlockTransactionId string `json:"block_transaction_id"` // 区块id
|
||||
Signature string `json:"signature"` // 签名
|
||||
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"2"`
|
||||
}
|
||||
|
||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||
type OrderNotifyResponseEpay struct {
|
||||
PID int `json:"pid"` // 商户ID
|
||||
TradeNo string `json:"trade_no"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no"` // 商户订单号
|
||||
Type string `json:"type"` // 订单类型
|
||||
Name string `json:"name"` // 商品名称
|
||||
Money string `json:"money"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign"` // 签名
|
||||
SignType string `json:"sign_type"` // 签名类型 // MD5
|
||||
TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
package response
|
||||
|
||||
type CheckoutCounterResponse struct {
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
||||
Currency string `json:"currency"` // 法币币种 CNY USD ...
|
||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
||||
Network string `json:"network"` // 网络 TRON ETH ...
|
||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url"`
|
||||
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
|
||||
IsSelected bool `json:"is_selected"`
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
|
||||
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
|
||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
|
||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH;status=4 占位订单为空
|
||||
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
|
||||
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
|
||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
|
||||
}
|
||||
|
||||
type CheckStatusResponse struct {
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
Status int `json:"status"`
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||
Status int `json:"status" enums:"1,2,3,4" example:"1"`
|
||||
}
|
||||
|
||||
type ManualPaymentResponse struct {
|
||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||
Status int `json:"status" enums:"1,2,3" example:"2"` // 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123def456..."` // 已验证并入账的交易哈希
|
||||
}
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
package response
|
||||
|
||||
type NetworkTokenSupport struct {
|
||||
Network string `json:"network"`
|
||||
Tokens []string `json:"tokens"`
|
||||
Network string `json:"network" example:"tron"`
|
||||
DisplayName string `json:"display_name" example:"TRON"`
|
||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||
}
|
||||
|
||||
type SupportedAssetsResponse struct {
|
||||
Supports []NetworkTokenSupport `json:"supports"`
|
||||
type EpayPublicConfig struct {
|
||||
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
|
||||
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||
}
|
||||
|
||||
type SitePublicConfig struct {
|
||||
CashierName string `json:"cashier_name" example:"Acme Cashier"`
|
||||
LogoURL string `json:"logo_url" example:"https://cdn.example.com/logo.png"`
|
||||
WebsiteTitle string `json:"website_title" example:"Acme Payments"`
|
||||
SupportLink string `json:"support_link" example:"https://example.com/support"`
|
||||
BackgroundColor string `json:"background_color" example:"#0f172a"`
|
||||
BackgroundImageURL string `json:"background_image_url" example:"https://cdn.example.com/background.png"`
|
||||
}
|
||||
|
||||
type OkPayPublicConfig struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
AllowTokens []string `json:"allow_tokens" example:"USDT,TRX"`
|
||||
ShopID string `json:"shop_id,omitempty" example:"okpay-shop-test"`
|
||||
ShopToken string `json:"shop_token,omitempty" example:"secret-token"`
|
||||
APIURL string `json:"api_url,omitempty" example:"https://api.okaypay.me/shop/"`
|
||||
CallbackURL string `json:"callback_url,omitempty" example:"https://pay.example.com/payments/okpay/v1/notify"`
|
||||
ReturnURL string `json:"return_url,omitempty" example:"https://pay.example.com/success"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty" example:"10"`
|
||||
}
|
||||
|
||||
type PublicConfigResponse struct {
|
||||
SupportedAssets []NetworkTokenSupport `json:"supported_assets"`
|
||||
Site SitePublicConfig `json:"site"`
|
||||
Epay EpayPublicConfig `json:"epay"`
|
||||
OkPay OkPayPublicConfig `json:"okpay"`
|
||||
Version string `json:"version" example:"v1.0.1"`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/xssnick/tonutils-go/address"
|
||||
"github.com/xssnick/tonutils-go/tlb"
|
||||
"github.com/xssnick/tonutils-go/ton"
|
||||
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func upsertTestChainToken(t *testing.T, token mdb.ChainToken) {
|
||||
t.Helper()
|
||||
if err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"contract_address",
|
||||
"decimals",
|
||||
"enabled",
|
||||
"min_amount",
|
||||
}),
|
||||
}).Create(&token).Error; err != nil {
|
||||
t.Fatalf("upsert token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
||||
hash := strings.Repeat("a", 64)
|
||||
if !isEvmHash(hash) {
|
||||
t.Fatal("expected bare EVM hash to be valid")
|
||||
}
|
||||
if !isEvmHash("0x" + hash) {
|
||||
t.Fatal("expected 0x-prefixed EVM hash to be valid")
|
||||
}
|
||||
if isEvmHash("0x" + strings.Repeat("a", 63)) {
|
||||
t.Fatal("expected short EVM hash to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeEvmAddressAcceptsOptional0x(t *testing.T) {
|
||||
addr := "1111111111111111111111111111111111111111"
|
||||
want := common.HexToAddress("0x" + addr)
|
||||
got, err := normalizeEvmAddress(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize bare address: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("bare address = %s, want %s", got.Hex(), want.Hex())
|
||||
}
|
||||
got, err = normalizeEvmAddress("0X" + strings.ToUpper(addr))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize prefixed address: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prefixed address = %s, want %s", got.Hex(), want.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeTronAddressHexAcceptsOptionalPrefix(t *testing.T) {
|
||||
body := "a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
want := "41" + body
|
||||
|
||||
for _, input := range []string{body, "0x" + body, want, "0X" + strings.ToUpper(want)} {
|
||||
got, err := normalizeTronAddressHex(input)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeTronAddressHex(%q): %v", input, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("normalizeTronAddressHex(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
||||
txID := strings.Repeat("a", 64)
|
||||
got, err := normalizeTronTxID(txID)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize bare txid: %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("bare txid = %q, want %q", got, txID)
|
||||
}
|
||||
got, err = normalizeTronTxID("0X" + strings.ToUpper(txID))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize prefixed txid: %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("prefixed txid = %q, want %q", got, txID)
|
||||
}
|
||||
if _, err = normalizeTronTxID("0x" + strings.Repeat("a", 63)); err == nil {
|
||||
t.Fatal("expected short txid to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
||||
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
||||
hash := strings.Repeat("a", 64)
|
||||
|
||||
ref, err := parseManualTonTxRef("ton:" + receiveRaw + ":123:" + strings.ToUpper(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("parse canonical TON tx ref: %v", err)
|
||||
}
|
||||
if ref.ReceiveRaw != receiveRaw || ref.LT != 123 || ref.HashHex != hash || !ref.HasLT {
|
||||
t.Fatalf("canonical ref = %#v", ref)
|
||||
}
|
||||
|
||||
ref, err = parseManualTonTxRef("456:0X" + strings.ToUpper(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("parse lt:hash TON tx ref: %v", err)
|
||||
}
|
||||
if ref.ReceiveRaw != "" || ref.LT != 456 || ref.HashHex != hash || !ref.HasLT {
|
||||
t.Fatalf("lt:hash ref = %#v", ref)
|
||||
}
|
||||
|
||||
ref, err = parseManualTonTxRef("0X" + strings.ToUpper(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("parse hash-only TON tx ref: %v", err)
|
||||
}
|
||||
if ref.ReceiveRaw != "" || ref.LT != 0 || ref.HashHex != hash || ref.HasLT {
|
||||
t.Fatalf("hash-only ref = %#v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeManualTonAPI struct {
|
||||
ton.APIClientWrapped
|
||||
master *ton.BlockIDExt
|
||||
account *tlb.Account
|
||||
txs []*tlb.Transaction
|
||||
getAccountCalls int
|
||||
listCalls []manualTonListCall
|
||||
}
|
||||
|
||||
type manualTonListCall struct {
|
||||
limit uint32
|
||||
lt uint64
|
||||
hashHex string
|
||||
}
|
||||
|
||||
func (f *fakeManualTonAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
||||
return f.master, nil
|
||||
}
|
||||
|
||||
func (f *fakeManualTonAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeManualTonAPI) GetAccount(context.Context, *ton.BlockIDExt, *address.Address) (*tlb.Account, error) {
|
||||
f.getAccountCalls++
|
||||
if f.account == nil {
|
||||
return &tlb.Account{}, nil
|
||||
}
|
||||
return f.account, nil
|
||||
}
|
||||
|
||||
func (f *fakeManualTonAPI) ListTransactions(_ context.Context, _ *address.Address, limit uint32, lt uint64, txHash []byte) ([]*tlb.Transaction, error) {
|
||||
f.listCalls = append(f.listCalls, manualTonListCall{
|
||||
limit: limit,
|
||||
lt: lt,
|
||||
hashHex: hex.EncodeToString(txHash),
|
||||
})
|
||||
return f.txs, nil
|
||||
}
|
||||
|
||||
func TestValidateManualTonPaymentWithAPINativeTON(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||
Update("enabled", false).Error; err != nil {
|
||||
t.Fatalf("disable TON USDT token: %v", err)
|
||||
}
|
||||
|
||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
||||
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
Network: mdb.NetworkTon,
|
||||
Token: "TON",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: receive.Bounce(false).String(),
|
||||
}
|
||||
api := &fakeManualTonAPI{
|
||||
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||
txs: []*tlb.Transaction{tx},
|
||||
}
|
||||
ref := manualTonTxRef{LT: tx.LT, HashHex: hex.EncodeToString(tx.Hash), HasLT: true}
|
||||
|
||||
got, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("validateManualTonPaymentWithAPI(): %v", err)
|
||||
}
|
||||
if api.getAccountCalls != 0 {
|
||||
t.Fatalf("exact TON ref fetched account state %d times, want 0", api.getAccountCalls)
|
||||
}
|
||||
if len(api.listCalls) != 1 {
|
||||
t.Fatalf("ListTransactions calls = %#v, want one exact lookup", api.listCalls)
|
||||
}
|
||||
if call := api.listCalls[0]; call.limit != 1 || call.lt != tx.LT || call.hashHex != hex.EncodeToString(tx.Hash) {
|
||||
t.Fatalf("ListTransactions exact call = %#v, want limit=1 lt/hash from ref", call)
|
||||
}
|
||||
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||
if got != want {
|
||||
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualTonPaymentCandidatesUSDTJetton(t *testing.T) {
|
||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x68}, 32)).Bounce(false).Testnet(false)
|
||||
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x69}, 32)).Bounce(false).Testnet(false)
|
||||
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||
QueryID: 7,
|
||||
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
||||
Sender: sender,
|
||||
ForwardPayload: cell.BeginCell().EndCell(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build jetton notification body: %v", err)
|
||||
}
|
||||
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
Network: mdb.NetworkTon,
|
||||
Token: "USDT",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: receive.Bounce(false).String(),
|
||||
}
|
||||
state := &manualTonState{
|
||||
jettonWallets: map[string]mdb.ChainToken{
|
||||
addressutil.TonRawAddressObjectKey(jettonWallet): {
|
||||
BaseModel: mdb.BaseModel{ID: 2},
|
||||
Network: mdb.NetworkTon,
|
||||
Symbol: "USDT",
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateManualTonPaymentCandidates(order, receive, manualTonTxRef{
|
||||
LT: tx.LT,
|
||||
HashHex: hex.EncodeToString(tx.Hash),
|
||||
HasLT: true,
|
||||
}, []*tlb.Transaction{tx}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("validateManualTonPaymentCandidates(): %v", err)
|
||||
}
|
||||
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||
if got != want {
|
||||
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualTonPaymentWithAPIRejectsAmbiguousHashOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||
Update("enabled", false).Error; err != nil {
|
||||
t.Fatalf("disable TON USDT token: %v", err)
|
||||
}
|
||||
|
||||
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x67}, 32)).Bounce(false).Testnet(false)
|
||||
tx1 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||
tx2 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||
tx2.LT = tx1.LT + 1
|
||||
order := &mdb.Orders{
|
||||
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
Network: mdb.NetworkTon,
|
||||
Token: "TON",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: receive.Bounce(false).String(),
|
||||
}
|
||||
api := &fakeManualTonAPI{
|
||||
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||
account: &tlb.Account{
|
||||
LastTxLT: tx2.LT,
|
||||
LastTxHash: tx2.Hash,
|
||||
},
|
||||
txs: []*tlb.Transaction{tx1, tx2},
|
||||
}
|
||||
|
||||
_, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, manualTonTxRef{
|
||||
HashHex: hex.EncodeToString(tx1.Hash),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "matched multiple") {
|
||||
t.Fatalf("validate ambiguous hash-only error = %v, want multiple-match error", err)
|
||||
}
|
||||
if api.getAccountCalls != 1 {
|
||||
t.Fatalf("hash-only TON ref fetched account state %d times, want 1", api.getAccountCalls)
|
||||
}
|
||||
if len(api.listCalls) != 1 {
|
||||
t.Fatalf("ListTransactions calls = %#v, want one recent lookup", api.listCalls)
|
||||
}
|
||||
if call := api.listCalls[0]; call.limit != 100 || call.lt != tx2.LT || call.hashHex != hex.EncodeToString(tx2.Hash) {
|
||||
t.Fatalf("ListTransactions recent call = %#v, want account last tx cursor", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: "http://127.0.0.1:1",
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create rpc node: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
clients, err := dialManualEvmClients(ctx, mdb.NetworkEthereum)
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClients(): %v", err)
|
||||
}
|
||||
defer closeManualEvmClients(clients)
|
||||
if len(clients) != 1 {
|
||||
t.Fatalf("client count = %d, want 1", len(clients))
|
||||
}
|
||||
if !strings.Contains(clients[0].label, mdb.RpcNodeTypeHttp) {
|
||||
t.Fatalf("client label = %q, want HTTP node", clients[0].label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmCandidatesPreferGeneralAcrossNodeTypes(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
rows := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeHttp, Url: "http://manual.example.com", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeWs, Url: "ws://general.example.com", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||
}
|
||||
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed rpc_nodes: %v", err)
|
||||
}
|
||||
|
||||
got, err := listManualEvmRpcCandidates(mdb.NetworkEthereum)
|
||||
if err != nil {
|
||||
t.Fatalf("listManualEvmRpcCandidates(): %v", err)
|
||||
}
|
||||
urls := make([]string, 0, len(got))
|
||||
for _, node := range got {
|
||||
urls = append(urls, node.Url)
|
||||
}
|
||||
want := []string{"ws://general.example.com", "http://manual.example.com"}
|
||||
if len(urls) != len(want) {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if urls[i] != want[i] {
|
||||
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRpcNodeLabelHidesSensitiveURLParts(t *testing.T) {
|
||||
label := manualRpcNodeLabel(mdb.RpcNode{
|
||||
BaseModel: mdb.BaseModel{ID: 9},
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: "https://rpc.example.com/v3/secret-key?token=secret",
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||
t.Fatalf("manualRpcNodeLabel() leaked sensitive URL parts: %s", label)
|
||||
}
|
||||
if !strings.Contains(label, "endpoint=https://rpc.example.com") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want sanitized endpoint", label)
|
||||
}
|
||||
if !strings.Contains(label, "purpose=manual_verify") {
|
||||
t.Fatalf("manualRpcNodeLabel() = %s, want purpose", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPHeader(t *testing.T) {
|
||||
var headerCalls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&headerCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&headerCalls); got == 0 {
|
||||
t.Fatal("manual verify EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyDialEvmClientDoesNotForwardUserIPForGeneralNode(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32000,
|
||||
"message": "not found",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Purpose: mdb.RpcNodePurposeGeneral,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||
if got := atomic.LoadInt32(&calls); got == 0 {
|
||||
t.Fatal("general EVM request did not reach test RPC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronPostJSONDoesNotForwardUserIPWithoutHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := tronPostJSON(server.URL, "", "/wallet/getnowblock", map[string]interface{}{}, &out); err != nil {
|
||||
t.Fatalf("tronPostJSON(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||
txTime := uint64(time.Now().Add(-time.Hour).Unix())
|
||||
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err == nil {
|
||||
t.Fatal("expected transaction before order to be rejected")
|
||||
}
|
||||
txTime = uint64(time.Now().Add(time.Minute).Unix())
|
||||
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err != nil {
|
||||
t.Fatalf("expected transaction after order to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyCanonicalEvmHash(t *testing.T) {
|
||||
hash := strings.Repeat("a", 64)
|
||||
_, canonical, err := canonicalEvmHash("0X" + strings.ToUpper(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalEvmHash(): %v", err)
|
||||
}
|
||||
if canonical != "0x"+hash {
|
||||
t.Fatalf("canonical hash = %q, want %q", canonical, "0x"+hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEquivalentBlockIDsCatchLegacyVariants(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
hash := strings.Repeat("b", 64)
|
||||
mixedHash := "0x" + strings.Repeat("bB", 32)
|
||||
if err := dao.Mdb.Create(&mdb.Orders{
|
||||
TradeId: "paid-legacy-hash",
|
||||
OrderId: "paid-legacy-hash",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
Network: mdb.NetworkEthereum,
|
||||
BlockTransactionId: mixedHash,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create existing order: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{ID: 999}, Network: mdb.NetworkEthereum}
|
||||
if err := ensureManualBlockTransactionUnused(order, "0x"+hash); err == nil {
|
||||
t.Fatal("expected legacy hash variant to be treated as already processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyEvmRequestFailureFallsBackToNextClient(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
to := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
rawAmount := big.NewInt(1230000)
|
||||
receipt := &types.Receipt{
|
||||
Status: types.ReceiptStatusSuccessful,
|
||||
BlockNumber: big.NewInt(10),
|
||||
Logs: []*types.Log{{
|
||||
Address: contract,
|
||||
Topics: []common.Hash{
|
||||
erc20TransferEventHash,
|
||||
common.Hash{},
|
||||
common.BytesToHash(to.Bytes()),
|
||||
},
|
||||
Data: common.LeftPadBytes(rawAmount.Bytes(), 32),
|
||||
}},
|
||||
}
|
||||
order := &mdb.Orders{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Token: "USDT",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: to.Hex(),
|
||||
BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||
}
|
||||
token := &mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: contract.Hex(), Decimals: 6}
|
||||
|
||||
err := validateManualEvmPaymentAcrossClients(context.Background(), []manualEvmClient{
|
||||
{label: "ws bad", reader: &fakeEvmReader{receiptErr: errors.New("ws receipt failed")}},
|
||||
{label: "http ok", reader: &fakeEvmReader{
|
||||
receipt: receipt,
|
||||
headers: map[string]*types.Header{
|
||||
"10": {Number: big.NewInt(10), Time: uint64(time.Now().Unix())},
|
||||
"latest": {Number: big.NewInt(12), Time: uint64(time.Now().Unix())},
|
||||
},
|
||||
}},
|
||||
}, order, common.HexToHash("0x"+strings.Repeat("c", 64)), token)
|
||||
if err != nil {
|
||||
t.Fatalf("validateManualEvmPaymentAcrossClients(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
contractHex := "411111111111111111111111111111111111111111"
|
||||
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
contractAddress, err := tronHexToAddress(contractHex)
|
||||
if err != nil {
|
||||
t.Fatalf("contract address: %v", err)
|
||||
}
|
||||
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient address: %v", err)
|
||||
}
|
||||
upsertTestChainToken(t, mdb.ChainToken{
|
||||
Network: mdb.NetworkTron,
|
||||
Symbol: "USDT",
|
||||
ContractAddress: contractAddress,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
rawAmount := big.NewInt(1230000)
|
||||
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
||||
info := &manualTronTxInfo{Log: []manualTronEventLog{{
|
||||
Address: strings.TrimPrefix(contractHex, "41"),
|
||||
Topics: []string{
|
||||
"0x" + strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||
strings.Repeat("0", 64),
|
||||
"0x" + strings.Repeat("0", 24) + strings.TrimPrefix(recipientHex, "41"),
|
||||
},
|
||||
Data: "0x" + fmt.Sprintf("%064x", rawAmount),
|
||||
}}}
|
||||
order := &mdb.Orders{ReceiveAddress: recipientAddress, Token: "USDT", ActualAmount: 1.23}
|
||||
|
||||
if err = validateManualTronTRC20Transfer(order, &tx, info); err != nil {
|
||||
t.Fatalf("validateManualTronTRC20Transfer(): %v", err)
|
||||
}
|
||||
|
||||
info.Log[0].Data = "0x" + fmt.Sprintf("%064x", big.NewInt(1240000))
|
||||
if err = validateManualTronTRC20Transfer(order, &tx, info); err == nil {
|
||||
t.Fatal("expected event amount mismatch to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
txID := strings.Repeat("a", 64)
|
||||
contractHex := "411111111111111111111111111111111111111111"
|
||||
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
contractAddress, err := tronHexToAddress(contractHex)
|
||||
if err != nil {
|
||||
t.Fatalf("contract address: %v", err)
|
||||
}
|
||||
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient address: %v", err)
|
||||
}
|
||||
|
||||
rawAmount := big.NewInt(1230000)
|
||||
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
|
||||
requestCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||
}
|
||||
requestCalls++
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode tron request: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/wallet/getnowblock" && req["value"] != txID {
|
||||
t.Errorf("request tx id = %q, want %q", req["value"], txID)
|
||||
http.Error(w, "bad tx id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wallet/gettransactionbyid":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"txID": txID,
|
||||
"raw_data": map[string]interface{}{
|
||||
"contract": []map[string]interface{}{{
|
||||
"type": "TriggerSmartContract",
|
||||
"parameter": map[string]interface{}{
|
||||
"value": map[string]interface{}{},
|
||||
},
|
||||
}},
|
||||
},
|
||||
"ret": []map[string]string{{"contractRet": "SUCCESS"}},
|
||||
})
|
||||
case "/wallet/gettransactioninfobyid":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": txID,
|
||||
"blockNumber": 100,
|
||||
"blockTimeStamp": blockTimeMs,
|
||||
"receipt": map[string]string{"result": "SUCCESS"},
|
||||
"log": []map[string]interface{}{{
|
||||
"address": strings.TrimPrefix(contractHex, "41"),
|
||||
"topics": []string{
|
||||
strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||
strings.Repeat("0", 64),
|
||||
"0X" + strings.ToUpper(strings.Repeat("0", 24)+strings.TrimPrefix(recipientHex, "41")),
|
||||
},
|
||||
"data": fmt.Sprintf("%064x", rawAmount),
|
||||
}},
|
||||
})
|
||||
case "/wallet/getnowblock":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"block_header": map[string]interface{}{
|
||||
"raw_data": map[string]interface{}{"number": 110},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err = dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Url: server.URL,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create tron rpc node: %v", err)
|
||||
}
|
||||
upsertTestChainToken(t, mdb.ChainToken{
|
||||
Network: mdb.NetworkTron,
|
||||
Symbol: "USDT",
|
||||
ContractAddress: contractAddress,
|
||||
Decimals: 6,
|
||||
Enabled: true,
|
||||
})
|
||||
order := &mdb.Orders{
|
||||
TradeId: "manual-tron-http-flow",
|
||||
OrderId: "manual-tron-http-flow",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: recipientAddress,
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusWaitPay,
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err = dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
got, err := ValidateManualOrderPayment(order, "0X"+strings.ToUpper(txID))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateManualOrderPayment(): %v", err)
|
||||
}
|
||||
if got != txID {
|
||||
t.Fatalf("canonical tx id = %q, want %q", got, txID)
|
||||
}
|
||||
if requestCalls != 3 {
|
||||
t.Fatalf("manual verify request calls = %d, want 3", requestCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
|
||||
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||
if err := ensureSolanaTransferNotBeforeOrder(0, order); err == nil {
|
||||
t.Fatal("expected missing block time to fail")
|
||||
}
|
||||
if err := ensureSolanaTransferNotBeforeOrder(time.Now().Add(time.Minute).Unix(), order); err != nil {
|
||||
t.Fatalf("expected future block time to pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEvmReader struct {
|
||||
receipt *types.Receipt
|
||||
receiptErr error
|
||||
headers map[string]*types.Header
|
||||
headerErr error
|
||||
}
|
||||
|
||||
func (f *fakeEvmReader) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) {
|
||||
if f.receiptErr != nil {
|
||||
return nil, f.receiptErr
|
||||
}
|
||||
return f.receipt, nil
|
||||
}
|
||||
|
||||
func (f *fakeEvmReader) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
|
||||
if f.headerErr != nil {
|
||||
return nil, f.headerErr
|
||||
}
|
||||
key := "latest"
|
||||
if number != nil {
|
||||
key = number.String()
|
||||
}
|
||||
header := f.headers[key]
|
||||
if header == nil {
|
||||
return nil, fmt.Errorf("missing header %s", key)
|
||||
}
|
||||
return header, nil
|
||||
}
|
||||
|
||||
func manualTronTransactionFromCallData(t *testing.T, contractHex, recipientHex string, amount *big.Int) manualTronTransaction {
|
||||
t.Helper()
|
||||
body := strings.TrimPrefix(recipientHex, "41")
|
||||
raw := fmt.Sprintf(`{
|
||||
"raw_data": {
|
||||
"contract": [{
|
||||
"type": "TriggerSmartContract",
|
||||
"parameter": {
|
||||
"value": {
|
||||
"contract_address": %q,
|
||||
"data": %q
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}`, contractHex, "a9059cbb"+strings.Repeat("0", 24)+body+fmt.Sprintf("%064x", amount))
|
||||
var tx manualTronTransaction
|
||||
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
|
||||
t.Fatalf("unmarshal tron tx: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
)
|
||||
|
||||
type okPayCreateDepositResponse struct {
|
||||
Status string `json:"status"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
OrderID string `json:"order_id"`
|
||||
PayURL string `json:"pay_url"`
|
||||
} `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type okPayDepositOrder struct {
|
||||
ProviderOrderID string
|
||||
PayURL string
|
||||
}
|
||||
|
||||
type okPayNotifyPayload struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
OrderID string `json:"order_id"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
PayUserID string `json:"pay_user_id"`
|
||||
Amount string `json:"amount"`
|
||||
Coin string `json:"coin"`
|
||||
PayStatus string `json:"pay_status"`
|
||||
NotifyType string `json:"notify_type"`
|
||||
Sign string `json:"sign"`
|
||||
RawFormData string `json:"raw_form_data"`
|
||||
}
|
||||
|
||||
func okPaySign(form map[string]string, shopID string, shopToken string) map[string]string {
|
||||
values := url.Values{}
|
||||
for key, value := range form {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
values.Set(key, value)
|
||||
}
|
||||
values.Set("id", shopID)
|
||||
query, _ := url.QueryUnescape(values.Encode())
|
||||
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||
|
||||
signed := make(map[string]string, len(values)+1)
|
||||
for key, vals := range values {
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
signed[key] = vals[0]
|
||||
}
|
||||
signed["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
return signed
|
||||
}
|
||||
|
||||
func createOkPayDepositOrder(uniqueID string, amount float64, coin string, returnURL string) (*okPayDepositOrder, error) {
|
||||
shopID := data.GetOkPayShopID()
|
||||
shopToken := data.GetOkPayShopToken()
|
||||
apiURL := strings.TrimSpace(data.GetOkPayAPIURL())
|
||||
callbackURL := data.GetOkPayCallbackURL()
|
||||
if shopID == "" || shopToken == "" || apiURL == "" || callbackURL == "" {
|
||||
return nil, fmt.Errorf("okpay config incomplete")
|
||||
}
|
||||
|
||||
if returnURL == "" {
|
||||
returnURL = data.GetOkPayReturnURL()
|
||||
}
|
||||
|
||||
form := map[string]string{
|
||||
"unique_id": uniqueID,
|
||||
"name": uniqueID,
|
||||
"amount": fmt.Sprintf("%.*f", data.GetAmountPrecision(), amount),
|
||||
"coin": strings.ToUpper(strings.TrimSpace(coin)),
|
||||
"callback_url": callbackURL,
|
||||
"return_url": returnURL,
|
||||
}
|
||||
form = okPaySign(form, shopID, shopToken)
|
||||
|
||||
base, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.Path = path.Join(base.Path, "payLink")
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
client.SetTimeout(time.Duration(data.GetOkPayTimeoutSeconds()) * time.Second)
|
||||
|
||||
var payload okPayCreateDepositResponse
|
||||
resp, err := client.R().
|
||||
SetFormData(form).
|
||||
SetResult(&payload).
|
||||
Post(base.String())
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[okpay] create order request failed unique_id=%s api_url=%s callback_url=%s return_url=%s err=%v", uniqueID, base.String(), callbackURL, returnURL, err)
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode() >= 400 {
|
||||
log.Sugar.Warnf("[okpay] create order http error unique_id=%s status=%s api_url=%s callback_url=%s return_url=%s body=%s", uniqueID, resp.Status(), base.String(), callbackURL, returnURL, resp.String())
|
||||
return nil, fmt.Errorf("okpay http status: %s", resp.Status())
|
||||
}
|
||||
if payload.Data.OrderID == "" || payload.Data.PayURL == "" {
|
||||
log.Sugar.Warnf("[okpay] create order rejected unique_id=%s api_url=%s callback_url=%s return_url=%s status=%s code=%d msg=%s", uniqueID, base.String(), callbackURL, returnURL, payload.Status, payload.Code, payload.Msg)
|
||||
if payload.Msg != "" {
|
||||
return nil, fmt.Errorf("okpay create order failed: %s (callback_url=%s)", payload.Msg, callbackURL)
|
||||
}
|
||||
return nil, fmt.Errorf("okpay create order failed (callback_url=%s)", callbackURL)
|
||||
}
|
||||
|
||||
return &okPayDepositOrder{
|
||||
ProviderOrderID: payload.Data.OrderID,
|
||||
PayURL: payload.Data.PayURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifyOkPayNotify(form map[string]string) bool {
|
||||
shopID := data.GetOkPayShopID()
|
||||
shopToken := data.GetOkPayShopToken()
|
||||
signature := strings.TrimSpace(form["sign"])
|
||||
if shopID == "" || shopToken == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if expected := okPayNotifySign(form, shopToken); expected != "" && strings.EqualFold(expected, signature) {
|
||||
return true
|
||||
}
|
||||
|
||||
unsigned := make(map[string]string, len(form))
|
||||
for key, value := range form {
|
||||
if strings.EqualFold(key, "sign") {
|
||||
continue
|
||||
}
|
||||
unsigned[key] = value
|
||||
}
|
||||
signed := okPaySign(unsigned, shopID, shopToken)
|
||||
return strings.EqualFold(strings.TrimSpace(signed["sign"]), signature)
|
||||
}
|
||||
|
||||
func okPayNotifySign(form map[string]string, shopToken string) string {
|
||||
orderedKeys := []string{
|
||||
"code",
|
||||
"data[order_id]",
|
||||
"data[unique_id]",
|
||||
"data[pay_user_id]",
|
||||
"data[amount]",
|
||||
"data[coin]",
|
||||
"data[status]",
|
||||
"data[type]",
|
||||
"id",
|
||||
"status",
|
||||
}
|
||||
|
||||
pairs := make([]string, 0, len(orderedKeys))
|
||||
for _, key := range orderedKeys {
|
||||
value := strings.TrimSpace(form[key])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
query, err := url.QueryUnescape(strings.Join(pairs, "&"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func parseOkPayNotify(form map[string]string, rawFormData string) *okPayNotifyPayload {
|
||||
return &okPayNotifyPayload{
|
||||
ID: strings.TrimSpace(form["id"]),
|
||||
Status: strings.TrimSpace(form["status"]),
|
||||
Code: strings.TrimSpace(form["code"]),
|
||||
OrderID: strings.TrimSpace(form["data[order_id]"]),
|
||||
UniqueID: strings.TrimSpace(form["data[unique_id]"]),
|
||||
PayUserID: strings.TrimSpace(form["data[pay_user_id]"]),
|
||||
Amount: strings.TrimSpace(form["data[amount]"]),
|
||||
Coin: strings.TrimSpace(form["data[coin]"]),
|
||||
PayStatus: strings.TrimSpace(form["data[status]"]),
|
||||
NotifyType: strings.TrimSpace(form["data[type]"]),
|
||||
Sign: strings.TrimSpace(form["sign"]),
|
||||
RawFormData: rawFormData,
|
||||
}
|
||||
}
|
||||
|
||||
func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
||||
if !verifyOkPayNotify(form) {
|
||||
return fmt.Errorf("invalid okpay notify sign")
|
||||
}
|
||||
|
||||
payload := parseOkPayNotify(form, rawFormData)
|
||||
|
||||
if payload.ID == "" || payload.ID != data.GetOkPayShopID() {
|
||||
return fmt.Errorf("okpay notify shop id mismatch: %s", payload.ID)
|
||||
}
|
||||
if payload.UniqueID == "" || payload.OrderID == "" {
|
||||
return fmt.Errorf("missing okpay notify identifiers")
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(payload.UniqueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.ID <= 0 {
|
||||
return fmt.Errorf("okpay notify order not found: %s", payload.UniqueID)
|
||||
}
|
||||
if order.PayProvider != "okpay" {
|
||||
return fmt.Errorf("okpay notify order provider mismatch: %s", payload.UniqueID)
|
||||
}
|
||||
|
||||
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(payload.UniqueID, order.PayProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if providerRow.ID <= 0 {
|
||||
return fmt.Errorf("okpay provider order not found: %s", payload.UniqueID)
|
||||
}
|
||||
|
||||
_ = data.SaveProviderOrderNotify(payload.UniqueID, order.PayProvider, payload.RawFormData)
|
||||
|
||||
if !strings.EqualFold(payload.Status, "success") || !strings.EqualFold(payload.NotifyType, "deposit") || payload.PayStatus != "1" {
|
||||
log.Sugar.Infof("[okpay] notify ignored trade_id=%s status=%s pay_status=%s type=%s", payload.UniqueID, payload.Status, payload.PayStatus, payload.NotifyType)
|
||||
return nil
|
||||
}
|
||||
|
||||
if providerRow.ProviderOrderID != "" && providerRow.ProviderOrderID != payload.OrderID {
|
||||
return fmt.Errorf("okpay provider order id mismatch: got=%s want=%s", payload.OrderID, providerRow.ProviderOrderID)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(order.Token), strings.TrimSpace(payload.Coin)) {
|
||||
return fmt.Errorf("okpay coin mismatch: got=%s want=%s", payload.Coin, order.Token)
|
||||
}
|
||||
|
||||
notifyAmount, err := strconv.ParseFloat(payload.Amount, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid okpay amount: %w", err)
|
||||
}
|
||||
precision := data.GetAmountPrecision()
|
||||
if fmt.Sprintf("%.*f", precision, notifyAmount) != fmt.Sprintf("%.*f", precision, order.ActualAmount) {
|
||||
return fmt.Errorf("okpay amount mismatch: got=%.*f want=%.*f", precision, notifyAmount, precision, order.ActualAmount)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Currency: order.Currency,
|
||||
Token: order.Token,
|
||||
Network: order.Network,
|
||||
Amount: order.ActualAmount,
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: payload.OrderID,
|
||||
})
|
||||
processed := err == nil
|
||||
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
||||
return err
|
||||
}
|
||||
|
||||
if processed {
|
||||
sendPaymentNotification(order)
|
||||
}
|
||||
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user