Compare commits
59 Commits
v0.0.5
...
hot_fix_0427
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 097c716714 | |||
| f7c5f678c4 | |||
| 3e88d7a40c | |||
| 18dcb96822 | |||
| 4750d63db3 | |||
| 7af7306729 | |||
| ff16db907a | |||
| 96c262a7dd | |||
| 8e96cc0058 | |||
| 9c003fbead | |||
| aff1055743 | |||
| f6a8fb47df | |||
| d46bc7793e | |||
| 9d01443f3e | |||
| 70f8ed4ea0 | |||
| 8cd816c35e | |||
| 2f6cd34610 | |||
| 1e2282ba2e | |||
| 786c5e8175 | |||
| 5e68bf8309 | |||
| 5168beda00 | |||
| 5e4d5dfae4 | |||
| 32ca778735 | |||
| 730ff983f8 | |||
| 93b32d38d9 | |||
| 3f071e6c01 |
@@ -0,0 +1,111 @@
|
||||
name: Bug 反馈
|
||||
description: 有一个 bug 需要解决
|
||||
title: "发现bug:"
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 提 ISSUE 须知
|
||||
|
||||
请先阅读文档:
|
||||
- [epusdt 文档](https://epusdt.com/)
|
||||
|
||||
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
|
||||
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
|
||||
|
||||
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: 提交前检查
|
||||
options:
|
||||
- label: 我已阅读文档并检查常见问题
|
||||
required: true
|
||||
- label: 我已搜索现有 issue,确认不是重复问题
|
||||
required: true
|
||||
- label: 我已尽量提供可复现信息(代码、配置、日志)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: epusdt_version
|
||||
attributes:
|
||||
label: 1. epusdt 版本
|
||||
description: 请提供版本号或 commit hash,避免只写“最新版”
|
||||
placeholder: |
|
||||
例如:
|
||||
- v1.2.3
|
||||
- commit: abcdef1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: runtime
|
||||
attributes:
|
||||
label: 2. 运行环境版本
|
||||
description: 请提供语言/运行时及相关依赖版本
|
||||
placeholder: |
|
||||
例如:
|
||||
- Go/Python/Node 版本:
|
||||
- 数据库及版本:
|
||||
- Docker 版本(如有):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: os_info
|
||||
attributes:
|
||||
label: 3. 操作系统信息
|
||||
description: Windows / Linux / macOS 或其他系统,请写明版本
|
||||
placeholder: |
|
||||
例如:
|
||||
- Ubuntu 22.04
|
||||
- macOS 14.6
|
||||
- Windows Server 2022
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro_code
|
||||
attributes:
|
||||
label: 4. 可复现代码/配置
|
||||
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
|
||||
render: shell
|
||||
placeholder: |
|
||||
# 最小复现步骤
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
# 关键请求/配置(脱敏后)
|
||||
curl -X POST https://xxx/api/...
|
||||
-H "Authorization: Bearer ***"
|
||||
-d '{"amount":100,"order_id":"test-001"}'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: stacktrace
|
||||
attributes:
|
||||
label: 5. 错误日志 / 堆栈信息
|
||||
description: 请粘贴完整报错信息、状态码、关键日志
|
||||
render: shell
|
||||
placeholder: |
|
||||
例如:
|
||||
- HTTP 状态码:
|
||||
- 错误码:
|
||||
- 错误堆栈:
|
||||
- 关键日志片段:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
|
||||
placeholder: 其他你认为有帮助的信息(可选)
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 安全漏洞报告
|
||||
url: https://github.com/ginta/epusdt/security/advisories/new
|
||||
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
|
||||
@@ -5,28 +5,40 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "master"
|
||||
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=branch,suffix=-alpine
|
||||
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 +50,8 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ steps.meta.outputs.commit }}
|
||||
DATE=${{ steps.meta.outputs.date }}
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
|
||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -25,6 +31,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Ensure Tag Commit Is On Default Branch
|
||||
env:
|
||||
|
||||
+6
-1
@@ -5,7 +5,7 @@ ENV CGO_ENABLED=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN git clone https://github.com/GMwalletApp/epusdt.git .
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app/src
|
||||
RUN go mod download
|
||||
@@ -14,10 +14,15 @@ RUN go build -o /app/epusdt .
|
||||
FROM alpine:latest AS runner
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
ARG API_RATE_URL=""
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/src/static /app/static
|
||||
COPY --from=builder /app/src/static /static
|
||||
COPY --from=builder /app/src/.env.example /app/.env
|
||||
RUN if [ -n "$API_RATE_URL" ]; then \
|
||||
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
|
||||
fi
|
||||
COPY --from=builder /app/epusdt .
|
||||
|
||||
VOLUME /app/conf
|
||||
|
||||
@@ -1,73 +1,170 @@
|
||||
## Epusdt (Easy Payment Usdt)
|
||||
# Epusdt — Easy Payment USDT
|
||||
|
||||
<p align="center">
|
||||
<img src="wiki/img/usdtlogo.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
|
||||
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
|
||||
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
|
||||
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
|
||||
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||
</p>
|
||||
|
||||
## 项目简介
|
||||
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
|
||||
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql`和`redis`
|
||||
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
|
||||
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
|
||||
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
|
||||
<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>
|
||||
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
|
||||
</p>
|
||||
|
||||
## 项目特点
|
||||
- 支持私有化部署,无需担心钱包被篡改和吞单😁
|
||||
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
|
||||
- 多钱包地址轮询,提高订单并发率
|
||||
- 异步队列响应,优雅及高性能
|
||||
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
|
||||
- 支持`http api`,其他系统亦可接入
|
||||
- `Telegram`机器人接入,便捷使用和支付消息快速通知
|
||||
<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 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.
|
||||
|
||||
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
|
||||
|
||||
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
|
||||
|
||||
### 🔗 已支持网络与代币
|
||||
|
||||
| 网络 | 代币 |
|
||||
|------|------|
|
||||
| **TRC20** (Tron) | USDT、TRX|
|
||||
| **ERC20** (Ethereum) | USDT、USDC、ETH |
|
||||
| **Solana** | USDT、USDC |
|
||||
| **BEP20** (BSC) | USDT、USDC、BNB |
|
||||
| **Polygon** | USDT、USDC |
|
||||
| **更多** | 持续扩展中… |
|
||||
|
||||
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 广泛兼容,即插即用
|
||||
|
||||
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
||||
|
||||
| 领域 | 已支持系统 |
|
||||
|------|-----------|
|
||||
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[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/)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
|
||||
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
|
||||
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
|
||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
|
||||
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
|
||||
- **异步队列** — 高性能消息回调,优雅处理高并发场景
|
||||
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
|
||||
- **Telegram Bot** — 实时支付通知,快捷管理与监控
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档与教程
|
||||
|
||||
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
|
||||
|
||||
快速入门:
|
||||
|
||||
| 教程 | 说明 |
|
||||
|------|------|
|
||||
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
|
||||
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
|
||||
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
|
||||
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
Epusdt
|
||||
├── plugins ---> (已集成的插件库,例如dujiaoka)
|
||||
├── src ---> (项目核心目录)
|
||||
├── sdk ---> (接入SDK)
|
||||
├── sql ---> (安装sql文件或更新sql文件)
|
||||
└── wiki ---> (知识库)
|
||||
├── plugins/ → 已集成的系统插件(独角数卡等)
|
||||
├── src/ → 项目核心代码
|
||||
├── sdk/ → 接入 SDK
|
||||
├── sql/ → 数据库安装 / 升级脚本
|
||||
└── wiki/ → 文档与知识库
|
||||
```
|
||||
|
||||
## 教程:
|
||||
- docker 运行教程 [docker-RUN](wiki/docker-RUN.md)
|
||||
- API 文档( 自定义汇率接口 ) [API](wiki/LEGACY_API_MIGRATION.md)
|
||||
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
|
||||
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
|
||||
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
|
||||
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
|
||||
---
|
||||
|
||||
## 已适配系统插件
|
||||
- 独角数卡[插件地址](plugins/dujiaoka)
|
||||
## 🔧 实现原理
|
||||
|
||||
## 加入交流/意见反馈
|
||||
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
|
||||
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
|
||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||
|
||||
## 设计实现
|
||||
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
|
||||
监听钱包地址`usdt`代币入账事件,通过`金额差异`和`时效性`来判定交易归属信息,
|
||||
可参考下方`流程图`
|
||||
```
|
||||
简单的原理:
|
||||
1.客户需要支付20.05usdt
|
||||
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
|
||||
3.发起支付的时候,我们可以判定钱包address_1的20.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟。
|
||||
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
|
||||
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功!
|
||||
工作流程:
|
||||
1. 客户发起支付,需支付 20.05 USDT
|
||||
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
||||
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
||||
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||
```
|
||||
### 流程图:
|
||||

|
||||
|
||||

|
||||
|
||||
## 声明
|
||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
||||
---
|
||||
|
||||
## 💬 社区与支持
|
||||
|
||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
||||
|
||||
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
||||
|
||||
| 渠道 | 链接 |
|
||||
|------|------|
|
||||
| 📢 **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) |
|
||||
|
||||
---
|
||||
|
||||
## 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>
|
||||
|
||||
---
|
||||
|
||||
## 📜 开源协议
|
||||
|
||||
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
||||
|
||||
---
|
||||
|
||||
<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 ·
|
||||
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: .
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @author GMWalletApp<ashang@utf8.hk>
|
||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @author GMWalletApp<ashang@utf8.hk>
|
||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
+7
-11
@@ -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
|
||||
@@ -50,14 +50,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=
|
||||
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
*.log
|
||||
@@ -3,12 +3,15 @@ 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/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 +21,43 @@ func InitApp() {
|
||||
config.Init()
|
||||
log.Init()
|
||||
dao.Init()
|
||||
// Wire settings-table lookups into the config package so
|
||||
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||
config.SettingsGetString = func(key string) string {
|
||||
return data.GetSettingString(key, "")
|
||||
}
|
||||
// Seed rate.api_url from .env into the settings table on first run
|
||||
// so the admin UI can display and change it without a code restart.
|
||||
// Only written if the key is not already present in the DB.
|
||||
if data.GetSettingString("rate.api_url", "") == "" {
|
||||
if envURL := config.GetRateApiUrlFromEnv(); envURL != "" {
|
||||
if err := data.SetSetting("rate", "rate.api_url", envURL, "string"); err != nil {
|
||||
color.Red.Printf("[bootstrap] seed rate.api_url err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed admin account and JWT secret so the management console is
|
||||
// immediately usable on a fresh install. Both are idempotent.
|
||||
_, isNew, err := data.EnsureDefaultAdmin()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||
}
|
||||
if isNew {
|
||||
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
|
||||
color.Yellow.Printf("║ Username: admin ║\n")
|
||||
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||
}
|
||||
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
|
||||
}
|
||||
// Seed one universal default API key on fresh installs. The seeded
|
||||
// key (PID=1000) works for all three gateway flows.
|
||||
_, err = data.EnsureDefaultApiKey()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
|
||||
}
|
||||
mq.Start()
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
+176
-21
@@ -3,18 +3,26 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// parseFloat is the minimal helper used by the settings bridge; kept
|
||||
// here so settings_bridge.go stays import-free.
|
||||
func parseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
var (
|
||||
HTTPAccessLog bool
|
||||
SQLDebug bool
|
||||
@@ -27,9 +35,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 +63,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 +78,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 +86,93 @@ func mustMkdir(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureConfiguredStaticFiles() error {
|
||||
if strings.TrimSpace(StaticFilePath) == "" {
|
||||
return nil
|
||||
}
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcDir := filepath.Join(filepath.Dir(exePath), "static")
|
||||
srcInfo, err := os.Stat(srcDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !srcInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
srcAbs, err := filepath.Abs(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstAbs, err := filepath.Abs(StaticFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srcAbs == dstAbs {
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyMissingStaticFiles(srcAbs, dstAbs)
|
||||
}
|
||||
|
||||
func copyMissingStaticFiles(srcDir, dstDir string) error {
|
||||
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstPath := filepath.Join(dstDir, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
if _, err = os.Stat(dstPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(srcPath, dstPath string) error {
|
||||
in, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func normalizeLogLevel(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug", "info", "warn", "error":
|
||||
@@ -124,6 +216,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 +321,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 +341,6 @@ func GetRateApiUrl() string {
|
||||
if rateURL == "" {
|
||||
log.Println("api_rate_url is empty")
|
||||
}
|
||||
RateApiUrl = rateURL
|
||||
return rateURL
|
||||
}
|
||||
|
||||
@@ -212,13 +362,14 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
if usdtRate > 0 {
|
||||
return 1 / usdtRate
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return getRateForCoinFromAPI(coin, base)
|
||||
}
|
||||
|
||||
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 +401,18 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
}
|
||||
|
||||
func GetUsdtRate() float64 {
|
||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
||||
if forcedUsdtRate > 0 {
|
||||
return forcedUsdtRate
|
||||
// Only the admin setting can force the USDT/CNY rate. When the
|
||||
// setting is unset, zero, or negative, fall back to the rate API.
|
||||
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||
return forced
|
||||
}
|
||||
if UsdtRate <= 0 {
|
||||
return 6.4
|
||||
|
||||
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||
if apiRate > 0 {
|
||||
return 1 / apiRate
|
||||
}
|
||||
return UsdtRate
|
||||
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
|
||||
return 0
|
||||
}
|
||||
|
||||
func GetOrderExpirationTime() int {
|
||||
|
||||
@@ -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,181 @@ 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 TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
apiCalled := false
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
apiCalled = true
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Status: http.StatusText(http.StatusInternalServerError),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "7.25",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
if got != 7.25 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||
}
|
||||
if apiCalled {
|
||||
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "-1",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
want := 1 / 0.14635
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
rate := GetRateForCoin("usdt", "cny")
|
||||
if math.Abs(rate-0.14635) > 1e-9 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Status: "502 Bad Gateway",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
if got := GetUsdtRate(); got != 0 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 0", got)
|
||||
}
|
||||
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
callCount := 0
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
callCount++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Status: "502 Bad Gateway",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("rate api call count = %d, want 1", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
// Settings lookups are injected at runtime by bootstrap (after DB init)
|
||||
// to keep the config package free of a `model/data` import, which would
|
||||
// create a cycle via `model/dao -> config`.
|
||||
//
|
||||
// If unset (e.g. during early startup or tests) the getters return the
|
||||
// zero string / 0 and callers apply their own fallback behavior.
|
||||
|
||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||
// reads from the settings table. Runtime-only.
|
||||
var SettingsGetString func(key string) string
|
||||
|
||||
func settingsRateApiUrl() string {
|
||||
if SettingsGetString == nil {
|
||||
return ""
|
||||
}
|
||||
return SettingsGetString("rate.api_url")
|
||||
}
|
||||
|
||||
func settingsForcedUsdtRate() float64 {
|
||||
if SettingsGetString == nil {
|
||||
return 0
|
||||
}
|
||||
raw := SettingsGetString("rate.forced_usdt_rate")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := parseFloat(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateApiKeyRequest is the payload for creating an API key.
|
||||
// A single key is valid for both gateway flows (epay/gmpay); there is
|
||||
// no gateway_type. PID is auto-generated (incrementing from 1000);
|
||||
// no manual override.
|
||||
type CreateApiKeyRequest struct {
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
|
||||
IpWhitelist string `json:"ip_whitelist" example:""`
|
||||
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// CreateApiKeyResponse is the response for a newly created API key.
|
||||
type CreateApiKeyResponse struct {
|
||||
ID uint64 `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"My API Key"`
|
||||
Pid string `json:"pid" example:"1003"`
|
||||
// SecretKey is returned ONCE on creation. After that, fetch via
|
||||
// GET /api-keys/:id/secret or rotate to generate a new one.
|
||||
SecretKey string `json:"secret_key" example:"secret123abc456"`
|
||||
}
|
||||
|
||||
// UpdateApiKeyRequest is the payload for updating an API key.
|
||||
type UpdateApiKeyRequest struct {
|
||||
Name *string `json:"name" example:"Updated Key Name"`
|
||||
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
|
||||
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
|
||||
type ChangeApiKeyStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
|
||||
// mdb.ApiKey json tag (`json:"-"`).
|
||||
// @Summary List API keys
|
||||
// @Description Returns all API keys
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [get]
|
||||
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
||||
rows, err := data.ListApiKeys()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateApiKey mints a new universal key. PID is auto-incremented from
|
||||
// the highest existing numeric PID (starting at 1000). Secret is
|
||||
// randomly generated and returned once.
|
||||
// @Summary Create API key
|
||||
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateApiKeyRequest true "API key payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [post]
|
||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||
req := new(CreateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Retry on unique-index violation: two concurrent creates could
|
||||
// both see the same max PID from NextPid() and race on INSERT.
|
||||
// The loser re-reads and retries with the now-higher max.
|
||||
const maxAttempts = 5
|
||||
secret := randomHex(32)
|
||||
var row *mdb.ApiKey
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
pid, err := data.NextPid()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row = &mdb.ApiKey{
|
||||
Name: req.Name,
|
||||
Pid: strconv.Itoa(pid),
|
||||
SecretKey: secret,
|
||||
IpWhitelist: req.IpWhitelist,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
err = data.CreateApiKey(row)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
}
|
||||
return c.SucJson(ctx, CreateApiKeyResponse{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Pid: row.Pid,
|
||||
SecretKey: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// isUniqueViolation matches the driver-specific strings for a unique-
|
||||
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
|
||||
// the PID retry loop; a cleaner approach would be a typed error but
|
||||
// GORM doesn't surface one portably.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unique") ||
|
||||
strings.Contains(msg, "duplicate") ||
|
||||
strings.Contains(msg, "constraint failed")
|
||||
}
|
||||
|
||||
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
|
||||
// and enable/disable have their own endpoints.
|
||||
// @Summary Update API key
|
||||
// @Description Patch API key name / ip_whitelist / notify_url
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.IpWhitelist != nil {
|
||||
fields["ip_whitelist"] = *req.IpWhitelist
|
||||
}
|
||||
if req.NotifyUrl != nil {
|
||||
fields["notify_url"] = *req.NotifyUrl
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatus toggles enable/disable.
|
||||
// @Summary Change API key status
|
||||
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(ChangeApiKeyStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
|
||||
// @Summary Rotate API key secret
|
||||
// @Description Generate a new secret_key. Returned only once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
|
||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
secret := randomHex(32)
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": secret})
|
||||
}
|
||||
|
||||
// DeleteApiKey soft-deletes a row. Orders created with this key will
|
||||
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
|
||||
// disabling via /status over deletion if there are live orders.
|
||||
// @Summary Delete API key
|
||||
// @Description Soft-delete an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// GetApiKeyStats returns usage stats for one key. Currently thin — just
|
||||
// the stored call_count and last_used_at — since we don't persist per-
|
||||
// call telemetry. Expanded later if/when we add a request log table.
|
||||
// @Summary Get API key stats
|
||||
// @Description Returns usage stats (call_count, last_used_at) for an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/stats [get]
|
||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"call_count": row.CallCount,
|
||||
"last_used_at": row.LastUsedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetApiKeySecret returns the secret_key for one API key. Requires
|
||||
// admin JWT authentication.
|
||||
// @Summary View API key secret
|
||||
// @Description Returns the secret_key for an API key (admin only)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=map[string]string}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/secret [get]
|
||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// LoginRequest is the payload for admin login.
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" validate:"required" example:"admin"`
|
||||
Password string `json:"password" validate:"required" example:"password123"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response for a successful login.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
|
||||
Username string `json:"username" example:"admin"`
|
||||
UserID uint64 `json:"user_id" example:"1"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest is the payload for changing admin password.
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" validate:"required" example:"old_pass"`
|
||||
NewPassword string `json:"new_password" validate:"required|minLen:6" example:"new_pass123"`
|
||||
}
|
||||
|
||||
// MeResponse wraps AdminUser with a default-password flag.
|
||||
type MeResponse struct {
|
||||
mdb.AdminUser
|
||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
||||
}
|
||||
|
||||
// InitialPasswordResponse is returned by the one-time initial password API.
|
||||
type InitialPasswordResponse struct {
|
||||
Username string `json:"username" example:"admin"`
|
||||
Password string `json:"password" example:"a1b2c3d4e5f6"`
|
||||
}
|
||||
|
||||
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
||||
// @Summary Admin login
|
||||
// @Description Verify credentials and return a signed JWT token
|
||||
// @Tags Admin Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.LoginResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/login [post]
|
||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
req := new(LoginRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
return c.FailJson(ctx, errors.New("invalid username or password"))
|
||||
}
|
||||
if user.Status != mdb.AdminUserStatusEnable {
|
||||
return c.FailJson(ctx, errors.New("user disabled"))
|
||||
}
|
||||
token, err := appjwt.Sign(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
err = data.TouchAdminUserLastLogin(user.ID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, LoginResponse{
|
||||
Token: token,
|
||||
Username: user.Username,
|
||||
UserID: user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPassword returns the one-time initial admin password.
|
||||
// @Summary Get initial admin password (one-time)
|
||||
// @Description Returns the initial random admin password once, then invalidates it.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password [get]
|
||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||
password, err := data.ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, InitialPasswordResponse{
|
||||
Username: "admin",
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPasswordHash returns the initial-password fingerprint so the
|
||||
// frontend can warn until the password is changed.
|
||||
// @Summary Get initial admin password hash
|
||||
// @Description Returns the initial password hash and password-changed flag.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password-hash [get]
|
||||
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
|
||||
info, err := data.GetInitialAdminPasswordHashInfo()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, info)
|
||||
}
|
||||
|
||||
// Logout is a no-op stub — tokens are stateless and the frontend
|
||||
// discards them. Kept for API symmetry with the documented spec.
|
||||
// @Summary Admin logout
|
||||
// @Description Logout (no-op, frontend discards token)
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/logout [post]
|
||||
func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Me returns the currently authenticated admin user profile.
|
||||
// @Summary Get current admin profile
|
||||
// @Description Returns the currently authenticated admin user
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.MeResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/me [get]
|
||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("user not found"))
|
||||
}
|
||||
// Warn the frontend when the operator hasn't changed the seeded
|
||||
// initial password.
|
||||
isDefault := data.IsUsingInitialAdminPassword()
|
||||
return c.SucJson(ctx, MeResponse{
|
||||
AdminUser: *user,
|
||||
PasswordIsDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword requires the old password, updates to the new one.
|
||||
// @Summary Change admin password
|
||||
// @Description Change the current admin user's password
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.ChangePasswordRequest true "Password payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/password [post]
|
||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
}
|
||||
req := new(ChangePasswordRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||
return c.FailJson(ctx, errors.New("old password incorrect"))
|
||||
}
|
||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -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/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// UpdateChainRequest is the payload for updating chain settings.
|
||||
type UpdateChainRequest struct {
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinConfirmations *int `json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec *int `json:"scan_interval_sec" example:"5"`
|
||||
DisplayName *string `json:"display_name" example:"Tron"`
|
||||
}
|
||||
|
||||
// ListChains returns the chains table (tron/ethereum/solana seeded; new
|
||||
// networks like bsc/polygon inserted manually once supported).
|
||||
// @Summary List chains
|
||||
// @Description Returns all supported blockchain networks
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Chain}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains [get]
|
||||
func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
||||
rows, err := data.ListChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpdateChain toggles enabled / tweaks scan params. Scanners re-read the
|
||||
// chains table on every poll tick, so changes take effect within one
|
||||
// cycle without a restart.
|
||||
// @Summary Update chain
|
||||
// @Description Toggle enabled / tweak scan params for a chain
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
|
||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains/{network} [patch]
|
||||
func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
||||
network := ctx.Param("network")
|
||||
req := new(UpdateChainRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinConfirmations != nil {
|
||||
fields["min_confirmations"] = *req.MinConfirmations
|
||||
}
|
||||
if req.ScanIntervalSec != nil {
|
||||
fields["scan_interval_sec"] = *req.ScanIntervalSec
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
fields["display_name"] = *req.DisplayName
|
||||
}
|
||||
if err := data.UpdateChainFields(network, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateChainTokenRequest is the payload for creating a chain token.
|
||||
type CreateChainTokenRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
|
||||
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// UpdateChainTokenRequest is the payload for updating a chain token.
|
||||
type UpdateChainTokenRequest struct {
|
||||
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals *int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount *float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatusRequest is the payload for toggling chain token status.
|
||||
type ChangeChainTokenStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListChainTokens returns rows, optionally filtered by network.
|
||||
// @Summary List chain tokens
|
||||
// @Description Returns chain tokens, optionally filtered by network
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [get]
|
||||
func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
|
||||
// @Summary Create chain token
|
||||
// @Description Create a new chain token entry
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateChainTokenRequest true "Chain token payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [post]
|
||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||
req := new(CreateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
decimals := req.Decimals
|
||||
if decimals == 0 {
|
||||
decimals = 6
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.ChainToken{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Symbol: strings.ToUpper(strings.TrimSpace(req.Symbol)),
|
||||
ContractAddress: strings.TrimSpace(req.ContractAddress),
|
||||
Decimals: decimals,
|
||||
Enabled: enabled,
|
||||
MinAmount: req.MinAmount,
|
||||
}
|
||||
if err := data.CreateChainToken(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateChainToken patches mutable columns.
|
||||
// @Summary Update chain token
|
||||
// @Description Patch chain token fields
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.UpdateChainTokenRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.ContractAddress != nil {
|
||||
fields["contract_address"] = *req.ContractAddress
|
||||
}
|
||||
if req.Decimals != nil {
|
||||
fields["decimals"] = *req.Decimals
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
fields["min_amount"] = *req.MinAmount
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatus toggles enable/disable.
|
||||
// @Summary Change chain token status
|
||||
// @Description Toggle enable/disable for a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.ChangeChainTokenStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(ChangeChainTokenStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteChainToken soft-deletes the row.
|
||||
// @Summary Delete chain token
|
||||
// @Description Soft-delete a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OverviewResponse is the dashboard top-card response.
|
||||
type OverviewResponse struct {
|
||||
TotalAsset float64 `json:"total_asset" example:"12345.6789"`
|
||||
Volume float64 `json:"volume" example:"100.5"`
|
||||
OrderCount int64 `json:"order_count" example:"42"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
SevenDayVolume float64 `json:"seven_day_volume" example:"700.25"`
|
||||
ActiveAddresses int64 `json:"active_addresses" example:"5"`
|
||||
OnlineChains int64 `json:"online_chains" example:"3"`
|
||||
}
|
||||
|
||||
// OrderStatsResponse is the order statistics response.
|
||||
type OrderStatsResponse struct {
|
||||
OrderCount int64 `json:"order_count" example:"100"`
|
||||
SuccessCount int64 `json:"success_count" example:"85"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
AvgPaymentSeconds float64 `json:"avg_payment_seconds" example:"120.5"`
|
||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||
}
|
||||
|
||||
// RpcHealthCheckResponse is the response for a health check probe.
|
||||
type RpcHealthCheckResponse struct {
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `json:"status" example:"ok" enums:"unknown,ok,down"`
|
||||
LastLatencyMs int `json:"last_latency_ms" example:"120"`
|
||||
}
|
||||
|
||||
// ApiKeyStatsResponse is the response for API key usage stats.
|
||||
type ApiKeyStatsResponse struct {
|
||||
CallCount int64 `json:"call_count" example:"342"`
|
||||
LastUsedAt string `json:"last_used_at" example:"2026-04-14T08:30:00+00:00"`
|
||||
}
|
||||
|
||||
// RotateSecretResponse is returned when rotating an API key secret.
|
||||
type RotateSecretResponse struct {
|
||||
SecretKey string `json:"secret_key" example:"newSecret123abc456"`
|
||||
}
|
||||
|
||||
// BatchImportResult is one row in the batch import response.
|
||||
type BatchImportResult struct {
|
||||
Address string `json:"address" example:"TTestTronAddress001"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
}
|
||||
|
||||
// SettingsUpsertResult is one row in the settings upsert response.
|
||||
type SettingsUpsertResult struct {
|
||||
Key string `json:"key" example:"rate.api_url"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
}
|
||||
|
||||
// Overview returns the top-card numbers for the dashboard landing page.
|
||||
// All values are computed with on-demand SQL — no cache — per the design.
|
||||
// The selected time range (range / start_at / end_at) is applied to
|
||||
// volume, order_count, success_rate and active_addresses.
|
||||
// seven_day_volume always covers the rolling past 7 days.
|
||||
// total_asset and online_chains are time-independent.
|
||||
// @Summary Dashboard overview
|
||||
// @Description Returns top-card numbers: total asset, volume, success rate, active addresses, etc. Supports ?range= for filtering.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OverviewResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/overview [get]
|
||||
func (c *BaseAdminController) Overview(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
|
||||
now := time.Now()
|
||||
sevenDaysAgo := startOfDay(now).AddDate(0, 0, -6)
|
||||
|
||||
totalActual, _ := data.SumPaidActualAmount()
|
||||
|
||||
orderCount, successCount, volume, _ := data.PaidStatsInRange(start, end)
|
||||
var successRate float64
|
||||
if orderCount > 0 {
|
||||
successRate = float64(successCount) / float64(orderCount)
|
||||
}
|
||||
|
||||
_, _, sevenDayActual, _ := data.PaidStatsInRange(sevenDaysAgo, now)
|
||||
|
||||
activeAddr, _ := data.ActiveAddressCountInRange(start, end)
|
||||
onlineChains, _ := data.CountEnabledChains()
|
||||
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"total_asset": totalActual,
|
||||
"volume": volume,
|
||||
"order_count": orderCount,
|
||||
"success_rate": successRate,
|
||||
"seven_day_volume": sevenDayActual,
|
||||
"active_addresses": activeAddr,
|
||||
"online_chains": onlineChains,
|
||||
})
|
||||
}
|
||||
|
||||
// isHourlyRange returns true when the time window spans ≤ 1 day,
|
||||
// so callers can choose hourly vs daily granularity.
|
||||
func isHourlyRange(start, end time.Time) bool {
|
||||
return end.Sub(start) <= 24*time.Hour
|
||||
}
|
||||
|
||||
// AssetTrend returns per-day actual_amount for paid orders. group_by=
|
||||
// "total" yields one series; "address" yields rows keyed by
|
||||
// receive_address for the stacked chart.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Asset trend
|
||||
// @Description Returns per-period actual_amount for paid orders. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Param group_by query string false "Group by: total or address" default(total)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/asset-trend [get]
|
||||
func (c *BaseAdminController) AssetTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
groupBy := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group_by")))
|
||||
hourly := isHourlyRange(start, end)
|
||||
if groupBy == "address" {
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyAssetByAddress(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyAssetByAddress(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// RevenueTrend mirrors the daily stats view used for revenue charts.
|
||||
// Same data shape as AssetTrend(group_by=total) but kept as its own
|
||||
// endpoint so the frontend can evolve each chart independently.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Revenue trend
|
||||
// @Description Returns per-period revenue stats for charts. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/revenue-trend [get]
|
||||
func (c *BaseAdminController) RevenueTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
var rows interface{}
|
||||
var err error
|
||||
if isHourlyRange(start, end) {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// OrderStats returns aggregate order figures for the selected window:
|
||||
// total count, success rate, average payment duration (seconds), and
|
||||
// expired-unpaid count.
|
||||
// @Summary Order statistics
|
||||
// @Description Returns aggregate order figures: count, success rate, avg payment time, expired count
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/order-stats [get]
|
||||
func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
total, success, _, err := data.PaidStatsInRange(start, end)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
avg, _ := data.AveragePaymentDurationSeconds(start, end)
|
||||
expired, _ := data.CountExpiredInRange(start, end)
|
||||
|
||||
var rate float64
|
||||
if total > 0 {
|
||||
rate = float64(success) / float64(total)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"order_count": total,
|
||||
"success_count": success,
|
||||
"success_rate": rate,
|
||||
"avg_payment_seconds": avg,
|
||||
"expired_unpaid_count": expired,
|
||||
})
|
||||
}
|
||||
|
||||
// RecentOrders returns the last N orders for the dashboard list card.
|
||||
// @Summary Recent orders
|
||||
// @Description Returns the last N orders for the dashboard
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of orders to return" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/recent-orders [get]
|
||||
func (c *BaseAdminController) RecentOrders(ctx echo.Context) error {
|
||||
limit := 20
|
||||
if s := ctx.QueryParam("limit"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := data.RecentOrders(limit)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// parseRange maps the ?range=... shorthand to a concrete [start, end].
|
||||
// Accepted: today / 7d / 30d / custom (with start_at/end_at Unix secs).
|
||||
// Defaults to today.
|
||||
func parseRange(ctx echo.Context) (time.Time, time.Time) {
|
||||
rng := strings.ToLower(strings.TrimSpace(ctx.QueryParam("range")))
|
||||
now := time.Now()
|
||||
todayStart := startOfDay(now)
|
||||
switch rng {
|
||||
case "7d":
|
||||
return todayStart.AddDate(0, 0, -6), now
|
||||
case "30d":
|
||||
return todayStart.AddDate(0, 0, -29), now
|
||||
case "custom":
|
||||
start := todayStart
|
||||
end := now
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
start = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
end = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
default:
|
||||
return todayStart, now
|
||||
}
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateNotificationChannelRequest is the payload for creating a notification channel.
|
||||
type CreateNotificationChannelRequest struct {
|
||||
Type string `json:"type" validate:"required|in:telegram,webhook,email" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"主通知群"`
|
||||
Config map[string]interface{} `json:"config" validate:"required"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelRequest is the payload for updating a notification channel.
|
||||
type UpdateNotificationChannelRequest struct {
|
||||
Name *string `json:"name" example:"更新后的名称"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ChangeChannelStatusRequest is the payload for toggling notification channel status.
|
||||
type ChangeChannelStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListNotificationChannels lists rows, optionally filtered by type.
|
||||
// @Summary List notification channels
|
||||
// @Description Returns notification channels, optionally filtered by type
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param type query string false "Channel type: telegram, webhook, email"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [get]
|
||||
func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
|
||||
channelType := strings.ToLower(strings.TrimSpace(ctx.QueryParam("type")))
|
||||
rows, err := data.ListNotificationChannels(channelType)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateNotificationChannel validates Config shape per type (only
|
||||
// telegram is strongly validated today) and inserts a row.
|
||||
// @Summary Create notification channel
|
||||
// @Description Create a new notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateNotificationChannelRequest true "Channel payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [post]
|
||||
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||
req := new(CreateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := validateChannelConfig(req.Type, req.Config); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.NotificationChannel{
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := data.CreateNotificationChannel(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(row.Type)) == mdb.NotificationTypeTelegram {
|
||||
telegram.ReloadBotAsync("admin create telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel patches name/config/events/enabled.
|
||||
// @Summary Update notification channel
|
||||
// @Description Patch notification channel fields
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.UpdateNotificationChannelRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(UpdateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.Config != nil {
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
fields["config"] = string(configJSON)
|
||||
}
|
||||
if req.Events != nil {
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
fields["events"] = string(eventsJSON)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin update telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeNotificationChannelStatus toggles enabled.
|
||||
// @Summary Change notification channel status
|
||||
// @Description Toggle enable/disable for a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.ChangeChannelStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(ChangeChannelStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin change telegram channel status")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel soft-deletes the row.
|
||||
// @Summary Delete notification channel
|
||||
// @Description Soft-delete a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
if err := data.DeleteNotificationChannelByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin delete telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// validateChannelConfig enforces minimum shape. Telegram needs bot_token
|
||||
// + chat_id; webhook/email kept loose until those senders exist.
|
||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config required")
|
||||
}
|
||||
if channelType == mdb.NotificationTypeTelegram {
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
if raw == nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
toFlag := func(v interface{}) (bool, error) {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, nil
|
||||
case float64:
|
||||
return t != 0, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
switch s {
|
||||
case "", "0", "false", "off", "no":
|
||||
return false, nil
|
||||
case "1", "true", "on", "yes":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("invalid boolean string")
|
||||
}
|
||||
default:
|
||||
return false, errors.New("invalid event value type")
|
||||
}
|
||||
}
|
||||
|
||||
trimKey := func(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
out := make(map[string]bool)
|
||||
switch t := raw.(type) {
|
||||
case map[string]bool:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
case map[string]interface{}:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
flag, err := toFlag(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = flag
|
||||
}
|
||||
return out, nil
|
||||
case []interface{}:
|
||||
for _, v := range t {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("events array must contain strings")
|
||||
}
|
||||
s = trimKey(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
out[s] = true
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, errors.New("events must be object or string array")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// MarkPaidRequest is the payload for manually marking an order as paid.
|
||||
type MarkPaidRequest struct {
|
||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||
}
|
||||
|
||||
// OrderListResponse wraps the paginated order list.
|
||||
type OrderListResponse struct {
|
||||
List []mdb.Orders `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// OrderWithSub embeds a parent order together with its child orders.
|
||||
type OrderWithSub struct {
|
||||
mdb.Orders
|
||||
SubOrders []mdb.Orders `json:"sub_orders"`
|
||||
}
|
||||
|
||||
// OrderWithSubListResponse wraps the paginated list-with-sub result.
|
||||
type OrderWithSubListResponse struct {
|
||||
List []OrderWithSub `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// ListOrders handles the admin order list with filters + pagination.
|
||||
// @Summary List orders
|
||||
// @Description Returns paginated orders with optional filters
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders [get]
|
||||
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"list": rows,
|
||||
"total": total,
|
||||
"page": f.Page,
|
||||
"page_size": f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrder returns full order detail by trade_id.
|
||||
// @Summary Get order
|
||||
// @Description Returns full order detail by trade_id
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id} [get]
|
||||
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
return c.SucJson(ctx, order)
|
||||
}
|
||||
|
||||
// CloseOrder flips a waiting order to expired and releases its lock.
|
||||
// @Summary Close order
|
||||
// @Description Manually close a waiting order (mark as expired)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/close [post]
|
||||
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
}
|
||||
ok, err := data.CloseOrderManually(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
||||
}
|
||||
// Release the transaction lock so the amount slot becomes reusable.
|
||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
|
||||
// same OrderProcessing path as on-chain confirmation so downstream
|
||||
// callback + notification fire normally.
|
||||
// @Summary Mark order paid
|
||||
// @Description Manually mark a waiting order as paid (operator补单)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
req := new(MarkPaidRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
}
|
||||
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Currency: order.Currency,
|
||||
Token: order.Token,
|
||||
Network: order.Network,
|
||||
Amount: order.ActualAmount,
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: req.BlockTransactionId,
|
||||
})
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ResendCallback requeues the order callback by flipping
|
||||
// callback_confirm back to NO. The mq worker will grab it on the
|
||||
// next poll tick.
|
||||
// @Summary Resend callback
|
||||
// @Description Re-queue the order callback notification
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
ok, err := data.ReopenOrderCallback(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ExportOrders streams matching orders as CSV. No pagination — the
|
||||
// caller drives what rows to include via filter params. Capped at
|
||||
// 10k rows to keep the response bounded.
|
||||
// @Summary Export orders as CSV
|
||||
// @Description Stream matching orders as a CSV file (max 10k rows)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce text/csv
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Success 200 {string} string "CSV file"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/export [get]
|
||||
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
f.Page = 1
|
||||
f.PageSize = 10000
|
||||
rows, _, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp := ctx.Response()
|
||||
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
|
||||
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
|
||||
w := csv.NewWriter(resp.Writer)
|
||||
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.TradeId,
|
||||
r.OrderId,
|
||||
fmt.Sprintf("%.4f", r.Amount),
|
||||
fmt.Sprintf("%.4f", r.ActualAmount),
|
||||
r.Currency,
|
||||
r.Token,
|
||||
r.Network,
|
||||
r.ReceiveAddress,
|
||||
strconv.Itoa(r.Status),
|
||||
r.BlockTransactionId,
|
||||
r.CreatedAt.ToDateTimeString(),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOrderFilter extracts filter + pagination values from the query
|
||||
// string. Time params are Unix seconds.
|
||||
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
|
||||
f := data.OrderListFilter{
|
||||
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
|
||||
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
|
||||
Address: strings.TrimSpace(ctx.QueryParam("address")),
|
||||
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
|
||||
}
|
||||
if s := ctx.QueryParam("status"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Status = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.StartAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.EndAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Page = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page_size"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.PageSize = n
|
||||
}
|
||||
}
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.PageSize < 1 {
|
||||
f.PageSize = 20
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ListOrdersWithSub returns the same paginated order list as ListOrders but
|
||||
// each row has a sub_orders field containing all child orders under it.
|
||||
// @Summary List orders with sub-orders
|
||||
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/list-with-sub [get]
|
||||
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
|
||||
f.ParentOnly = true
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Batch-fetch all sub-orders for the current page in one query.
|
||||
tradeIds := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tradeIds = append(tradeIds, r.TradeId)
|
||||
}
|
||||
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Group sub-orders by parent_trade_id.
|
||||
subMap := make(map[string][]mdb.Orders, len(subs))
|
||||
for _, s := range subs {
|
||||
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
|
||||
}
|
||||
|
||||
list := make([]OrderWithSub, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
children := subMap[r.TradeId]
|
||||
if children == nil {
|
||||
children = []mdb.Orders{}
|
||||
}
|
||||
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, OrderWithSubListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: f.Page,
|
||||
PageSize: f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderWithSub returns a single parent order together with all its child
|
||||
// orders (any status), regardless of whether the caller passes a parent or
|
||||
// child trade_id — if it is a child, the parent is resolved first.
|
||||
// @Summary Get order with sub-orders
|
||||
// @Description Returns an order and its child orders embedded in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
|
||||
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if subOrders == nil {
|
||||
subOrders = []mdb.Orders{}
|
||||
}
|
||||
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||
type CreateRpcNodeRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
||||
Weight int `json:"weight" example:"1"`
|
||||
ApiKey string `json:"api_key" example:""`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||
type UpdateRpcNodeRequest struct {
|
||||
Url *string `json:"url" example:"https://api.trongrid.io"`
|
||||
Weight *int `json:"weight" example:"1"`
|
||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
// @Summary List RPC nodes
|
||||
// @Description Returns RPC nodes, optionally filtered by network
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [get]
|
||||
func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListRpcNodes(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row. Status starts as "unknown" until the
|
||||
// health-check task runs.
|
||||
// @Summary Create RPC node
|
||||
// @Description Create a new RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateRpcNodeRequest true "RPC node payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [post]
|
||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
req := new(CreateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
weight := req.Weight
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.RpcNode{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Url: strings.TrimSpace(req.Url),
|
||||
Type: strings.ToLower(strings.TrimSpace(req.Type)),
|
||||
Weight: weight,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: enabled,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
LastLatencyMs: -1,
|
||||
}
|
||||
if err := data.CreateRpcNode(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateRpcNode patches url/weight/api_key/enabled.
|
||||
// @Summary Update RPC node
|
||||
// @Description Patch RPC node fields
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Param request body admin.UpdateRpcNodeRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Url != nil {
|
||||
fields["url"] = *req.Url
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
}
|
||||
if req.ApiKey != nil {
|
||||
fields["api_key"] = *req.ApiKey
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteRpcNode soft-deletes the row.
|
||||
// @Summary Delete RPC node
|
||||
// @Description Soft-delete an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||
// For HTTP endpoints this is a GET to the URL with a short timeout; for
|
||||
// WS we just attempt a TCP-level check via the same HTTP client (the
|
||||
// handshake URL resolves the host identically).
|
||||
// @Summary Health check RPC node
|
||||
// @Description Perform an on-demand health probe on an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RpcHealthCheckResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id}/health-check [post]
|
||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("node not found"))
|
||||
}
|
||||
status, latency := task.ProbeNode(row.Url)
|
||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latency,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// SettingUpsertItem is a single setting entry for batch upsert.
|
||||
// Supported groups and keys:
|
||||
//
|
||||
// - group=rate:
|
||||
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
|
||||
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
|
||||
// rate.adjust_percent (float) — rate adjustment percentage
|
||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||
//
|
||||
// - group=epay:
|
||||
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
|
||||
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
|
||||
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
|
||||
//
|
||||
// - group=brand:
|
||||
// brand.site_name (string) — site display name
|
||||
// brand.logo_url (string) — logo image URL
|
||||
// brand.page_title (string) — payment page title
|
||||
// brand.pay_success_text (string) — text shown on payment success
|
||||
// brand.support_url (string) — support / help URL
|
||||
//
|
||||
// - group=system:
|
||||
// system.order_expiration_time (int) — order expiry in minutes
|
||||
type SettingUpsertItem struct {
|
||||
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value string `json:"value" example:"tron"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
}
|
||||
|
||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||
type SettingsUpsertRequest struct {
|
||||
Items []SettingUpsertItem `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
// ListSettings returns all rows, optionally filtered by group.
|
||||
// @Summary List settings
|
||||
// @Description Returns all settings, optionally filtered by group.
|
||||
// @Description Available groups: brand, rate, system, epay.
|
||||
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param group query string false "Group filter (brand|rate|system|epay)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [get]
|
||||
func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
group := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group")))
|
||||
rows, err := data.ListSettingsByGroup(group)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpsertSettings batch-inserts / updates rows. Each item is treated
|
||||
// independently so a malformed row in the middle doesn't drop earlier
|
||||
// ones. Errors are returned per-key so the UI can surface them.
|
||||
// @Summary Upsert settings
|
||||
// @Description Batch insert/update settings. Returns per-key status.
|
||||
// @Description Supported groups: brand, rate, system, epay.
|
||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
||||
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
||||
// @Description system group keys: system.order_expiration_time.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.SettingsUpsertRequest true "Settings payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.SettingsUpsertResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [put]
|
||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||
req := new(SettingsUpsertRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Key string `json:"key"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
|
||||
continue
|
||||
}
|
||||
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
|
||||
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
out = append(out, result{Key: key, OK: true})
|
||||
}
|
||||
|
||||
// When telegram credentials are updated via settings, reload the
|
||||
// command bot so operators don't need to restart the process, and
|
||||
// sync the notification_channels row so the notify dispatcher picks
|
||||
// up the new values immediately.
|
||||
telegramKeys := map[string]bool{
|
||||
"system.telegram_bot_token": true,
|
||||
"system.telegram_chat_id": true,
|
||||
"system.telegram_payment_notice_enabled": true,
|
||||
"system.telegram_abnormal_notice_enabled": true,
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
if telegramKeys[strings.TrimSpace(item.Key)] {
|
||||
telegram.ReloadBotAsync("settings upsert")
|
||||
go dao.SyncTelegramChannelFromSettings()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
// DeleteSetting removes one row. The next read of that key will fall
|
||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||
// @Summary Delete setting
|
||||
// @Description Remove a setting by key (falls back to default)
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param key path string true "Setting key"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings/{key} [delete]
|
||||
func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
||||
key := strings.TrimSpace(ctx.Param("key"))
|
||||
if key == "" {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := data.DeleteSetting(key); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Public helper for the rate/usdt overrides — used by config package to
|
||||
// read settings-backed values without importing the controller package.
|
||||
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
|
||||
@@ -0,0 +1,279 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AdminAddWalletRequest is the payload for adding a wallet via admin.
|
||||
type AdminAddWalletRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
|
||||
Remark string `json:"remark" example:"主钱包"`
|
||||
}
|
||||
|
||||
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
|
||||
type AdminUpdateWalletRequest struct {
|
||||
Remark *string `json:"remark" example:"已更新的备注"`
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
|
||||
type AdminChangeWalletStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// AdminBatchImportRequest is the payload for batch importing wallets.
|
||||
type AdminBatchImportRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
// Addresses listed as observation-only wallets. Private-key import
|
||||
// and API-managed modes are out of scope for v1.
|
||||
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
|
||||
}
|
||||
|
||||
// WalletListItem extends WalletAddress with order count.
|
||||
type WalletListItem struct {
|
||||
mdb.WalletAddress
|
||||
OrderCount int64 `json:"order_count" example:"5"`
|
||||
}
|
||||
|
||||
// AdminListWallets extends the read model with per-wallet order counts
|
||||
// so the UI can render the "订单数" column without an N+1 follow-up.
|
||||
// @Summary List wallets (admin)
|
||||
// @Description Returns wallets with per-wallet order counts
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
|
||||
// @Param keyword query string false "Search by address or remark"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [get]
|
||||
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
status := strings.TrimSpace(ctx.QueryParam("status"))
|
||||
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
|
||||
|
||||
tx := dao.Mdb.Model(&mdb.WalletAddress{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", network)
|
||||
}
|
||||
if status != "" {
|
||||
if s, err := strconv.Atoi(status); err == nil {
|
||||
tx = tx.Where("status = ?", s)
|
||||
}
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := "%" + keyword + "%"
|
||||
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
|
||||
}
|
||||
var rows []mdb.WalletAddress
|
||||
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
counts, err := data.CountOrdersByAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
out := make([]WalletListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
// AdminAddWallet wraps the existing create with admin-exposed Remark +
|
||||
// Source=manual so audit fields are populated.
|
||||
// @Summary Add wallet (admin)
|
||||
// @Description Create a wallet with admin-specific fields (remark, source=manual)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [post]
|
||||
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||
req := new(AdminAddWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
// Patch remark/source separately to avoid changing the existing
|
||||
// package-level AddWalletAddressWithNetwork signature (used by
|
||||
// Telegram bot + legacy endpoint).
|
||||
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
|
||||
if req.Remark != "" {
|
||||
fields["remark"] = req.Remark
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
|
||||
row.Remark = req.Remark
|
||||
row.Source = mdb.WalletSourceManual
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// AdminGetWallet returns detail including order count for one wallet.
|
||||
// @Summary Get wallet (admin)
|
||||
// @Description Returns wallet detail with order count
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [get]
|
||||
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
wallet, err := data.GetWalletAddressById(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if wallet.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("wallet not found"))
|
||||
}
|
||||
counts, _ := data.CountOrdersByAddress()
|
||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||
}
|
||||
|
||||
// AdminUpdateWallet only patches remark (status has its own endpoint).
|
||||
// @Summary Update wallet (admin)
|
||||
// @Description Patch wallet remark
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [patch]
|
||||
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(AdminUpdateWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Remark != nil {
|
||||
fields["remark"] = *req.Remark
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatus reuses existing helper.
|
||||
// @Summary Change wallet status (admin)
|
||||
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id}/status [post]
|
||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(AdminChangeWalletStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminDeleteWallet soft-deletes the wallet.
|
||||
// @Summary Delete wallet (admin)
|
||||
// @Description Soft-delete a wallet
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [delete]
|
||||
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminBatchImportWallets accepts a list of addresses and creates
|
||||
// them all as observation wallets. Per-row failures are collected and
|
||||
// returned so the UI can surface which addresses already existed.
|
||||
// @Summary Batch import wallets
|
||||
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/batch-import [post]
|
||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||
req := new(AdminBatchImportRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Address string `json:"address"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Addresses))
|
||||
for _, addr := range req.Addresses {
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||
if err != nil {
|
||||
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||
Update("source", mdb.WalletSourceImport).Error
|
||||
out = append(out, result{Address: addr, OK: true})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/assimon/luuu/util/http"
|
||||
"github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/gookit/validate"
|
||||
"github.com/gookit/validate/locales/zhcn"
|
||||
"github.com/gookit/validate/locales/zhtw"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package comm
|
||||
|
||||
import "github.com/assimon/luuu/controller"
|
||||
import "github.com/GMWalletApp/epusdt/controller"
|
||||
|
||||
var Ctrl = &BaseCommController{}
|
||||
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"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).
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||
// @Param order_id formData string false "Merchant order ID"
|
||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||
// @Param token formData string false "Crypto token (e.g. usdt)"
|
||||
// @Param network formData string false "Network (e.g. tron)"
|
||||
// @Param amount formData number false "Amount"
|
||||
// @Param notify_url formData string false "Callback URL"
|
||||
// @Param signature formData string false "MD5 signature"
|
||||
// @Param redirect_url formData string false "Redirect URL"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param payment_type formData string false "Payment type"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
@@ -16,9 +51,95 @@ 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 切换支付网络,创建或返回子订单
|
||||
// @Summary Switch payment network
|
||||
// @Description Switch to a different payment network, creating or returning a sub-order
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /pay/switch-network [post]
|
||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
req := new(request.SwitchNetworkRequest)
|
||||
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)
|
||||
}
|
||||
resp, err := service.SwitchNetwork(req)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
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.
|
||||
// @Tags Payment
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param pid query integer false "API key PID (GET query)"
|
||||
// @Param money query number false "Amount (fiat, GET query)"
|
||||
// @Param out_trade_no query string false "Merchant order ID (GET query)"
|
||||
// @Param notify_url query string false "Callback URL (GET query)"
|
||||
// @Param return_url query string false "Redirect URL after payment (GET query)"
|
||||
// @Param name query string false "Order name (GET query)"
|
||||
// @Param type query string false "Payment type (e.g. alipay, GET query)"
|
||||
// @Param sign query string false "MD5 signature (GET query)"
|
||||
// @Param sign_type query string false "Signature type (MD5, GET query)"
|
||||
// @Param pid formData integer true "API key PID"
|
||||
// @Param money formData number true "Amount (fiat)"
|
||||
// @Param out_trade_no formData string true "Merchant order ID"
|
||||
// @Param notify_url formData string true "Callback URL"
|
||||
// @Param return_url formData string false "Redirect URL after payment"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param type formData string false "Payment type (e.g. alipay)"
|
||||
// @Param sign formData string true "MD5 signature"
|
||||
// @Param sign_type formData string false "Signature type (MD5)"
|
||||
// @Success 302 "Redirect to checkout counter"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
log.Sugar.Errorf("bind request error: %v", err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
log.Sugar.Errorf("validate request error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("create transaction error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("create transaction response: %+v", resp)
|
||||
|
||||
tradeID := resp.TradeId
|
||||
return ctx.Redirect(http.StatusFound, "/pay/checkout-counter/"+tradeID)
|
||||
}
|
||||
|
||||
@@ -5,36 +5,54 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckoutCounter 收银台
|
||||
// @Summary Checkout counter page
|
||||
// @Description Render the payment checkout counter HTML page for a given trade
|
||||
// @Tags Payment
|
||||
// @Produce html
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {string} string "HTML page"
|
||||
// @Router /pay/checkout-counter/{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())
|
||||
// Unknown trade id: render the page with empty payload
|
||||
// (client side shows a friendly "order not found" screen).
|
||||
tmpl, tmplErr := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||
if tmplErr != nil {
|
||||
return ctx.String(http.StatusInternalServerError, tmplErr.Error())
|
||||
}
|
||||
ctx.Response().Status = http.StatusNotFound
|
||||
emptyResp := response.CheckoutCounterResponse{}
|
||||
return tmpl.Execute(ctx.Response(), emptyResp)
|
||||
}
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
resp.Network = "TRON"
|
||||
|
||||
return tmpl.Execute(ctx.Response(), resp)
|
||||
}
|
||||
|
||||
// CheckStatus 支付状态检测
|
||||
// @Summary Check payment status
|
||||
// @Description Check the payment status of an order by trade ID
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /pay/check-status/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
order, err := service.GetOrderInfoByTradeId(tradeId)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台
|
||||
// 的 chains + chain_tokens + wallet_address 三张表的实时查询,admin
|
||||
// 启用/禁用链或代币后,这个接口在下次调用时就会反映新状态(无缓存)。
|
||||
// 仅返回:链 enabled=true、代币 enabled=true、且该链上至少有一个可用
|
||||
// 钱包地址的组合。
|
||||
// @Summary List supported (network, tokens) groupings
|
||||
// @Description Public endpoint, Auth required in /admin/api/v1/supported-assets
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=response.SupportedAssetsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets [get]
|
||||
// @Router /admin/api/v1/supported-assets [get]
|
||||
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
||||
chains, err := data.ListEnabledChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
wallets, err := data.GetAvailableWalletAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// A chain only qualifies if it has at least one wallet available —
|
||||
// without a recipient address, payments on that chain can't be
|
||||
// processed even if the chain + token config says "enabled".
|
||||
networkHasWallet := make(map[string]struct{})
|
||||
for _, w := range wallets {
|
||||
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
||||
}
|
||||
|
||||
supports := make([]response.NetworkTokenSupport, 0, len(chains))
|
||||
for _, ch := range chains {
|
||||
network := strings.ToLower(ch.Network)
|
||||
if _, ok := networkHasWallet[network]; !ok {
|
||||
continue
|
||||
}
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, 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)
|
||||
supports = append(supports, response.NetworkTokenSupport{
|
||||
Network: network,
|
||||
Tokens: symbols,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
|
||||
// @Summary List supported asset records
|
||||
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network.
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter (e.g. tron, ethereum)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets/records [get]
|
||||
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||
network := ctx.QueryParam("network")
|
||||
list, err := data.ListEnabledChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, list)
|
||||
}
|
||||
|
||||
// GetSupportedAsset 查询单条支持项(无需鉴权)。
|
||||
// @Summary Get a supported asset
|
||||
// @Description Public endpoint. Returns one chain_token row by ID. Returns 404 if the token is disabled.
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Param id path int true "ChainToken ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets/{id} [get]
|
||||
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
token, err := data.GetChainTokenByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if token.ID <= 0 || !token.Enabled {
|
||||
return c.FailJson(ctx, constant.SupportedAssetNotFound)
|
||||
}
|
||||
return c.SucJson(ctx, token)
|
||||
}
|
||||
+28
-8
@@ -1,12 +1,14 @@
|
||||
module github.com/assimon/luuu
|
||||
module github.com/GMWalletApp/epusdt
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcutil v1.0.2
|
||||
github.com/dromara/carbon/v2 v2.6.15
|
||||
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,6 +24,7 @@ require (
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/tidwall/gjson v1.18.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
|
||||
@@ -32,19 +35,32 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.20.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/consensys/bavard v0.1.27 // indirect
|
||||
github.com/consensys/gnark-crypto v0.16.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gookit/filter v1.1.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
@@ -59,6 +75,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mmcloughlin/addchain v0.4.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
@@ -67,30 +84,33 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/supranational/blst v0.3.14 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
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
|
||||
go.uber.org/ratelimit v0.3.1 // 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/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.47.0 // indirect
|
||||
rsc.io/tmplfunc v0.0.3 // indirect
|
||||
)
|
||||
|
||||
+156
-23
@@ -1,13 +1,21 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=
|
||||
github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
@@ -20,15 +28,52 @@ github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVa
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
|
||||
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
|
||||
github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA=
|
||||
github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
|
||||
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
|
||||
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/consensys/bavard v0.1.27 h1:j6hKUrGAy/H+gpNrpLU3I26n1yc+VMGmd6ID5+gAhOs=
|
||||
github.com/consensys/bavard v0.1.27/go.mod h1:k/zVjHHC4B+PQy1Pg7fgvG3ALicQw540Crag8qx+dZs=
|
||||
github.com/consensys/gnark-crypto v0.16.0 h1:8Dl4eYmUWK9WmlP1Bj6je688gBRJCJbT8Mw4KoTAawo=
|
||||
github.com/consensys/gnark-crypto v0.16.0/go.mod h1:Ke3j06ndtPTVvo++PhGNgvm+lgpLvzbcE2MqljY7diU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/crate-crypto/go-eth-kzg v1.3.0 h1:05GrhASN9kDAidaFJOda6A4BEvgvuXbazXg/0E3OOdI=
|
||||
github.com/crate-crypto/go-eth-kzg v1.3.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4=
|
||||
github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/dromara/carbon/v2 v2.6.15 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI=
|
||||
github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.0 h1:gQropX9YFBhl3g4HYhwE70zq3IHFRgbbNPw0Shwzf5w=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.0/go.mod h1:TC48kOKjJKPbN7C++qIgt0TJzZ70QznYR7Ob+WXl57E=
|
||||
github.com/ethereum/go-ethereum v1.15.11 h1:JK73WKeu0WC0O1eyX+mdQAVHUV+UR1a9VB/domDngBU=
|
||||
github.com/ethereum/go-ethereum v1.15.11/go.mod h1:mf8YiHIb0GR4x4TipcvBUPxJLw1mFdmxzoDi11sDRoI=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
@@ -44,6 +89,11 @@ github.com/gagliardetto/solana-go v1.16.0 h1:lRPn/NxVmxzXw+vQ3AxH33jQIvj8avx2CKV
|
||||
github.com/gagliardetto/solana-go v1.16.0/go.mod h1:2n7osXNoDeUhq1r1lOgCMVkl90yYUVrV9FHGINBWPHU=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
@@ -55,14 +105,25 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
|
||||
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gookit/color v1.3.8/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ=
|
||||
@@ -76,9 +137,21 @@ github.com/gookit/goutil v0.4.6 h1:LSATnmIyR0rV4BMj3XyaKFYtX/HbRURnPVHt6Zb5ABs=
|
||||
github.com/gookit/goutil v0.4.6/go.mod h1:pq1eTibwb2wN96jrci0xy7xogWzzo9CihOQJEAvz4yQ=
|
||||
github.com/gookit/validate v1.3.1 h1:YoxBxG5H+WucKjfEo8X+QvYOzED7ue3Mfpq6jtpIbYs=
|
||||
github.com/gookit/validate v1.3.1/go.mod h1:mzcr3U5XtAlQVAgbW0wbmDGiuefU/MNuDkXFMBNb1ko=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
|
||||
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4=
|
||||
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc=
|
||||
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
|
||||
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -87,6 +160,8 @@ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU=
|
||||
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
@@ -108,10 +183,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.6.0 h1:vsYEeeYy077cB5yMpgI+ubA7iVRZEtrzHhcvRhd27gA=
|
||||
github.com/labstack/echo/v4 v4.6.0/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
|
||||
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
|
||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/libtnb/sqlite v1.0.1 h1:H4kdU3LBOjdxfQy8s02/wG1rNmp8GMiqzgel0axewtI=
|
||||
github.com/libtnb/sqlite v1.0.1/go.mod h1:zuagCP0nvIJwr4e0kdzq9Yc2Pw5HlVOoLT6ZmzmjOes=
|
||||
@@ -129,12 +208,23 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
|
||||
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
|
||||
github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
|
||||
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
|
||||
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -150,22 +240,47 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
|
||||
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y=
|
||||
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
|
||||
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
|
||||
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
|
||||
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
@@ -173,6 +288,8 @@ github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs=
|
||||
github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -202,6 +319,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo=
|
||||
github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -212,6 +333,12 @@ github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
@@ -219,14 +346,14 @@ github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||
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/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=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
@@ -234,8 +361,6 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
@@ -250,8 +375,10 @@ 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=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -270,8 +397,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.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=
|
||||
@@ -284,6 +411,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -298,9 +426,11 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -310,8 +440,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.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=
|
||||
@@ -320,8 +450,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.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=
|
||||
@@ -336,15 +466,16 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo=
|
||||
gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
@@ -394,3 +525,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU=
|
||||
rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA=
|
||||
|
||||
@@ -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,10 +4,10 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
appLog "github.com/assimon/luuu/util/log"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
@@ -20,35 +20,83 @@ 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.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,
|
||||
} {
|
||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||
}
|
||||
|
||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||
// `pid=1001` still match.
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-default",
|
||||
Pid: "test-token", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-pid-1001",
|
||||
Pid: "1001", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: "rate",
|
||||
Key: "rate.forced_usdt_rate",
|
||||
Value: "1.0",
|
||||
Type: "string",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
closeDB(t, runtimeDB)
|
||||
closeDB(t, mainDB)
|
||||
dao.Mdb = nil
|
||||
dao.RuntimeDB = nil
|
||||
config.SettingsGetString = nil
|
||||
viper.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
+100
-1
@@ -1,11 +1,110 @@
|
||||
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:static
|
||||
var staticDir embed.FS
|
||||
|
||||
//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() {
|
||||
staticPath, err := releaseStatic(staticDir, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("static released to:", staticPath)
|
||||
|
||||
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,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// AdminUserIDKey is the echo.Context key holding the authenticated
|
||||
// admin user's ID after successful JWT validation.
|
||||
AdminUserIDKey = "admin_user_id"
|
||||
// AdminUsernameKey mirrors the username for convenience.
|
||||
AdminUsernameKey = "admin_username"
|
||||
)
|
||||
|
||||
// CheckAdminJWT validates an Authorization: Bearer <jwt> header against
|
||||
// the HS256 secret stored in system.jwt_secret. On success it injects
|
||||
// the admin user ID and username into the echo context.
|
||||
//
|
||||
// Accepts both "Authorization: Bearer <jwt>" and a bare "<jwt>" for
|
||||
// developer convenience (curl / postman). The token itself is always
|
||||
// cryptographically validated — the relaxed header parsing doesn't
|
||||
// widen the trust surface.
|
||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
|
||||
if raw == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
|
||||
}
|
||||
token := strings.TrimSpace(raw[len("Bearer "):])
|
||||
if token == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||
}
|
||||
claims, err := appjwt.Parse(token)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||
}
|
||||
ctx.Set(AdminUserIDKey, claims.AdminUserID)
|
||||
ctx.Set(AdminUsernameKey, claims.Username)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,26 +1,250 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"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)
|
||||
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{}},
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
||||
// panic(err)
|
||||
return
|
||||
}
|
||||
|
||||
seedChains()
|
||||
seedRpcNodes()
|
||||
seedChainTokens()
|
||||
seedDefaultSettings()
|
||||
seedTelegramChannelFromSettings()
|
||||
})
|
||||
}
|
||||
|
||||
// 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},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedChainTokens inserts default token configurations for each chain.
|
||||
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
|
||||
// re-runs are no-ops and admin edits to contract_address / decimals /
|
||||
// min_amount persist across restarts.
|
||||
func seedChainTokens() {
|
||||
// Contract-address rows are matched by scanners via exact contract
|
||||
// lookup. Native-token rows (empty contract_address) are matched by
|
||||
// symbol — these act as admin toggles for the native payment path.
|
||||
defaults := []mdb.ChainToken{
|
||||
// TRON
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
// Ethereum
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
|
||||
// Solana
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
|
||||
// Polygon
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||
// Plasma
|
||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||
func seedRpcNodes() {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ? AND url = ?", d.Network, d.Url).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := Mdb.Create(&d).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultSettings inserts built-in default settings.
|
||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||
func seedDefaultSettings() {
|
||||
defaults := []mdb.Setting{
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
|
||||
// Telegram config into a notification_channels row so the notify dispatcher
|
||||
// can use it. Idempotent: no-op when any telegram channel already exists.
|
||||
//
|
||||
// Legacy keys read from settings:
|
||||
//
|
||||
// system.telegram_bot_token → TelegramConfig.BotToken
|
||||
// system.telegram_chat_id → TelegramConfig.ChatID
|
||||
// system.telegram_payment_notice_enabled → events.pay_success
|
||||
// system.telegram_abnormal_notice_enabled → events.order_expired
|
||||
func seedTelegramChannelFromSettings() {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -41,6 +41,9 @@ func RuntimeInit() error {
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_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,304 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
const InitialAdminPasswordHashAlgorithm = "sha256"
|
||||
|
||||
var (
|
||||
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
|
||||
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
|
||||
)
|
||||
|
||||
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
|
||||
// so the frontend can detect whether the operator is still using the
|
||||
// initial password.
|
||||
type InitialAdminPasswordHashInfo struct {
|
||||
Algorithm string `json:"algorithm" example:"sha256"`
|
||||
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
|
||||
PasswordChanged bool `json:"password_changed" example:"false"`
|
||||
Available bool `json:"available" example:"true"`
|
||||
}
|
||||
|
||||
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
||||
// exists. The password is randomly generated and returned so the caller
|
||||
// can print it to the console. Idempotent — subsequent calls return
|
||||
// ("", false, nil).
|
||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
password = randomAdminPassword()
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
user := &mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(user).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := initAdminPasswordState(password); err != nil {
|
||||
// Account was already created; surface both for visibility while
|
||||
// preserving created=true and password for emergency fallback.
|
||||
return password, true, err
|
||||
}
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashInitialAdminPassword fingerprints the initial plaintext password so
|
||||
// the frontend can compare user input locally without exposing plaintext.
|
||||
func HashInitialAdminPassword(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
func initAdminPasswordState(plain string) error {
|
||||
hash := HashInitialAdminPassword(plain)
|
||||
settings := []mdb.Setting{
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||
Value: plain, Type: mdb.SettingTypeString,
|
||||
Description: "One-time readable initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
|
||||
Value: hash, Type: mdb.SettingTypeString,
|
||||
Description: "SHA-256 fingerprint for initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
},
|
||||
}
|
||||
for _, row := range settings {
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Keep in-process cache coherent for the current process.
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
// ConsumeInitialAdminPassword returns the one-time initial admin password.
|
||||
// After a successful read, the plaintext is deleted and cannot be fetched
|
||||
// again.
|
||||
func ConsumeInitialAdminPassword() (string, error) {
|
||||
var password string
|
||||
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||
row := new(mdb.Setting)
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Limit(1).
|
||||
Find(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.ID == 0 || row.Value == "" {
|
||||
var fetched mdb.Setting
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
|
||||
Limit(1).
|
||||
Find(&fetched).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
|
||||
return ErrInitAdminPasswordAlreadyFetched
|
||||
}
|
||||
return ErrInitAdminPasswordUnavailable
|
||||
}
|
||||
password = row.Value
|
||||
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrInitAdminPasswordAlreadyFetched
|
||||
}
|
||||
return upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "true",
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||
settingsCacheMu.Unlock()
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
||||
// and the changed-state flag used by the admin frontend.
|
||||
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: "",
|
||||
PasswordChanged: true,
|
||||
Available: false,
|
||||
}, nil
|
||||
}
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: hash,
|
||||
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsUsingInitialAdminPassword reports whether the current admin password is
|
||||
// still considered the seeded initial password.
|
||||
func IsUsingInitialAdminPassword() bool {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return false
|
||||
}
|
||||
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
|
||||
}
|
||||
|
||||
// HashPassword bcrypts a plaintext password.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a plaintext password against a bcrypt hash.
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// GetAdminUserByUsername returns the row for a username (case-insensitive).
|
||||
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).
|
||||
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
|
||||
Limit(1).Find(u).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetAdminUserByID returns the row for an ID.
|
||||
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// UpdateAdminUserPassword rehashes and persists a new password.
|
||||
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||
hash, err := HashPassword(newPlain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue := ""
|
||||
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Old installs may not have initial-password metadata; skip in
|
||||
// that case to keep password updates backward compatible.
|
||||
var initHashRow mdb.Setting
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
|
||||
Limit(1).
|
||||
Find(&initHashRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
initHash := strings.TrimSpace(initHashRow.Value)
|
||||
if initHash == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
newHash := HashInitialAdminPassword(newPlain)
|
||||
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
|
||||
changedValue := "true"
|
||||
if !changed {
|
||||
changedValue = "false"
|
||||
}
|
||||
if err := upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: changedValue,
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue = changedValue
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changedCacheValue != "" {
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
||||
settingsCacheMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchAdminUserLastLogin stamps last_login_at to now.
|
||||
func TouchAdminUserLastLogin(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_login_at", carbon.Now().StdTime()).Error
|
||||
}
|
||||
@@ -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. Only
|
||||
// touches rows currently in StatusWaitPay so idempotent / safe.
|
||||
func CloseOrderManually(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ReopenOrderCallback flips callback_confirm back to NO so the mq
|
||||
// worker picks it up on the next tick. Used by "resend callback".
|
||||
func ReopenOrderCallback(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Updates(map[string]interface{}{
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"callback_num": 0,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// CountOrdersByAddress returns order counts grouped by receive_address.
|
||||
// The admin wallet list annotates each wallet row with this number.
|
||||
func CountOrdersByAddress() (map[string]int64, error) {
|
||||
type row struct {
|
||||
Address string `gorm:"column:receive_address"`
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("receive_address, COUNT(*) AS total").
|
||||
Group("receive_address").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Address] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+184
-21
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
@@ -28,10 +28,31 @@ func normalizeLockAmount(amount float64) (int64, string) {
|
||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||
}
|
||||
|
||||
func normalizeLockNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeLockAddress(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeLockNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeLockToken(token string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
if isEVMNetwork(network) {
|
||||
return tx.Where("lower(address) = ?", address)
|
||||
}
|
||||
return tx.Where("address = ?", address)
|
||||
}
|
||||
|
||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
@@ -108,17 +129,152 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
|
||||
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
|
||||
// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent.
|
||||
func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
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)
|
||||
err := dao.Mdb.Model(order).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("token = ?", token).
|
||||
Where("network = ?", network).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Limit(1).
|
||||
Find(order).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id.
|
||||
func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("trade_id != ?", excludeTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
"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{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("is_selected", true).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 {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("created_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// ResetCallbackConfirmOk sets callback_confirm back to Ok.
|
||||
// Prevents the callback worker from retrying a sub-order with an empty notify_url.
|
||||
func ResetCallbackConfirmOk(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("callback_confirm", mdb.CallBackConfirmOk).Error
|
||||
}
|
||||
|
||||
// GetActiveSubOrders returns all active sub-orders under a parent.
|
||||
func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Find(&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{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired).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) {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
var lock mdb.TransactionLock
|
||||
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("address = ?", address).
|
||||
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at > ?", time.Now()).
|
||||
Limit(1).
|
||||
Find(&lock).Error
|
||||
Where("expires_at > ?", time.Now())
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
err := query.Limit(1).Find(&lock).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -128,12 +284,15 @@ func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, am
|
||||
return lock.TradeId, nil
|
||||
}
|
||||
|
||||
// LockTransaction reserves an address+token+amount pair in sqlite until expiration.
|
||||
func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||
// 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 {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||
normalizedToken := normalizeLockToken(token)
|
||||
now := time.Now()
|
||||
lock := &mdb.TransactionLock{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
@@ -143,11 +302,12 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
|
||||
}
|
||||
|
||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.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 {
|
||||
@@ -165,14 +325,17 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
|
||||
})
|
||||
}
|
||||
|
||||
// UnLockTransaction releases the reservation for address+token+amount.
|
||||
func UnLockTransaction(address string, token string, amount float64) error {
|
||||
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
return dao.RuntimeDB.
|
||||
Where("address = ?", address).
|
||||
query := dao.RuntimeDB.
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Delete(&mdb.TransactionLock{}).Error
|
||||
Where("amount_scaled = ?", scaledAmount)
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
return query.Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func UnLockTransactionByTradeId(tradeID string) error {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// DailyStat is one bucket of day-level aggregation used by the
|
||||
// dashboard trend charts.
|
||||
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
|
||||
// "YYYY-MM-DD HH:00" for hourly buckets.
|
||||
type DailyStat struct {
|
||||
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
|
||||
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
|
||||
}
|
||||
|
||||
// AddressDailyStat buckets actual_amount by (day, receive_address) for
|
||||
// the stacked asset-trend chart.
|
||||
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
|
||||
type AddressDailyStat struct {
|
||||
Day string `json:"day"`
|
||||
Address string `json:"address"`
|
||||
ActualAmount float64 `json:"actual_amount"`
|
||||
}
|
||||
|
||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||
// zero-filling any days in the range that have no orders.
|
||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||
// what the user means by "流水". Pending orders count toward OrderCount
|
||||
// to keep成交率 computable.
|
||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`DATE(created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||
// zero-filling any hours that have no orders.
|
||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byDay := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byDay[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
key := cur.Format("2006-01-02")
|
||||
if r, ok := byDay[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillHourlyStats generates a zero-filled slice covering every whole
|
||||
// hour in [start, end]. Hours present in rows overwrite the zero entries.
|
||||
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byHour := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byHour[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endHour) {
|
||||
key := cur.Format("2006-01-02 15:00")
|
||||
if r, ok := byHour[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing days.
|
||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing hours.
|
||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||
// All addresses that appear in rows are represented for every day in range.
|
||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, false)
|
||||
}
|
||||
|
||||
// fillAddressHourlyStats zero-fills missing hour×address combinations.
|
||||
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, true)
|
||||
}
|
||||
|
||||
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
|
||||
type key struct{ day, addr string }
|
||||
byKey := make(map[key]float64, len(rows))
|
||||
addrSet := make(map[string]struct{}, 4)
|
||||
for _, r := range rows {
|
||||
byKey[key{r.Day, r.Address}] = r.ActualAmount
|
||||
addrSet[r.Address] = struct{}{}
|
||||
}
|
||||
if len(addrSet) == 0 {
|
||||
return rows
|
||||
}
|
||||
var periods []string
|
||||
if hourly {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endH) {
|
||||
periods = append(periods, cur.Format("2006-01-02 15:00"))
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
} else {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
periods = append(periods, cur.Format("2006-01-02"))
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
var out []AddressDailyStat
|
||||
for _, day := range periods {
|
||||
for addr := range addrSet {
|
||||
out = append(out, AddressDailyStat{
|
||||
Day: day,
|
||||
Address: addr,
|
||||
ActualAmount: byKey[key{day, addr}],
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SumPaidActualAmount returns the total paid actual_amount across all
|
||||
// time. Used by the overview "total asset" card.
|
||||
func SumPaidActualAmount() (float64, error) {
|
||||
var sum float64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("COALESCE(SUM(actual_amount), 0)").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Scan(&sum).Error
|
||||
return sum, err
|
||||
}
|
||||
|
||||
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
|
||||
// a given time range. Caller chooses the range (today / last 7 days).
|
||||
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
|
||||
type row struct {
|
||||
OrderCount int64
|
||||
SuccessCount int64
|
||||
ActualSum float64
|
||||
}
|
||||
var r row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&r).Error
|
||||
return r.OrderCount, r.SuccessCount, r.ActualSum, err
|
||||
}
|
||||
|
||||
// ActiveAddressCountInRange returns the number of distinct wallet addresses
|
||||
// that appear in at least one order within [start, end].
|
||||
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Distinct("receive_address").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// AveragePaymentDurationSeconds returns the mean elapsed time between
|
||||
// created_at and updated_at for orders that reached StatusPaySuccess
|
||||
// in the given range. Zero-safe when no rows match.
|
||||
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
|
||||
type row struct {
|
||||
Avg float64
|
||||
}
|
||||
var r row
|
||||
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
|
||||
// columns and compute in Go — trend windows are small enough.
|
||||
type pair struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
var pairs []pair
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("created_at, updated_at").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&pairs).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
total := 0.0
|
||||
for _, p := range pairs {
|
||||
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
|
||||
}
|
||||
r.Avg = total / float64(len(pairs))
|
||||
return r.Avg, nil
|
||||
}
|
||||
|
||||
// CountExpiredInRange returns the number of StatusExpired orders in range.
|
||||
func CountExpiredInRange(start, end time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("status = ?", mdb.StatusExpired).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentOrders returns the latest N orders regardless of status.
|
||||
func RecentOrders(limit int) ([]mdb.Orders, error) {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CountEnabledChains counts chains where Enabled=true.
|
||||
func CountEnabledChains() (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-evm-case"
|
||||
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
|
||||
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != tradeID {
|
||||
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
|
||||
}
|
||||
|
||||
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
|
||||
t.Fatalf("unlock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after unlock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-tron-case"
|
||||
address := "TCaseSensitiveAddress001"
|
||||
|
||||
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
tx := dao.Mdb.Model(&mdb.RpcNode{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetRpcNodeByID fetches one row.
|
||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
row := new(mdb.RpcNode)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row.
|
||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateRpcNodeFields patches mutable columns.
|
||||
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteRpcNodeByID soft-deletes the row.
|
||||
func DeleteRpcNodeByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).Error
|
||||
}
|
||||
|
||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||
// Falls back to enabled rows with status=unknown when no health check has
|
||||
// run yet. Explicitly down rows are not selected.
|
||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("type = ?", strings.ToLower(nodeType)).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
healthy := make([]mdb.RpcNode, 0, len(rows))
|
||||
bootstrap := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch r.Status {
|
||||
case mdb.RpcNodeStatusOk:
|
||||
healthy = append(healthy, r)
|
||||
case "", mdb.RpcNodeStatusUnknown:
|
||||
bootstrap = append(bootstrap, r)
|
||||
}
|
||||
}
|
||||
candidates := healthy
|
||||
if len(candidates) == 0 {
|
||||
candidates = bootstrap
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return pickWeighted(candidates), nil
|
||||
}
|
||||
|
||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||
// to truly disable).
|
||||
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
|
||||
total := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
total += w
|
||||
}
|
||||
if total <= 0 {
|
||||
return &rows[0]
|
||||
}
|
||||
pick := rand.Intn(total)
|
||||
acc := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if pick < acc {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return &rows[len(rows)-1]
|
||||
}
|
||||
|
||||
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
|
||||
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latencyMs,
|
||||
"last_checked_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestSelectRpcNodeUsesHealthyRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://unknown.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed unknown rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://ok.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed ok rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://ok.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want ok row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeFallsBackToUnknownOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://unknown.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed unknown rpc_node: %v", err)
|
||||
}
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://down.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusDown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed down rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got == nil || got.Url != "https://unknown.example.com" {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want unknown row", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: "https://down.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusDown,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed down rpc_node: %v", err)
|
||||
}
|
||||
|
||||
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectRpcNode(): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Simple in-process cache. Settings are read frequently (on every order
|
||||
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
|
||||
// is invalidated explicitly via SetString/Delete and expires on process
|
||||
// restart — that's enough for admin-edit semantics without TTL complexity.
|
||||
var (
|
||||
settingsCache = map[string]string{}
|
||||
settingsCacheMu sync.RWMutex
|
||||
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
|
||||
settingsLoaded bool
|
||||
)
|
||||
|
||||
func loadAllSettings() error {
|
||||
var rows []mdb.Setting
|
||||
if err := dao.Mdb.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
defer settingsCacheMu.Unlock()
|
||||
settingsCache = make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
settingsCache[r.Key] = r.Value
|
||||
}
|
||||
settingsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
|
||||
// a dedicated mutex + double-checked flag so two concurrent callers
|
||||
// don't both hit the DB. Can't use sync.Once because ReloadSettings
|
||||
// needs to be able to rebuild after a failed initial load.
|
||||
func ensureLoaded() {
|
||||
settingsCacheMu.RLock()
|
||||
loaded := settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
// Recheck under the load mutex — another goroutine may have loaded
|
||||
// while we were blocked.
|
||||
settingsCacheMu.RLock()
|
||||
loaded = settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
_ = loadAllSettings()
|
||||
}
|
||||
|
||||
// ReloadSettings forces a refresh from DB. Call after external writes.
|
||||
func ReloadSettings() error {
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
return loadAllSettings()
|
||||
}
|
||||
|
||||
// GetSettingString returns the string value for key, or fallback if unset.
|
||||
func GetSettingString(key, fallback string) string {
|
||||
ensureLoaded()
|
||||
settingsCacheMu.RLock()
|
||||
defer settingsCacheMu.RUnlock()
|
||||
v, ok := settingsCache[key]
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
|
||||
func GetSettingInt(key string, fallback int) int {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
|
||||
func GetSettingFloat(key string, fallback float64) float64 {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
|
||||
func GetSettingBool(key string, fallback bool) bool {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||
func SetSetting(group, key, value, valueType string) error {
|
||||
if valueType == "" {
|
||||
valueType = mdb.SettingTypeString
|
||||
}
|
||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[key] = value
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes a setting row and drops the cache entry.
|
||||
func DeleteSetting(key string) error {
|
||||
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, key)
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
||||
var sensitiveSettingKeys = []string{
|
||||
mdb.SettingKeyJwtSecret,
|
||||
mdb.SettingKeyInitAdminPasswordPlain,
|
||||
mdb.SettingKeyInitAdminPasswordHash,
|
||||
mdb.SettingKeyInitAdminPasswordFetched,
|
||||
mdb.SettingKeyInitAdminPasswordChanged,
|
||||
}
|
||||
|
||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||
// excluding any keys in sensitiveSettingKeys.
|
||||
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
||||
var rows []mdb.Setting
|
||||
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
|
||||
if group != "" {
|
||||
tx = tx.Where("`group` = ?", group)
|
||||
}
|
||||
err := tx.Order("`key` ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
@@ -1,21 +1,70 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
)
|
||||
|
||||
// AddWalletAddress 创建钱包
|
||||
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
|
||||
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
||||
exist, err := GetWalletAddressByToken(address)
|
||||
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
|
||||
}
|
||||
|
||||
// isEVMNetwork 判断是否是 EVM 网络
|
||||
func isEVMNetwork(network string) bool {
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeWalletNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeWalletAddressByNetwork(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeWalletNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
|
||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}
|
||||
@@ -23,7 +72,19 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
||||
return walletAddress, err
|
||||
}
|
||||
|
||||
// GetWalletAddressByToken 通过钱包地址获取address
|
||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Limit(1).Find(walletAddress).Error
|
||||
return walletAddress, err
|
||||
}
|
||||
|
||||
// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口)
|
||||
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
||||
@@ -50,6 +111,25 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
return WalletAddressList, err
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetAllWalletAddress 获得所有钱包地址
|
||||
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
var WalletAddressList []mdb.WalletAddress
|
||||
@@ -57,6 +137,22 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
return WalletAddressList, err
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ChangeWalletAddressStatus 启用禁用钱包
|
||||
func ChangeWalletAddressStatus(id uint64, status int) error {
|
||||
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
|
||||
if err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
if row.Address != strings.ToLower(input) {
|
||||
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
|
||||
}
|
||||
|
||||
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
|
||||
if err != nil {
|
||||
t.Fatalf("load wallet by mixed-case address: %v", err)
|
||||
}
|
||||
if loaded.ID == 0 {
|
||||
t.Fatal("expected to find wallet by mixed-case query")
|
||||
}
|
||||
if loaded.Address != strings.ToLower(input) {
|
||||
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixed,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
|
||||
if err != nil {
|
||||
t.Fatalf("list wallets: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("wallet count = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Address != strings.ToLower(mixed) {
|
||||
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tronAddress := "TCaseSensitiveTronAddress001"
|
||||
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if tronRow.Address != tronAddress {
|
||||
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
|
||||
}
|
||||
|
||||
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
|
||||
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add solana wallet: %v", err)
|
||||
}
|
||||
if solRow.Address != solAddress {
|
||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+28
-14
@@ -8,21 +8,35 @@ const (
|
||||
CallBackConfirmNo = 2
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentTypeEpay = "Epay"
|
||||
)
|
||||
|
||||
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"`
|
||||
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"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" 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" 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=已过期
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" 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 string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
||||
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
||||
// Zero when the parent order was paid directly (no sub-order involved).
|
||||
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
RpcNodeTypeHttp = "http"
|
||||
RpcNodeTypeWs = "ws"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcNodeStatusUnknown = "unknown"
|
||||
RpcNodeStatusOk = "ok"
|
||||
RpcNodeStatusDown = "down"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Clients select a node
|
||||
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
||||
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
||||
type RpcNode struct {
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (r *RpcNode) TableName() string {
|
||||
return "rpc_nodes"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mdb
|
||||
|
||||
// Setting stores non-credential runtime configuration as key/value pairs.
|
||||
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
|
||||
// live in the api_keys table; notification configs live in NotificationChannel.
|
||||
// Default rows for the epay group are seeded on first startup (see
|
||||
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
||||
// empty and fall back to hardcoded defaults until an admin sets them.
|
||||
// Only exception: system.jwt_secret is auto-generated on first startup.
|
||||
const (
|
||||
SettingGroupBrand = "brand"
|
||||
SettingGroupRate = "rate"
|
||||
SettingGroupSystem = "system"
|
||||
SettingGroupEpay = "epay"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingTypeString = "string"
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeJSON = "json"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingKeyJwtSecret = "system.jwt_secret"
|
||||
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
|
||||
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
|
||||
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||
SettingKeyBrandSiteName = "brand.site_name"
|
||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||
SettingKeyBrandPageTitle = "brand.page_title"
|
||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||
SettingKeyRateApiUrl = "rate.api_url"
|
||||
|
||||
// EPAY route defaults — can be overridden via admin settings.
|
||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
||||
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
||||
)
|
||||
|
||||
type Setting struct {
|
||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import "time"
|
||||
|
||||
type TransactionLock struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
|
||||
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"`
|
||||
|
||||
@@ -5,10 +5,27 @@ const (
|
||||
TokenStatusDisable = 2
|
||||
)
|
||||
|
||||
const (
|
||||
NetworkTron = "tron"
|
||||
NetworkSolana = "solana"
|
||||
NetworkEthereum = "ethereum"
|
||||
NetworkBsc = "bsc"
|
||||
NetworkPolygon = "polygon"
|
||||
NetworkPlasma = "plasma"
|
||||
)
|
||||
|
||||
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,14 +4,16 @@ 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"`
|
||||
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" validate:"required" example:"usdt"` // 币种 如:usdt
|
||||
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
||||
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 string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||
}
|
||||
|
||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||
@@ -36,3 +38,18 @@ type OrderProcessingRequest struct {
|
||||
TradeId string
|
||||
BlockTransactionId string
|
||||
}
|
||||
|
||||
// SwitchNetworkRequest 切换支付网络
|
||||
type SwitchNetworkRequest struct {
|
||||
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
|
||||
Token string `json:"token" validate:"required" example:"USDT"`
|
||||
Network string `json:"network" validate:"required" example:"ethereum"`
|
||||
}
|
||||
|
||||
func (r SwitchNetworkRequest) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"TradeId": "订单号",
|
||||
"Token": "币种",
|
||||
"Network": "网络",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package response
|
||||
|
||||
// ApiResponse is the standard JSON envelope used by all API endpoints.
|
||||
// Used for swagger documentation only — the actual response is built
|
||||
// by util/http.Resp.
|
||||
type ApiResponse struct {
|
||||
StatusCode int `json:"status_code" example:"200"`
|
||||
Message string `json:"message" example:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
RequestID string `json:"request_id" example:"req-123456"`
|
||||
}
|
||||
@@ -2,26 +2,41 @@ 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:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||
}
|
||||
|
||||
// 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:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
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" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||
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,19 +1,21 @@
|
||||
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"` // 订单创建时间 时间戳
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||
IsSelected bool `json:"is_selected" example:"false"`
|
||||
}
|
||||
|
||||
type CheckStatusResponse struct {
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
Status int `json:"status"`
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"1"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package response
|
||||
|
||||
type NetworkTokenSupport struct {
|
||||
Network string `json:"network" example:"tron"`
|
||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||
}
|
||||
|
||||
type SupportedAssetsResponse struct {
|
||||
Supports []NetworkTokenSupport `json:"supports"`
|
||||
}
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -28,16 +28,39 @@ const (
|
||||
IncrementalMaximumNumber = 100
|
||||
)
|
||||
|
||||
var gCreateTransactionLock sync.Mutex
|
||||
var gOrderProcessingLock sync.Mutex
|
||||
var (
|
||||
gCreateTransactionLock sync.Mutex
|
||||
gOrderProcessingLock sync.Mutex
|
||||
)
|
||||
|
||||
// apiKeyID safely extracts the primary key from an ApiKey row.
|
||||
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
|
||||
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
|
||||
if apiKey == nil {
|
||||
return 0
|
||||
}
|
||||
return apiKey.ID
|
||||
}
|
||||
|
||||
func normalizeOrderAddressByNetwork(network, address string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
address = strings.TrimSpace(address)
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return strings.ToLower(address)
|
||||
default:
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTransaction creates a new payment order.
|
||||
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
|
||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||
if rate <= 0 {
|
||||
@@ -61,7 +84,10 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
||||
return nil, constant.OrderAlreadyExists
|
||||
}
|
||||
|
||||
walletAddress, err := data.GetAvailableWalletAddress()
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,7 +97,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
||||
|
||||
tradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,9 +114,13 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
||||
ActualAmount: availableAmount,
|
||||
ReceiveAddress: availableAddress,
|
||||
Token: token,
|
||||
Network: network,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
RedirectUrl: req.RedirectUrl,
|
||||
Name: req.Name,
|
||||
PaymentType: req.PaymentType,
|
||||
ApiKeyID: apiKeyID(apiKey),
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||
tx.Rollback()
|
||||
@@ -148,22 +178,104 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
||||
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
||||
}
|
||||
|
||||
// Load order to check parent-child relationship
|
||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||
}
|
||||
|
||||
// Parent order paid directly: expire all sub-orders and release their locks
|
||||
if order.ParentTradeId == "" {
|
||||
subs, subErr := data.GetActiveSubOrders(order.TradeId)
|
||||
if subErr != nil {
|
||||
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
|
||||
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||
}
|
||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
|
||||
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
|
||||
}
|
||||
|
||||
// Snapshot siblings for lock release after DB state transition commits.
|
||||
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
finalizeTx := dao.Mdb.Begin()
|
||||
|
||||
// Mark parent as paid with sub-order's payment details
|
||||
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
|
||||
if markErr != nil {
|
||||
finalizeTx.Rollback()
|
||||
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
|
||||
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
|
||||
}
|
||||
if !updatedParent {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
|
||||
}
|
||||
|
||||
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
if err = finalizeTx.Commit().Error; err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Sub-order should not trigger its own callback (notify_url is empty).
|
||||
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
|
||||
// reset it only after the parent order is successfully finalized.
|
||||
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||
}
|
||||
|
||||
// Release parent's own wallet lock
|
||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Release sibling locks after their status transitions commit.
|
||||
for _, sib := range siblings {
|
||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
|
||||
func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
|
||||
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||
availableAddress := ""
|
||||
availableAmount := amount
|
||||
|
||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||
for _, address := range walletAddress {
|
||||
err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
||||
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
|
||||
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
||||
if err == nil {
|
||||
return address.Address, nil
|
||||
return normalizedAddress, nil
|
||||
}
|
||||
if errors.Is(err, data.ErrTransactionLocked) {
|
||||
continue
|
||||
@@ -208,3 +320,145 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
const MaxSubOrders = 2
|
||||
|
||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
||||
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
|
||||
// 1. Load parent order
|
||||
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.ID <= 0 {
|
||||
return nil, constant.OrderNotExists
|
||||
}
|
||||
if parent.ParentTradeId != "" {
|
||||
return nil, constant.CannotSwitchSubOrder
|
||||
}
|
||||
if parent.Status != mdb.StatusWaitPay {
|
||||
return nil, constant.OrderNotWaitPay
|
||||
}
|
||||
|
||||
// 2. Same token+network as parent → mark selected and return
|
||||
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
parent.IsSelected = true
|
||||
return buildCheckoutResponse(parent), nil
|
||||
}
|
||||
|
||||
// 3. Existing active sub-order for this token+network → return it
|
||||
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
_ = data.MarkOrderSelected(existing.TradeId)
|
||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||
existing.IsSelected = true
|
||||
return buildCheckoutResponse(existing), nil
|
||||
}
|
||||
|
||||
// 4. Check sub-order limit
|
||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= MaxSubOrders {
|
||||
return nil, constant.SubOrderLimitExceeded
|
||||
}
|
||||
|
||||
// 5. Calculate amount for the new network
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||
if rate <= 0 {
|
||||
return nil, constant.RateAmountErr
|
||||
}
|
||||
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||
return nil, constant.PayAmountErr
|
||||
}
|
||||
|
||||
// 6. Find and lock wallet
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(walletAddress) <= 0 {
|
||||
return nil, constant.NotAvailableWalletAddress
|
||||
}
|
||||
|
||||
subTradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if availableAddress == "" {
|
||||
return nil, constant.NotAvailableAmountErr
|
||||
}
|
||||
|
||||
// 7. Create sub-order
|
||||
tx := dao.Mdb.Begin()
|
||||
subOrder := &mdb.Orders{
|
||||
TradeId: subTradeID,
|
||||
OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint)
|
||||
ParentTradeId: parent.TradeId,
|
||||
Amount: parent.Amount,
|
||||
Currency: parent.Currency,
|
||||
ActualAmount: availableAmount,
|
||||
ReceiveAddress: availableAddress,
|
||||
Token: token,
|
||||
Network: network,
|
||||
Status: mdb.StatusWaitPay,
|
||||
IsSelected: true,
|
||||
NotifyUrl: "",
|
||||
RedirectUrl: parent.RedirectUrl,
|
||||
Name: parent.Name,
|
||||
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
|
||||
PaymentType: parent.PaymentType,
|
||||
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(subTradeID)
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(subTradeID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark parent as selected and refresh its expiration to match the sub-order
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||
|
||||
return buildCheckoutResponse(subOrder), nil
|
||||
}
|
||||
|
||||
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
||||
return &response.CheckoutCounterResponse{
|
||||
TradeId: order.TradeId,
|
||||
Amount: order.Amount,
|
||||
ActualAmount: order.ActualAmount,
|
||||
Token: order.Token,
|
||||
Currency: order.Currency,
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Network: order.Network,
|
||||
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||
RedirectUrl: order.RedirectUrl,
|
||||
CreatedAt: order.CreatedAt.TimestampMilli(),
|
||||
IsSelected: order.IsSelected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||
@@ -18,11 +24,32 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
||||
OrderId: orderID,
|
||||
Currency: "CNY",
|
||||
Token: "USDT",
|
||||
Network: "tron",
|
||||
Amount: amount,
|
||||
NotifyUrl: "https://merchant.example/callback",
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
t.Helper()
|
||||
|
||||
oldFactory := http_client.ClientFactory
|
||||
http_client.ClientFactory = func() *resty.Client {
|
||||
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||
client.SetTimeout(10 * time.Second)
|
||||
return client
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http_client.ClientFactory = oldFactory
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
@@ -31,11 +58,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
@@ -53,7 +80,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
|
||||
}
|
||||
|
||||
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
|
||||
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get first runtime lock: %v", err)
|
||||
}
|
||||
@@ -61,7 +88,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
|
||||
}
|
||||
|
||||
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
|
||||
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get second runtime lock: %v", err)
|
||||
}
|
||||
@@ -70,6 +97,102 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
||||
t.Fatalf("set rate.api_url: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
|
||||
t.Fatalf("actual amount = %s, want 1.46", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||
t.Fatalf("clear rate.api_url: %v", err)
|
||||
}
|
||||
|
||||
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
|
||||
if err != constant.RateAmountErr {
|
||||
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixedAddress,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
req := newCreateTransactionRequest("order_evm_1", 1)
|
||||
req.Network = mdb.NetworkEthereum
|
||||
|
||||
resp, err := CreateTransaction(req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
expectedAddress := strings.ToLower(mixedAddress)
|
||||
if resp.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if order.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup runtime lock: %v", err)
|
||||
}
|
||||
if tradeID != resp.TradeId {
|
||||
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
@@ -78,7 +201,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -86,6 +209,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_1",
|
||||
@@ -108,7 +232,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount)
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get runtime lock after processing: %v", err)
|
||||
}
|
||||
@@ -125,7 +249,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -133,6 +257,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_1",
|
||||
@@ -166,7 +291,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -180,6 +305,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_expired",
|
||||
@@ -211,11 +337,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
@@ -239,6 +365,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: token,
|
||||
Network: "tron",
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: "shared_block",
|
||||
@@ -298,3 +425,285 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentResp.TradeId).
|
||||
Update("status", mdb.StatusExpired).Error; err != nil {
|
||||
t.Fatalf("force parent expired: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_not_wait",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is not wait-pay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
|
||||
t.Fatalf("delete parent order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_missing",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
|
||||
// when a sub-order is paid, the parent order is marked as paid but its own
|
||||
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
|
||||
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
|
||||
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
|
||||
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot the parent's original fields before any payment.
|
||||
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load original parent: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Load the sub-order to get its DB primary-key ID.
|
||||
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load sub-order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_paid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Sub-order must be paid with the block hash and no pending callback.
|
||||
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload sub-order: %v", err)
|
||||
}
|
||||
if sub.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if sub.BlockTransactionId != "block_sub_paid" {
|
||||
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
|
||||
}
|
||||
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
|
||||
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
|
||||
}
|
||||
|
||||
// Parent must be paid but its own fields must be unchanged.
|
||||
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload parent order: %v", err)
|
||||
}
|
||||
if parent.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if parent.BlockTransactionId != "" {
|
||||
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
|
||||
}
|
||||
if parent.ReceiveAddress != originalParent.ReceiveAddress {
|
||||
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
|
||||
}
|
||||
if parent.ActualAmount != originalParent.ActualAmount {
|
||||
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
|
||||
}
|
||||
if parent.PayBySubId != subOrder.ID {
|
||||
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
|
||||
}
|
||||
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
|
||||
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
|
||||
// well as the parent's lock) are released.
|
||||
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
|
||||
t.Fatalf("add bsc wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Create two sub-orders on different networks.
|
||||
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||
}
|
||||
|
||||
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkBsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to bsc sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Pay the Ethereum sub-order.
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subEthResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subEthResp.Token),
|
||||
Network: strings.ToLower(subEthResp.Network),
|
||||
TradeId: subEthResp.TradeId,
|
||||
Amount: subEthResp.ActualAmount,
|
||||
BlockTransactionId: "block_sib_eth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing eth sub-order: %v", err)
|
||||
}
|
||||
|
||||
// BSC sibling must be expired.
|
||||
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload bsc sub-order: %v", err)
|
||||
}
|
||||
if subBsc.Status != mdb.StatusExpired {
|
||||
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
||||
}
|
||||
|
||||
// Parent runtime lock must be released.
|
||||
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check parent runtime lock: %v", err)
|
||||
}
|
||||
if parentLock != "" {
|
||||
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
|
||||
}
|
||||
|
||||
// BSC sibling runtime lock must be released.
|
||||
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||
}
|
||||
if sibLock != "" {
|
||||
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
|
||||
}
|
||||
|
||||
// Ethereum sub-order runtime lock must also be released.
|
||||
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check eth sub-order runtime lock: %v", err)
|
||||
}
|
||||
if ethLock != "" {
|
||||
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
)
|
||||
|
||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
||||
@@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||
RedirectUrl: orderInfo.RedirectUrl,
|
||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||
IsSelected: orderInfo.IsSelected,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
+595
-169
@@ -1,22 +1,469 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/gagliardetto/solana-go"
|
||||
"github.com/gagliardetto/solana-go/rpc"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func SolCallBack(address string) {}
|
||||
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
||||
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||
|
||||
type TransferInfo struct {
|
||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||
Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers)
|
||||
Amount float64 // Human-readable amount (adjusted for decimals)
|
||||
RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals)
|
||||
Decimals *int // Optional decimals from the transaction, if available
|
||||
BlockTime int64 // Block time of the transfer unit is seconds since epoch
|
||||
}
|
||||
|
||||
// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。
|
||||
func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Load enabled Solana tokens from chain_tokens. The contract_address
|
||||
// column holds the mint for SPL tokens; a special entry with
|
||||
// symbol=SOL and empty contract_address enables native SOL payments.
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||
var nativeSolToken *mdb.ChainToken
|
||||
for i := range tokens {
|
||||
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||
mint := strings.TrimSpace(tokens[i].ContractAddress)
|
||||
if sym == "SOL" && mint == "" {
|
||||
nativeSolToken = &tokens[i]
|
||||
continue
|
||||
}
|
||||
if mint == "" {
|
||||
continue
|
||||
}
|
||||
mintTokens[mint] = &tokens[i]
|
||||
}
|
||||
|
||||
// Clean up old entries from processed cache (older than 1 hour)
|
||||
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
|
||||
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
||||
if ts, ok := value.(int64); ok && ts < cleanupCutoff {
|
||||
gProcessedSignatures.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
limit := 1000
|
||||
|
||||
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
|
||||
queryAddrs := []string{address}
|
||||
for mint := range mintTokens {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
|
||||
continue
|
||||
}
|
||||
queryAddrs = append(queryAddrs, ata)
|
||||
}
|
||||
|
||||
// 拉取签名并去重
|
||||
seen := make(map[string]bool)
|
||||
var result []solSignatureResult
|
||||
for _, queryAddr := range queryAddrs {
|
||||
respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "")
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resultBody := gjson.GetBytes(respBody, "result")
|
||||
if !resultBody.Exists() || !resultBody.IsArray() {
|
||||
log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody))
|
||||
continue
|
||||
}
|
||||
|
||||
var batch []solSignatureResult
|
||||
err = json.Unmarshal([]byte(resultBody.Raw), &batch)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sig := range batch {
|
||||
if !seen[sig.Signature] {
|
||||
seen[sig.Signature] = true
|
||||
result = append(result, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address)
|
||||
return
|
||||
}
|
||||
|
||||
// 按 blockTime 降序排列
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].BlockTime == nil {
|
||||
return false
|
||||
}
|
||||
if result[j].BlockTime == nil {
|
||||
return true
|
||||
}
|
||||
return *result[i].BlockTime > *result[j].BlockTime
|
||||
})
|
||||
|
||||
// 时间截止线:订单过期时间 + 5 分钟
|
||||
cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix()
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d",
|
||||
address, len(result), len(queryAddrs), cutoffTime)
|
||||
|
||||
// Process each transaction signature
|
||||
for sigIdx, txSig := range result {
|
||||
sig := txSig.Signature
|
||||
|
||||
// Skip failed transactions
|
||||
if txSig.Err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过截止时间的旧签名不再处理
|
||||
if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime {
|
||||
log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan",
|
||||
address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime)
|
||||
break
|
||||
}
|
||||
|
||||
// 跳过已处理的签名
|
||||
if _, ok := gProcessedSignatures.Load(sig); ok {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot)
|
||||
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err)
|
||||
continue
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions))
|
||||
|
||||
for instrIdx, instruction := range instructions {
|
||||
programID := instruction.Get("programId").String()
|
||||
parsedType := instruction.Get("parsed.type").String()
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType)
|
||||
|
||||
transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData)
|
||||
if err != nil {
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err)
|
||||
continue
|
||||
}
|
||||
if transferInfo == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d",
|
||||
address, sig, instrIdx,
|
||||
transferInfo.Source, transferInfo.Destination, transferInfo.Mint,
|
||||
transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime)
|
||||
|
||||
if !isTransferToAddress(transferInfo, address) {
|
||||
continue
|
||||
}
|
||||
|
||||
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
|
||||
if token == "" || amount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f",
|
||||
address, sig, instrIdx, token, amount, address, token, amount)
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
||||
continue
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)",
|
||||
address, sig, address, token, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||
address, tradeID, sig, token, amount)
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
||||
address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli())
|
||||
|
||||
// blockTime 秒 → 毫秒,与订单创建时间对齐
|
||||
blockTimestamp := transferInfo.BlockTime * 1000
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d",
|
||||
address, sig, blockTimestamp, createTime, blockTimestamp-createTime)
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)",
|
||||
address, sig, blockTimestamp, createTime-blockTimestamp, createTime)
|
||||
continue
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: token,
|
||||
Network: mdb.NetworkSolana,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: sig,
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||
address, tradeID, sig, token, amount)
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification",
|
||||
address, tradeID, sig, token, amount)
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
||||
}
|
||||
|
||||
// 标记已处理
|
||||
gProcessedSignatures.Store(sig, time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// solSignatureResult getSignaturesForAddress 返回结构
|
||||
type solSignatureResult struct {
|
||||
Signature string `json:"signature"`
|
||||
Slot uint64 `json:"slot"`
|
||||
Err interface{} `json:"err"`
|
||||
BlockTime *int64 `json:"blockTime"`
|
||||
}
|
||||
|
||||
func resolveSolanaRpcURL() (string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, nil
|
||||
}
|
||||
|
||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
client := resty.New()
|
||||
client.SetRetryCount(5)
|
||||
client.SetRetryWaitTime(2 * time.Second)
|
||||
client.SetRetryMaxWaitTime(10 * time.Second)
|
||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if r.StatusCode() >= 429 || r.StatusCode() >= 500 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
rpcUrl, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
Post(rpcUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody := resp.Body()
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) {
|
||||
opts := map[string]interface{}{
|
||||
"commitment": "finalized",
|
||||
"limit": limit,
|
||||
}
|
||||
if untilSig != "" {
|
||||
opts["until"] = untilSig
|
||||
}
|
||||
if beforeSig != "" {
|
||||
opts["before"] = beforeSig
|
||||
}
|
||||
|
||||
bodyData, err := SolRetryClient("getSignaturesForAddress",
|
||||
[]interface{}{address, opts})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, ok := result["result"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
return bodyData, nil
|
||||
}
|
||||
|
||||
func SolGetTransaction(sig string) ([]byte, error) {
|
||||
txData, err := SolRetryClient("getTransaction", []interface{}{
|
||||
sig,
|
||||
map[string]interface{}{
|
||||
"encoding": "jsonParsed",
|
||||
"commitment": "confirmed",
|
||||
"maxSupportedTransactionVersion": 0, // suport
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("SolRetryClient failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errField := gjson.GetBytes(txData, "result.meta.err")
|
||||
if errField.Exists() && errField.Type != gjson.Null {
|
||||
log.Sugar.Warnf("Transaction failed: %v", errField.String())
|
||||
return nil, fmt.Errorf("transaction failed: %s", errField.String())
|
||||
}
|
||||
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||
// Native SOL transfer - check destination directly
|
||||
if transfer.Mint == "SOL" {
|
||||
return strings.EqualFold(transfer.Destination, targetAddress)
|
||||
}
|
||||
|
||||
// Skip Transfer instruction without mint info (use TransferChecked instead)
|
||||
if transfer.Mint == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// SPL Token transfer - check if destination ATA matches
|
||||
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
|
||||
}
|
||||
|
||||
// resolveSolTokenAndAmount identifies the token from a transfer using
|
||||
// the admin-configured chain_tokens list. mintTokens maps SPL mint
|
||||
// addresses to their ChainToken config; nativeSolToken is non-nil when a
|
||||
// native SOL entry exists in chain_tokens. Returns ("", 0) for
|
||||
// transfers whose mint is not configured or below min_amount.
|
||||
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
|
||||
mint := transfer.Mint
|
||||
|
||||
// Native SOL
|
||||
if mint == "SOL" {
|
||||
if nativeSolToken == nil {
|
||||
return "", 0
|
||||
}
|
||||
amount := transfer.Amount
|
||||
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
|
||||
}
|
||||
|
||||
cfg, ok := mintTokens[mint]
|
||||
if !ok || cfg == nil {
|
||||
return "", 0
|
||||
}
|
||||
decimals := cfg.Decimals
|
||||
if transfer.Decimals != nil {
|
||||
decimals = int(*transfer.Decimals)
|
||||
}
|
||||
if decimals <= 0 {
|
||||
decimals = 6
|
||||
}
|
||||
amount := ADJustAmount(transfer.RawAmount, decimals)
|
||||
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
|
||||
}
|
||||
|
||||
const (
|
||||
// Mint token
|
||||
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
||||
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
||||
|
||||
USDT_Decimals = 6
|
||||
USDC_Decimals = 6
|
||||
SOL_Decimals = 9
|
||||
)
|
||||
|
||||
const (
|
||||
// SPL Token instructions
|
||||
InstructionTransfer = 3
|
||||
InstructionTransferChecked = 12
|
||||
)
|
||||
|
||||
const (
|
||||
// System Program
|
||||
SystemProgramID = "11111111111111111111111111111111"
|
||||
InstructionSystemTransfer = 2
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,6 +471,49 @@ const (
|
||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
||||
)
|
||||
|
||||
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
|
||||
func ADJustAmount(amount uint64, decimals int) float64 {
|
||||
if amount == 0 {
|
||||
return 0
|
||||
}
|
||||
decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0)
|
||||
// 10^decimals
|
||||
decimalDivisor := decimal.New(1, int32(decimals))
|
||||
adjustedAmount := decimalAmount.Div(decimalDivisor)
|
||||
// Round to 2 decimal places
|
||||
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
|
||||
}
|
||||
|
||||
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDT_Mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func MatchUsdcAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDC_Mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func MatchAtaAddress(address string, mint string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func FindATAAddress(owner, mint string) (string, error) {
|
||||
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
||||
if err != nil {
|
||||
@@ -43,191 +533,127 @@ func FindATAAddress(owner, mint string) (string, error) {
|
||||
return ata.String(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
InstructionTransfer = 3
|
||||
InstructionTransferChecked = 12
|
||||
)
|
||||
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
|
||||
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
programID := instruction.Get("programId").String()
|
||||
parsedType := instruction.Get("parsed.type").String()
|
||||
|
||||
type TransferInfo struct {
|
||||
ProgramID string
|
||||
Type string
|
||||
if programID == SystemProgramID && parsedType == "transfer" {
|
||||
return parseSystemTransfer(instruction, txData)
|
||||
}
|
||||
|
||||
Source string
|
||||
Destination string
|
||||
Mint string
|
||||
Authority string
|
||||
if programID == TokenProgramID || programID == Token2022ProgramID {
|
||||
switch parsedType {
|
||||
case "transfer":
|
||||
return parseSplTransfer(instruction, txData)
|
||||
case "transferChecked":
|
||||
return parseSplTransferChecked(instruction, txData)
|
||||
}
|
||||
}
|
||||
|
||||
Amount uint64
|
||||
Decimals *uint8
|
||||
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func isTokenProgram(programID solana.PublicKey) bool {
|
||||
p := programID.String()
|
||||
return p == TokenProgramID || p == Token2022ProgramID
|
||||
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
lamports := info.Get("lamports").Uint()
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: "SOL",
|
||||
Amount: ADJustAmount(lamports, SOL_Decimals),
|
||||
RawAmount: lamports,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DecodeTransfer(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
|
||||
if len(data) < 9 {
|
||||
return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
|
||||
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
|
||||
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
amountStr := info.Get("amount").String()
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
if data[0] != InstructionTransfer {
|
||||
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
|
||||
// Look up mint and decimals from postTokenBalances using the destination ATA
|
||||
mint, decimals, found := findMintFromTokenBalances(destination, txData)
|
||||
if !found {
|
||||
// Try source ATA as fallback
|
||||
mint, decimals, found = findMintFromTokenBalances(source, txData)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
|
||||
}
|
||||
|
||||
if len(accountIndexes) < 3 {
|
||||
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
|
||||
}
|
||||
|
||||
amount := binary.LittleEndian.Uint64(data[1:9])
|
||||
|
||||
sourceIdx := accountIndexes[0]
|
||||
destIdx := accountIndexes[1]
|
||||
authIdx := accountIndexes[2]
|
||||
|
||||
if int(sourceIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) {
|
||||
return nil, errors.New("account index out of range")
|
||||
}
|
||||
|
||||
info := &TransferInfo{
|
||||
ProgramID: programID.String(),
|
||||
Type: "transfer",
|
||||
Source: accountKeys[sourceIdx].String(),
|
||||
Destination: accountKeys[destIdx].String(),
|
||||
Authority: accountKeys[authIdx].String(),
|
||||
Amount: amount,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
d := decimals
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: mint,
|
||||
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||
RawAmount: rawAmount.Uint64(),
|
||||
Decimals: &d,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type TransferCheckedInfo struct {
|
||||
ProgramID string
|
||||
Type string
|
||||
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
|
||||
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
mint := info.Get("mint").String()
|
||||
amountStr := info.Get("tokenAmount.amount").String()
|
||||
decimals := int(info.Get("tokenAmount.decimals").Int())
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
Source string
|
||||
Destination string
|
||||
Mint string
|
||||
Authority string
|
||||
|
||||
Amount uint64
|
||||
Decimals uint8
|
||||
}
|
||||
|
||||
func DecodeTransferCheck(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
|
||||
if len(data) < 10 {
|
||||
return nil, fmt.Errorf("invalid transferChecked data length: %d", len(data))
|
||||
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
if data[0] != InstructionTransferChecked {
|
||||
return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
|
||||
}
|
||||
|
||||
if len(accountIndexes) < 4 {
|
||||
return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
|
||||
}
|
||||
|
||||
amount := binary.LittleEndian.Uint64(data[1:9])
|
||||
decimals := uint8(data[9])
|
||||
|
||||
sourceIdx := accountIndexes[0]
|
||||
mintIdx := accountIndexes[1]
|
||||
destIdx := accountIndexes[2]
|
||||
authIdx := accountIndexes[3]
|
||||
|
||||
if int(sourceIdx) >= len(accountKeys) ||
|
||||
int(mintIdx) >= len(accountKeys) ||
|
||||
int(destIdx) >= len(accountKeys) ||
|
||||
int(authIdx) >= len(accountKeys) {
|
||||
return nil, errors.New("account index out of range")
|
||||
}
|
||||
|
||||
info := &TransferInfo{
|
||||
ProgramID: programID.String(),
|
||||
Type: "transferChecked",
|
||||
Source: accountKeys[sourceIdx].String(),
|
||||
Mint: accountKeys[mintIdx].String(),
|
||||
Destination: accountKeys[destIdx].String(),
|
||||
Authority: accountKeys[authIdx].String(),
|
||||
Amount: amount,
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: mint,
|
||||
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||
RawAmount: rawAmount.Uint64(),
|
||||
Decimals: &decimals,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseTransactionTransfers
|
||||
func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
|
||||
signature, err := solana.SignatureFromBase58(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid signature: %w", err)
|
||||
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
|
||||
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
|
||||
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
|
||||
accountIndex := -1
|
||||
for i, key := range accountKeys {
|
||||
if key.Get("pubkey").String() == ataAddress {
|
||||
accountIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if accountIndex == -1 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
tx, err := client.GetTransaction(
|
||||
ctx,
|
||||
signature,
|
||||
&rpc.GetTransactionOpts{
|
||||
Encoding: solana.EncodingBase64,
|
||||
Commitment: rpc.CommitmentConfirmed,
|
||||
MaxSupportedTransactionVersion: func(v uint64) *uint64 { return &v }(0),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get transaction failed: %w", err)
|
||||
}
|
||||
if tx == nil {
|
||||
return nil, errors.New("transaction not found")
|
||||
}
|
||||
|
||||
decodedTx, err := tx.Transaction.GetTransaction()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode transaction failed: %w", err)
|
||||
}
|
||||
|
||||
msg := decodedTx.Message
|
||||
accountKeys := msg.AccountKeys
|
||||
results := make([]*TransferInfo, 0)
|
||||
|
||||
for _, ix := range msg.Instructions {
|
||||
if int(ix.ProgramIDIndex) >= len(accountKeys) {
|
||||
continue
|
||||
}
|
||||
|
||||
programID := accountKeys[ix.ProgramIDIndex]
|
||||
if !isTokenProgram(programID) {
|
||||
continue
|
||||
}
|
||||
|
||||
data := ix.Data
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch data[0] {
|
||||
case InstructionTransfer:
|
||||
info, err := DecodeTransfer(data, accountKeys, toUint16Slice(ix.Accounts), programID)
|
||||
if err == nil {
|
||||
results = append(results, info)
|
||||
}
|
||||
case InstructionTransferChecked:
|
||||
info, err := DecodeTransferCheck(data, accountKeys, toUint16Slice(ix.Accounts), programID)
|
||||
if err == nil {
|
||||
results = append(results, info)
|
||||
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
|
||||
for _, balance := range balances {
|
||||
if int(balance.Get("accountIndex").Int()) == accountIndex {
|
||||
mint := balance.Get("mint").String()
|
||||
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
|
||||
return mint, decimals, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func toUint16Slice(in []uint16) []uint16 {
|
||||
out := make([]uint16, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = uint16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func DecodeBase64InstructionData(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
@@ -1,45 +1,401 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gagliardetto/solana-go/rpc"
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestParseSolTransaction(t *testing.T) {
|
||||
client := rpc.New("https://api.mainnet-beta.solana.com")
|
||||
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
||||
func setupSolanaRPCNode(t *testing.T, url string) func() {
|
||||
t.Helper()
|
||||
|
||||
txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: url,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("seed solana rpc_node: %v", err)
|
||||
}
|
||||
return cleanup
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
||||
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
|
||||
defer cleanup()
|
||||
|
||||
got, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||
}
|
||||
if got != "https://solana.example.com" {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientHealthy(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
bodyData, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range txInfo {
|
||||
fmt.Println("type =", item.Type)
|
||||
fmt.Println("program =", item.ProgramID)
|
||||
fmt.Println("from =", item.Source)
|
||||
fmt.Println("to =", item.Destination)
|
||||
fmt.Println("mint =", item.Mint)
|
||||
fmt.Println("authority =", item.Authority)
|
||||
fmt.Println("amount =", item.Amount)
|
||||
if item.Decimals != nil {
|
||||
fmt.Println("decimals =", *item.Decimals)
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
fmt.Println("-----")
|
||||
|
||||
status, ok := result["result"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("RPC Health Status: %s", status)
|
||||
|
||||
if status != "ok" {
|
||||
t.Errorf("Expected health status 'ok', got '%s'", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example wallet address (replace with actual test address)
|
||||
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
signatures, ok := result["result"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("Found %d signatures for address %s", len(signatures), address)
|
||||
|
||||
}
|
||||
|
||||
func TestSolClientGetTransaction(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example transaction signature (replace with actual test signature)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
|
||||
txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(txData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
txInfo, ok := result["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
|
||||
}
|
||||
|
||||
func requireSolanaIntegration(t *testing.T) {
|
||||
t.Helper()
|
||||
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
|
||||
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
|
||||
}
|
||||
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
|
||||
t.Cleanup(cleanup)
|
||||
}
|
||||
|
||||
func TestFindATAAddress(t *testing.T) {
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
||||
|
||||
ata, err := FindATAAddress(owner, mint)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
tests := []struct {
|
||||
name string
|
||||
owner string
|
||||
mint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "RAY token ATA",
|
||||
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
|
||||
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
|
||||
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
|
||||
},
|
||||
}
|
||||
|
||||
fmt.Println("ATA =", ata)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ata, err := FindATAAddress(tt.owner, tt.mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", tt.owner)
|
||||
t.Logf("Mint: %s", tt.mint)
|
||||
t.Logf("ATA: %s", ata)
|
||||
|
||||
if tt.want != "" && ata != tt.want {
|
||||
t.Errorf("Expected ATA %s, got %s", tt.want, ata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchATAAddress(t *testing.T) {
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
||||
expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
|
||||
|
||||
ok := MatchAtaAddress(owner, mint, expectedATA)
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("Mint: %s", mint)
|
||||
t.Logf("Expected ATA: %s", expectedATA)
|
||||
t.Logf("Match result: %v", ok)
|
||||
|
||||
if !ok {
|
||||
t.Error("Expected ATA to match, but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsdtAtaAddress(t *testing.T) {
|
||||
// Example wallet address (replace with actual test address)
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
ata, err := FindATAAddress(owner, USDT_Mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("USDT Mint: %s", USDT_Mint)
|
||||
t.Logf("USDT ATA: %s", ata)
|
||||
|
||||
ok := MatchUsdtAtaAddress(owner, ata)
|
||||
if !ok {
|
||||
t.Error("Expected USDT ATA to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsdcAtaAddress(t *testing.T) {
|
||||
// Example wallet address (replace with actual test address)
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
ata, err := FindATAAddress(owner, USDC_Mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("USDC Mint: %s", USDC_Mint)
|
||||
t.Logf("USDC ATA: %s", ata)
|
||||
|
||||
ok := MatchUsdcAtaAddress(owner, ata)
|
||||
if !ok {
|
||||
t.Error("Expected USDC ATA to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount uint64
|
||||
decimals int
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "USDT amount (6 decimals)",
|
||||
amount: 123456789,
|
||||
decimals: 6,
|
||||
want: 123.46,
|
||||
},
|
||||
{
|
||||
name: "USDC amount (6 decimals)",
|
||||
amount: 1000000,
|
||||
decimals: 6,
|
||||
want: 1.0,
|
||||
},
|
||||
{
|
||||
name: "SOL amount (9 decimals)",
|
||||
amount: 1000000000,
|
||||
decimals: 9,
|
||||
want: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Zero amount",
|
||||
amount: 0,
|
||||
decimals: 6,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "Small amount",
|
||||
amount: 1,
|
||||
decimals: 6,
|
||||
want: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
adjusted := ADJustAmount(tt.amount, tt.decimals)
|
||||
t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
|
||||
|
||||
if adjusted != tt.want {
|
||||
t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
|
||||
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
var found bool
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
||||
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint == "" {
|
||||
t.Error("Expected mint to be resolved from postTokenBalances")
|
||||
}
|
||||
if info.RawAmount != 50000 {
|
||||
t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("No transfer instruction found in transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
var found bool
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
||||
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint != USDT_Mint {
|
||||
t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
|
||||
}
|
||||
if info.RawAmount != 300000 {
|
||||
t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
|
||||
}
|
||||
if info.Amount != 0.3 {
|
||||
t.Errorf("Expected amount 0.3, got %f", info.Amount)
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("No transferChecked instruction found in transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// System program SOL transfer
|
||||
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
transferCount := 0
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
transferCount++
|
||||
t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
|
||||
transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint != "SOL" {
|
||||
t.Errorf("Expected mint SOL, got %s", info.Mint)
|
||||
}
|
||||
if info.RawAmount == 0 {
|
||||
t.Error("Expected non-zero raw amount")
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if transferCount == 0 {
|
||||
t.Error("No system transfer instruction found")
|
||||
}
|
||||
t.Logf("Found %d system transfers", transferCount)
|
||||
}
|
||||
|
||||
+282
-208
@@ -1,260 +1,310 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
tron "github.com/assimon/luuu/crypto"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/gookit/goutil/stdutil"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Error(err)
|
||||
func resolveTronNode() (string, string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}()
|
||||
|
||||
var innerWg sync.WaitGroup
|
||||
innerWg.Add(2)
|
||||
go checkTrxTransfers(address, &innerWg)
|
||||
go checkTrc20Transfers(address, &innerWg)
|
||||
innerWg.Wait()
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
if rpcURL == "" {
|
||||
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, node.ApiKey, nil
|
||||
}
|
||||
|
||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
func ResolveTronNode() (string, string, error) {
|
||||
return resolveTronNode()
|
||||
}
|
||||
|
||||
func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
|
||||
log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"order_by": "block_timestamp,desc",
|
||||
"limit": "100",
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRX API response indicates failure")
|
||||
}
|
||||
|
||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
if len(transfers) == 0 {
|
||||
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
|
||||
addr := strings.TrimSpace(toAddr)
|
||||
if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
|
||||
return
|
||||
}
|
||||
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
|
||||
|
||||
for i, transfer := range transfers {
|
||||
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
|
||||
continue
|
||||
}
|
||||
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
|
||||
continue
|
||||
}
|
||||
|
||||
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
|
||||
toBytes, err := hex.DecodeString(toAddressHex)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
if tron.EncodeCheck(toBytes) != address {
|
||||
continue
|
||||
}
|
||||
|
||||
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
|
||||
decimalQuant, err := decimal.NewFromString(rawAmount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||
if amount <= 0 {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
txID := transfer.Get("txID").String()
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
|
||||
return
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
||||
continue
|
||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
|
||||
return
|
||||
}
|
||||
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
|
||||
continue
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: "TRX",
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: txID,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
|
||||
}
|
||||
}
|
||||
|
||||
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"order_by": "block_timestamp,desc",
|
||||
"limit": "100",
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRC20 API response indicates failure")
|
||||
}
|
||||
|
||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
if len(transfers) == 0 {
|
||||
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
|
||||
log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
|
||||
return
|
||||
}
|
||||
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
|
||||
|
||||
for i, transfer := range transfers {
|
||||
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
|
||||
continue
|
||||
}
|
||||
if transfer.Get("to").String() != address {
|
||||
continue
|
||||
}
|
||||
|
||||
valueStr := transfer.Get("value").String()
|
||||
decimalQuant, err := decimal.NewFromString(valueStr)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
txID := transfer.Get("transaction_id").String()
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
|
||||
continue
|
||||
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||
return
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
ReceiveAddress: addr,
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: txID,
|
||||
BlockTransactionId: txHash,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
|
||||
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
|
||||
}
|
||||
|
||||
func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := strings.TrimSpace(toAddr)
|
||||
if addr == "" || rawSun <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
decimalQuant := decimal.NewFromInt(rawSun)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
|
||||
return
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
|
||||
return
|
||||
}
|
||||
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||
return
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: addr,
|
||||
Token: "TRX",
|
||||
Network: mdb.NetworkTron,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: txHash,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
|
||||
}
|
||||
|
||||
func evmChainLogLabel(chainNetwork string) string {
|
||||
switch chainNetwork {
|
||||
case mdb.NetworkEthereum:
|
||||
return "ETH"
|
||||
case mdb.NetworkBsc:
|
||||
return "BSC"
|
||||
case mdb.NetworkPolygon:
|
||||
return "POLYGON"
|
||||
case mdb.NetworkPlasma:
|
||||
return "PLASMA"
|
||||
default:
|
||||
return "EVM"
|
||||
}
|
||||
}
|
||||
|
||||
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
|
||||
}
|
||||
}()
|
||||
|
||||
var usdt, usdc common.Address
|
||||
var polygonUsdcE common.Address
|
||||
switch chainNetwork {
|
||||
case mdb.NetworkEthereum:
|
||||
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
case mdb.NetworkBsc:
|
||||
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
|
||||
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
|
||||
case mdb.NetworkPolygon:
|
||||
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
|
||||
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
|
||||
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
|
||||
case mdb.NetworkPlasma:
|
||||
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
|
||||
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
var tokenSym string
|
||||
switch {
|
||||
case contract == usdt:
|
||||
tokenSym = "USDT"
|
||||
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
|
||||
tokenSym = "USDC"
|
||||
default:
|
||||
net := evmChainLogLabel(chainNetwork)
|
||||
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
|
||||
return
|
||||
}
|
||||
|
||||
net := evmChainLogLabel(chainNetwork)
|
||||
walletAddr := strings.ToLower(toAddr.Hex())
|
||||
if rawValue == nil || rawValue.Sign() <= 0 {
|
||||
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
|
||||
return
|
||||
}
|
||||
|
||||
chainTokens, err := data.ListChainTokens(chainNetwork)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-%s] load chain tokens: %v", net, tokenSym, err)
|
||||
return
|
||||
}
|
||||
var tokenConfig *mdb.ChainToken
|
||||
for _, t := range chainTokens {
|
||||
if strings.EqualFold(t.Symbol, tokenSym) {
|
||||
tokenConfig = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if tokenConfig == nil || !tokenConfig.Enabled {
|
||||
log.Sugar.Warnf("[%s-%s] token not enabled or configured in chain_tokens", net, tokenSym)
|
||||
return
|
||||
}
|
||||
|
||||
pow := decimal.New(1, int32(tokenConfig.Decimals))
|
||||
log.Sugar.Warnf("tokenConfig.Decimals %d pow %s", tokenConfig.Decimals, pow.String())
|
||||
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
|
||||
if amount <= 0 {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
||||
return
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(chainNetwork, walletAddr, tokenSym, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-%s][%s] lock lookup: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-%s][%s] load order: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
|
||||
return
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
||||
return
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: walletAddr,
|
||||
Token: tokenSym,
|
||||
Network: chainNetwork,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: txHash,
|
||||
}
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[%s-%s][%s] skip resolved trade_id=%s hash=%s err=%v", net, tokenSym, walletAddr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
|
||||
}
|
||||
|
||||
func sendPaymentNotification(order *mdb.Orders) {
|
||||
if order == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(order.TradeId) != "" {
|
||||
latest, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
|
||||
} else if latest != nil && latest.TradeId != "" {
|
||||
order = latest
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"🎉 <b>收款成功通知</b>\n\n"+
|
||||
"💰 <b>金额信息</b>\n"+
|
||||
@@ -263,6 +313,7 @@ func sendPaymentNotification(order *mdb.Orders) {
|
||||
"📋 <b>订单信息</b>\n"+
|
||||
"├ 交易号:<code>%s</code>\n"+
|
||||
"├ 订单号:<code>%s</code>\n"+
|
||||
"├ 网络:<code>%s</code>\n"+
|
||||
"└ 钱包地址:<code>%s</code>\n\n"+
|
||||
"⏰ <b>时间信息</b>\n"+
|
||||
"├ 创建时间:%s\n"+
|
||||
@@ -273,9 +324,32 @@ func sendPaymentNotification(order *mdb.Orders) {
|
||||
strings.ToUpper(order.Token),
|
||||
order.TradeId,
|
||||
order.OrderId,
|
||||
networkDisplay(order.Network),
|
||||
order.ReceiveAddress,
|
||||
order.CreatedAt.ToDateTimeString(),
|
||||
carbon.Now().ToDateTimeString(),
|
||||
order.UpdatedAt.ToDateTimeString(),
|
||||
)
|
||||
telegram.SendToBot(msg)
|
||||
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
|
||||
}
|
||||
|
||||
func networkDisplay(n string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(n)) {
|
||||
case mdb.NetworkTron:
|
||||
return "Tron"
|
||||
case mdb.NetworkSolana:
|
||||
return "Solana"
|
||||
case mdb.NetworkEthereum:
|
||||
return "Ethereum"
|
||||
case mdb.NetworkBsc:
|
||||
return "BSC"
|
||||
case mdb.NetworkPolygon:
|
||||
return "Polygon"
|
||||
case mdb.NetworkPlasma:
|
||||
return "Plasma"
|
||||
default:
|
||||
if n == "" {
|
||||
return "Tron"
|
||||
}
|
||||
return strings.ToUpper(n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
)
|
||||
|
||||
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
const channelType = "test-pay-success-time"
|
||||
got := make(chan string, 1)
|
||||
notify.RegisterSender(channelType, func(config, text string) error {
|
||||
got <- text
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := dao.Mdb.Create(&mdb.NotificationChannel{
|
||||
Type: channelType,
|
||||
Name: "test",
|
||||
Config: "{}",
|
||||
Events: `{"pay_success":true}`,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed notification channel: %v", err)
|
||||
}
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "T202604270001",
|
||||
OrderId: "ORD202604270001",
|
||||
Amount: 100,
|
||||
Currency: "cny",
|
||||
ActualAmount: 14.28,
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
ReceiveAddress: "TTestAddress",
|
||||
Status: mdb.StatusWaitPay,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
|
||||
const createdAt = "2026-04-27 09:00:00"
|
||||
const staleUpdatedAt = "2026-04-27 09:01:00"
|
||||
const paidAt = "2026-04-27 10:20:30"
|
||||
if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
|
||||
t.Fatalf("set initial timestamps: %v", err)
|
||||
}
|
||||
|
||||
var staleOrderModel mdb.Orders
|
||||
if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
|
||||
t.Fatalf("load stale order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
|
||||
t.Fatalf("set paid timestamp: %v", err)
|
||||
}
|
||||
|
||||
sendPaymentNotification(&staleOrderModel)
|
||||
|
||||
select {
|
||||
case text := <-got:
|
||||
if !strings.Contains(text, "支付时间:"+paidAt) {
|
||||
t.Fatalf("notification payment time = %q, want %s", text, paidAt)
|
||||
}
|
||||
if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
|
||||
t.Fatalf("notification used stale payment time: %q", text)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for notification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
|
||||
// TRON row from rpc_nodes.
|
||||
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_WithRow verifies that resolveTronNode uses the DB row
|
||||
// when an enabled TRON HTTP node is present.
|
||||
func TestResolveTronNode_WithRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Insert an enabled TRON http node.
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://custom-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "db-api-key",
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
|
||||
gotURL, gotKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTronNode(): %v", err)
|
||||
}
|
||||
if gotURL != "https://custom-tron.example.com" {
|
||||
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
|
||||
}
|
||||
if gotKey != "db-api-key" {
|
||||
t.Errorf("apiKey = %q, want \"db-api-key\"", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://disabled-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "disabled-key",
|
||||
Weight: 1,
|
||||
Enabled: true, // start enabled, then disable below
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
// Explicitly disable — GORM does not save bool zero-value on Create.
|
||||
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
|
||||
t.Fatalf("disable rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package mq
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+99
-12
@@ -2,20 +2,45 @@ package mq
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
)
|
||||
|
||||
// resolveOrderApiKey returns the api_keys row that signed the order.
|
||||
// Every order must carry an ApiKeyID (the default key is always seeded
|
||||
// at bootstrap, and the middleware/EPAY inline flow always stamps it).
|
||||
// If the row is missing or disabled, we return an error — using any
|
||||
// other secret would produce a signature the merchant can't verify.
|
||||
// The admin can resend the callback after fixing the key.
|
||||
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
|
||||
if order.ApiKeyID == 0 {
|
||||
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(order.ApiKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
|
||||
}
|
||||
if row.Status != mdb.ApiKeyStatusEnable {
|
||||
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
const batchSize = 100
|
||||
|
||||
const sqliteBusyRetryAttempts = 3
|
||||
@@ -23,6 +48,7 @@ const sqliteBusyRetryAttempts = 3
|
||||
type expirableOrder struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
TradeId string `gorm:"column:trade_id"`
|
||||
Network string `gorm:"column:network"`
|
||||
ReceiveAddress string `gorm:"column:receive_address"`
|
||||
Token string `gorm:"column:token"`
|
||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||
@@ -65,7 +91,7 @@ func processExpiredOrders() {
|
||||
var orders []expirableOrder
|
||||
err := withSQLiteBusyRetry(func() error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("id", "trade_id", "receive_address", "token", "actual_amount").
|
||||
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Where("created_at <= ?", expirationCutoff).
|
||||
Order("id asc").
|
||||
@@ -89,7 +115,7 @@ func processExpiredOrders() {
|
||||
if !expired {
|
||||
continue
|
||||
}
|
||||
if err = data.UnLockTransaction(order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||
}
|
||||
}
|
||||
@@ -161,8 +187,62 @@ func processCallback(tradeID string) {
|
||||
}
|
||||
|
||||
func sendOrderCallback(order *mdb.Orders) error {
|
||||
apiKeyRow, err := resolveOrderApiKey(order)
|
||||
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
|
||||
return errors.New("no api key row available for callback")
|
||||
}
|
||||
|
||||
switch order.PaymentType {
|
||||
case mdb.PaymentTypeEpay:
|
||||
// EPAY uses pid (integer) and secret_key as "key".
|
||||
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
||||
if convErr != nil {
|
||||
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
|
||||
}
|
||||
notifyData := response.OrderNotifyResponseEpay{
|
||||
PID: pidInt,
|
||||
TradeNo: order.TradeId,
|
||||
OutTradeNo: order.OrderId,
|
||||
Type: "alipay",
|
||||
Name: order.Name,
|
||||
Money: fmt.Sprintf("%.4f", order.Amount),
|
||||
TradeStatus: "TRADE_SUCCESS",
|
||||
}
|
||||
|
||||
signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formData := map[string]string{
|
||||
"pid": fmt.Sprintf("%d", notifyData.PID),
|
||||
"trade_no": notifyData.TradeNo,
|
||||
"out_trade_no": notifyData.OutTradeNo,
|
||||
"type": notifyData.Type,
|
||||
"name": notifyData.Name,
|
||||
"money": notifyData.Money,
|
||||
"trade_status": notifyData.TradeStatus,
|
||||
"sign": signstr2,
|
||||
"sign_type": "MD5",
|
||||
}
|
||||
|
||||
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Sugar.Infof("[mq] epay notify_url response status: %d, body: %s", epayResp.StatusCode(), string(epayResp.Body()))
|
||||
if epayResp.StatusCode() != http.StatusOK {
|
||||
return errors.New(epayResp.Status())
|
||||
}
|
||||
if !isCallbackAck(epayResp.Body()) {
|
||||
return errors.New("not ok")
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
orderResp := response.OrderNotifyResponse{
|
||||
Pid: apiKeyRow.Pid,
|
||||
TradeId: order.TradeId,
|
||||
OrderId: order.OrderId,
|
||||
Amount: order.Amount,
|
||||
@@ -172,7 +252,7 @@ func sendOrderCallback(order *mdb.Orders) error {
|
||||
BlockTransactionId: order.BlockTransactionId,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
}
|
||||
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
|
||||
signature, err := sign.Get(orderResp, apiKeyRow.SecretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,12 +268,19 @@ func sendOrderCallback(order *mdb.Orders) error {
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return errors.New(resp.Status())
|
||||
}
|
||||
if string(resp.Body()) != "ok" {
|
||||
if !isCallbackAck(resp.Body()) {
|
||||
return errors.New("not ok")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCallbackAck(body []byte) bool {
|
||||
ack := strings.ToLower(strings.TrimSpace(string(body)))
|
||||
return ack == "ok" || ack == "success"
|
||||
}
|
||||
|
||||
func cleanupExpiredTransactionLocks() {
|
||||
if err := data.CleanupExpiredTransactionLocks(); err != nil {
|
||||
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)
|
||||
|
||||
+411
-8
@@ -1,6 +1,7 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,10 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
)
|
||||
|
||||
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
|
||||
@@ -27,6 +29,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_1",
|
||||
Token: "USDT",
|
||||
Network: "tron",
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/callback",
|
||||
}
|
||||
@@ -36,7 +39,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
||||
if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
|
||||
t.Fatalf("age expired order: %v", err)
|
||||
}
|
||||
if err := data.LockTransaction(order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
|
||||
if err := data.LockTransaction("tron", order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
|
||||
t.Fatalf("lock expired order: %v", err)
|
||||
}
|
||||
|
||||
@@ -48,13 +51,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
||||
ActualAmount: 1.01,
|
||||
ReceiveAddress: "wallet_1",
|
||||
Token: "USDT",
|
||||
Network: "tron",
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: "https://merchant.example/callback",
|
||||
}
|
||||
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
|
||||
t.Fatalf("create recent order: %v", err)
|
||||
}
|
||||
if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
|
||||
if err := data.LockTransaction("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
|
||||
t.Fatalf("lock recent order: %v", err)
|
||||
}
|
||||
|
||||
@@ -67,7 +71,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
||||
if expired.Status != mdb.StatusExpired {
|
||||
t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired)
|
||||
}
|
||||
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("expired order lock lookup: %v", err)
|
||||
}
|
||||
@@ -82,7 +86,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
||||
if recent.Status != mdb.StatusWaitPay {
|
||||
t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay)
|
||||
}
|
||||
lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
|
||||
lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("recent order lock lookup: %v", err)
|
||||
}
|
||||
@@ -156,6 +160,7 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
|
||||
BlockTransactionId: "block_callback",
|
||||
CallbackNum: 1,
|
||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create callback order: %v", err)
|
||||
@@ -216,6 +221,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
|
||||
BlockTransactionId: "block_callback_restart",
|
||||
CallbackNum: 0,
|
||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create callback order: %v", err)
|
||||
@@ -257,6 +263,403 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
callbackLimiter = make(chan struct{}, 1)
|
||||
callbackInflight = sync.Map{}
|
||||
|
||||
epayKey, err := data.GetEnabledApiKey("1001")
|
||||
if err != nil || epayKey == nil || epayKey.ID == 0 {
|
||||
t.Fatalf("load epay key: %v", err)
|
||||
}
|
||||
|
||||
var requestCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&requestCount, 1)
|
||||
http.Error(w, "fail", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade_callback_epay_fail",
|
||||
OrderId: "order_callback_epay_fail",
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_epay_fail",
|
||||
Token: "USDT",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: server.URL,
|
||||
BlockTransactionId: "block_callback_epay_fail",
|
||||
CallbackNum: 0,
|
||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
ApiKeyID: epayKey.ID,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create callback order: %v", err)
|
||||
}
|
||||
|
||||
dispatchPendingCallbacks()
|
||||
|
||||
waitFor(t, 3*time.Second, func() bool {
|
||||
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if innerErr != nil || current.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return current.CallBackConfirm == mdb.CallBackConfirmNo && current.CallbackNum == 1
|
||||
})
|
||||
|
||||
if got := atomic.LoadInt32(&requestCount); got != 1 {
|
||||
t.Fatalf("callback request count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
callbackLimiter = make(chan struct{}, 1)
|
||||
callbackInflight = sync.Map{}
|
||||
|
||||
epayKey, err := data.GetEnabledApiKey("1001")
|
||||
if err != nil || epayKey == nil || epayKey.ID == 0 {
|
||||
t.Fatalf("load epay key: %v", err)
|
||||
}
|
||||
|
||||
var requestCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&requestCount, 1)
|
||||
_, _ = io.WriteString(w, " ok\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade_callback_epay_ok",
|
||||
OrderId: "order_callback_epay_ok",
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_epay_ok",
|
||||
Token: "USDT",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: server.URL,
|
||||
BlockTransactionId: "block_callback_epay_ok",
|
||||
CallbackNum: 0,
|
||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
ApiKeyID: epayKey.ID,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create callback order: %v", err)
|
||||
}
|
||||
|
||||
dispatchPendingCallbacks()
|
||||
|
||||
waitFor(t, 3*time.Second, func() bool {
|
||||
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if innerErr != nil || current.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
|
||||
})
|
||||
|
||||
if got := atomic.LoadInt32(&requestCount); got != 1 {
|
||||
t.Fatalf("callback request count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Orders must always carry an ApiKeyID (the default key is seeded at
|
||||
// bootstrap and the signing middleware / EPAY inline flow always
|
||||
// stamps it). If ApiKeyID is 0, resolveOrderApiKey returns an error —
|
||||
// the callback won't sign with a guess.
|
||||
func TestResolveOrderApiKeyRejectsZeroApiKeyID(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "no-api-key-id",
|
||||
ApiKeyID: 0,
|
||||
}
|
||||
if _, err := resolveOrderApiKey(order); err == nil {
|
||||
t.Fatal("expected error for ApiKeyID=0")
|
||||
}
|
||||
}
|
||||
|
||||
// Happy path: when ApiKeyID points at an enabled row, resolveOrderApiKey
|
||||
// returns it.
|
||||
func TestResolveOrderApiKeyReturnsEnabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
row := &mdb.ApiKey{
|
||||
Name: "target",
|
||||
Pid: "target-key",
|
||||
SecretKey: "target-secret",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(row).Error; err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
order := &mdb.Orders{
|
||||
TradeId: "happy-path",
|
||||
ApiKeyID: row.ID,
|
||||
}
|
||||
got, err := resolveOrderApiKey(order)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got == nil || got.ID != row.ID {
|
||||
t.Fatalf("resolved row = %+v, want id=%d", got, row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendOrderCallbackGmpayUsesApiKeySecretByPid(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
key := &mdb.ApiKey{
|
||||
Name: "gmpay-key",
|
||||
Pid: "9001",
|
||||
SecretKey: "gmpay-secret-9001",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(key).Error; err != nil {
|
||||
t.Fatalf("create gmpay api key: %v", err)
|
||||
}
|
||||
|
||||
warningKey := &mdb.ApiKey{
|
||||
Name: "wrong-key",
|
||||
Pid: "9002",
|
||||
SecretKey: "wrong-secret-9002",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(warningKey).Error; err != nil {
|
||||
t.Fatalf("create wrong api key: %v", err)
|
||||
}
|
||||
|
||||
var received map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &received)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade_gmpay_sign",
|
||||
OrderId: "order_gmpay_sign",
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_gmpay",
|
||||
Token: "USDT",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: server.URL,
|
||||
BlockTransactionId: "block_gmpay_sign",
|
||||
ApiKeyID: key.ID,
|
||||
}
|
||||
|
||||
if err := sendOrderCallback(order); err != nil {
|
||||
t.Fatalf("send callback: %v", err)
|
||||
}
|
||||
|
||||
if received == nil {
|
||||
t.Fatal("expected callback payload")
|
||||
}
|
||||
if gotPid, _ := received["pid"].(string); gotPid != key.Pid {
|
||||
t.Fatalf("payload pid = %q, want %q", gotPid, key.Pid)
|
||||
}
|
||||
|
||||
recvSig, _ := received["signature"].(string)
|
||||
if recvSig == "" {
|
||||
t.Fatal("payload signature is empty")
|
||||
}
|
||||
delete(received, "signature")
|
||||
|
||||
calcSig, err := sign.Get(received, key.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("calc signature with target secret: %v", err)
|
||||
}
|
||||
if recvSig != calcSig {
|
||||
t.Fatalf("signature mismatch: got %q want %q", recvSig, calcSig)
|
||||
}
|
||||
|
||||
wrongSig, err := sign.Get(received, warningKey.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("calc signature with wrong secret: %v", err)
|
||||
}
|
||||
if recvSig == wrongSig {
|
||||
t.Fatal("signature should not match wrong api key secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
key := &mdb.ApiKey{
|
||||
Name: "epay-key",
|
||||
Pid: "9101",
|
||||
SecretKey: "epay-secret-9101",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(key).Error; err != nil {
|
||||
t.Fatalf("create epay api key: %v", err)
|
||||
}
|
||||
|
||||
warningKey := &mdb.ApiKey{
|
||||
Name: "wrong-epay-key",
|
||||
Pid: "9102",
|
||||
SecretKey: "wrong-epay-secret-9102",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(warningKey).Error; err != nil {
|
||||
t.Fatalf("create wrong epay api key: %v", err)
|
||||
}
|
||||
|
||||
formPayload := map[string]string{}
|
||||
callbackMethod := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callbackMethod = r.Method
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for k, v := range r.Form {
|
||||
if len(v) > 0 {
|
||||
formPayload[k] = v[0]
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade_epay_sign",
|
||||
OrderId: "order_epay_sign",
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_epay",
|
||||
Token: "USDT",
|
||||
Name: "VIP",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: server.URL,
|
||||
BlockTransactionId: "block_epay_sign",
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
ApiKeyID: key.ID,
|
||||
}
|
||||
|
||||
if err := sendOrderCallback(order); err != nil {
|
||||
t.Fatalf("send epay callback: %v", err)
|
||||
}
|
||||
|
||||
if callbackMethod != http.MethodGet {
|
||||
t.Fatalf("callback method = %s, want %s", callbackMethod, http.MethodGet)
|
||||
}
|
||||
if formPayload["sign_type"] != "MD5" {
|
||||
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
|
||||
}
|
||||
if formPayload["type"] != "alipay" {
|
||||
t.Fatalf("type = %q, want alipay", formPayload["type"])
|
||||
}
|
||||
if formPayload["trade_status"] != "TRADE_SUCCESS" {
|
||||
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
|
||||
}
|
||||
|
||||
if formPayload["pid"] != key.Pid {
|
||||
t.Fatalf("form pid = %q, want %q", formPayload["pid"], key.Pid)
|
||||
}
|
||||
recvSig := formPayload["sign"]
|
||||
if recvSig == "" {
|
||||
t.Fatal("epay sign is empty")
|
||||
}
|
||||
|
||||
signParams := map[string]interface{}{
|
||||
"pid": formPayload["pid"],
|
||||
"trade_no": formPayload["trade_no"],
|
||||
"out_trade_no": formPayload["out_trade_no"],
|
||||
"type": formPayload["type"],
|
||||
"name": formPayload["name"],
|
||||
"money": formPayload["money"],
|
||||
"trade_status": formPayload["trade_status"],
|
||||
}
|
||||
|
||||
calcSig, err := sign.Get(signParams, key.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("calc epay signature with target secret: %v", err)
|
||||
}
|
||||
if recvSig != calcSig {
|
||||
t.Fatalf("epay signature mismatch: got %q want %q", recvSig, calcSig)
|
||||
}
|
||||
|
||||
wrongSig, err := sign.Get(signParams, warningKey.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("calc epay signature with wrong secret: %v", err)
|
||||
}
|
||||
if recvSig == wrongSig {
|
||||
t.Fatal("epay signature should not match wrong api key secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
callbackLimiter = make(chan struct{}, 1)
|
||||
callbackInflight = sync.Map{}
|
||||
|
||||
epayKey, err := data.GetEnabledApiKey("1001")
|
||||
if err != nil || epayKey == nil || epayKey.ID == 0 {
|
||||
t.Fatalf("load epay key: %v", err)
|
||||
}
|
||||
|
||||
var requestCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&requestCount, 1)
|
||||
_, _ = io.WriteString(w, "success")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
order := &mdb.Orders{
|
||||
TradeId: "trade_callback_epay_success",
|
||||
OrderId: "order_callback_epay_success",
|
||||
Amount: 1,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1,
|
||||
ReceiveAddress: "wallet_epay_success",
|
||||
Token: "USDT",
|
||||
Status: mdb.StatusPaySuccess,
|
||||
NotifyUrl: server.URL,
|
||||
BlockTransactionId: "block_callback_epay_success",
|
||||
CallbackNum: 0,
|
||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||
PaymentType: mdb.PaymentTypeEpay,
|
||||
ApiKeyID: epayKey.ID,
|
||||
}
|
||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||
t.Fatalf("create callback order: %v", err)
|
||||
}
|
||||
|
||||
dispatchPendingCallbacks()
|
||||
|
||||
waitFor(t, 3*time.Second, func() bool {
|
||||
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
|
||||
if innerErr != nil || current.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
|
||||
})
|
||||
|
||||
if got := atomic.LoadInt32(&requestCount); got != 1 {
|
||||
t.Fatalf("callback request count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package notify fans out domain events (order paid, order expired,
|
||||
// etc.) to every NotificationChannel subscribed to the event. Senders
|
||||
// are type-keyed (telegram/webhook/email/...) so adding a new push
|
||||
// method means registering one function; no callers change.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
)
|
||||
|
||||
// Sender delivers an already-rendered text message via a channel row.
|
||||
// Config is the channel's Config JSON string (type-specific shape).
|
||||
type Sender func(config, text string) error
|
||||
|
||||
var (
|
||||
sendersMu sync.RWMutex
|
||||
senders = map[string]Sender{}
|
||||
)
|
||||
|
||||
// RegisterSender wires a Sender for a channel type. Called once at
|
||||
// package init of each sender file (e.g. telegram_sender.go).
|
||||
func RegisterSender(channelType string, s Sender) {
|
||||
sendersMu.Lock()
|
||||
senders[channelType] = s
|
||||
sendersMu.Unlock()
|
||||
}
|
||||
|
||||
func getSender(channelType string) (Sender, bool) {
|
||||
sendersMu.RLock()
|
||||
defer sendersMu.RUnlock()
|
||||
s, ok := senders[channelType]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Dispatch delivers text to every enabled channel subscribed to event.
|
||||
// Per-channel failures are logged but don't abort the fan-out — one
|
||||
// broken TG token shouldn't prevent webhook delivery.
|
||||
func Dispatch(event, text string) {
|
||||
channels, err := data.ListEnabledChannelsByEvent(event)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[notify] list channels failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, ch := range channels {
|
||||
sender, ok := getSender(ch.Type)
|
||||
if !ok {
|
||||
log.Sugar.Warnf("[notify] no sender registered for type=%s (channel_id=%d)", ch.Type, ch.ID)
|
||||
continue
|
||||
}
|
||||
go func(c mdb.NotificationChannel) {
|
||||
if err := sender(c.Config, text); err != nil {
|
||||
log.Sugar.Errorf("[notify] send failed type=%s channel_id=%d: %v", c.Type, c.ID, err)
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseConfig helper for senders: unmarshal channel Config JSON into
|
||||
// an arbitrary struct, returning a typed error on invalid JSON.
|
||||
func ParseConfig(raw string, out interface{}) error {
|
||||
if raw == "" {
|
||||
return ErrEmptyConfig
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), out)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package notify
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoSender = errors.New("no sender registered for channel type")
|
||||
ErrEmptyConfig = errors.New("channel config is empty")
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tb "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
// TelegramConfig mirrors the Config JSON of a telegram channel row.
|
||||
type TelegramConfig struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterSender("telegram", sendTelegram)
|
||||
}
|
||||
|
||||
// botCache caches *tb.Bot instances keyed by bot_token so we don't
|
||||
// create a new Bot (and call getMe) on every notification.
|
||||
var (
|
||||
botCacheMu sync.RWMutex
|
||||
botCache = map[string]*tb.Bot{}
|
||||
)
|
||||
|
||||
func getOrCreateBot(cfg *TelegramConfig) (*tb.Bot, error) {
|
||||
botCacheMu.RLock()
|
||||
bot, ok := botCache[cfg.BotToken]
|
||||
botCacheMu.RUnlock()
|
||||
if ok {
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
settings := tb.Settings{
|
||||
Token: cfg.BotToken,
|
||||
Poller: &tb.LongPoller{Timeout: 1 * time.Second},
|
||||
Synchronous: true,
|
||||
Offline: true,
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
settings.URL = cfg.Proxy
|
||||
}
|
||||
bot, err := tb.NewBot(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
botCacheMu.Lock()
|
||||
botCache[cfg.BotToken] = bot
|
||||
botCacheMu.Unlock()
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// ParseTelegramConfig decodes telegram channel config and supports
|
||||
// chat_id in either numeric or string form so API clients can send
|
||||
// both JSON number and quoted text.
|
||||
func ParseTelegramConfig(raw string) (*TelegramConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, ErrEmptyConfig
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &TelegramConfig{}
|
||||
cfg.BotToken = pickFirstString(payload, "bot_token", "botToken", "bot-token")
|
||||
if cfg.BotToken == "" {
|
||||
return nil, fmt.Errorf("telegram config.bot_token required")
|
||||
}
|
||||
|
||||
chatRaw, ok := pickFirstValue(payload, "chat_id", "chatId", "chat-id")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("telegram config.chat_id required")
|
||||
}
|
||||
chatID, err := parseTelegramChatID(chatRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.ChatID = chatID
|
||||
|
||||
cfg.Proxy = pickFirstString(payload, "proxy", "proxy_url", "proxyUrl")
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func pickFirstString(payload map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := payload[k].(string); ok {
|
||||
s := strings.TrimSpace(v)
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickFirstValue(payload map[string]interface{}, keys ...string) (interface{}, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := payload[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parseTelegramChatID(v interface{}) (int64, error) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int64(t), nil
|
||||
case json.Number:
|
||||
return t.Int64()
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("telegram config.chat_id required")
|
||||
}
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("telegram config.chat_id invalid: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("telegram config.chat_id invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
func sendTelegram(configJSON, text string) error {
|
||||
cfg, err := ParseTelegramConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot, err := getOrCreateBot(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = bot.Send(&tb.User{ID: cfg.ChatID}, text, &tb.SendOptions{ParseMode: tb.ModeHTML})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package notify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTelegramConfigAcceptsNumericChatID(t *testing.T) {
|
||||
raw := `{"bot_token":"123:ABC","chat_id":123456789}`
|
||||
cfg, err := ParseTelegramConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if cfg.BotToken != "123:ABC" {
|
||||
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
|
||||
}
|
||||
if cfg.ChatID != 123456789 {
|
||||
t.Fatalf("chat id = %d, want %d", cfg.ChatID, 123456789)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramConfigAcceptsStringChatID(t *testing.T) {
|
||||
raw := `{"bot_token":"123:ABC","chat_id":"-1001234567890"}`
|
||||
cfg, err := ParseTelegramConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if cfg.ChatID != -1001234567890 {
|
||||
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramConfigRejectsInvalidChatID(t *testing.T) {
|
||||
raw := `{"bot_token":"123:ABC","chat_id":"not-a-number"}`
|
||||
_, err := ParseTelegramConfig(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error for invalid chat_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramConfigAcceptsCamelCaseKeys(t *testing.T) {
|
||||
raw := `{"botToken":"123:ABC","chatId":"-1001234567890","proxyUrl":"http://127.0.0.1:7890"}`
|
||||
cfg, err := ParseTelegramConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if cfg.BotToken != "123:ABC" {
|
||||
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
|
||||
}
|
||||
if cfg.ChatID != -1001234567890 {
|
||||
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
|
||||
}
|
||||
if cfg.Proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy = %q, want %q", cfg.Proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
testAdminUsername = "admin"
|
||||
testAdminPassword = "test-admin-pass-123"
|
||||
)
|
||||
|
||||
// setupAdminTestEnv sets up the test environment with a seeded admin user
|
||||
// and returns the echo instance and a valid JWT token.
|
||||
func setupAdminTestEnv(t *testing.T) (*echo.Echo, string) {
|
||||
t.Helper()
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Seed admin user with a known password.
|
||||
hash, err := data.HashPassword(testAdminPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: testAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
})
|
||||
|
||||
// Login to obtain a JWT for subsequent authenticated requests.
|
||||
token := adminLogin(t, e, testAdminUsername, testAdminPassword)
|
||||
return e, token
|
||||
}
|
||||
|
||||
// adminLogin performs a login request and returns the JWT token.
|
||||
func adminLogin(t *testing.T, e *echo.Echo, username, password string) string {
|
||||
t.Helper()
|
||||
body := map[string]interface{}{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
rec := doPost(e, "/admin/api/v1/auth/login", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login failed: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("login unmarshal: %v", err)
|
||||
}
|
||||
dataObj, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("login response missing data field: %s", rec.Body.String())
|
||||
}
|
||||
token, ok := dataObj["token"].(string)
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("login response missing token: %s", rec.Body.String())
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// doGetAdmin sends an authenticated GET request with a Bearer JWT.
|
||||
func doGetAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// doPostAdmin sends an authenticated POST request with a Bearer JWT.
|
||||
func doPostAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
|
||||
jsonBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// doPatchAdmin sends an authenticated PATCH request with a Bearer JWT.
|
||||
func doPatchAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
|
||||
jsonBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// doDeleteAdmin sends an authenticated DELETE request with a Bearer JWT.
|
||||
func doDeleteAdmin(e *echo.Echo, path, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// doPutAdmin sends an authenticated PUT request with a Bearer JWT.
|
||||
func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token string) *httptest.ResponseRecorder {
|
||||
jsonBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPut, path, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// doGet sends an unauthenticated GET request.
|
||||
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// assertOK asserts the response status is 200 and the status_code field is 200.
|
||||
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
code, _ := resp["status_code"].(float64)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected status_code=200, got %v: %s", code, rec.Body.String())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// assertUnauthorized asserts that a request without a valid JWT returns 401.
|
||||
func assertUnauthorized(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLogin_Success verifies correct credentials return 200 + JWT.
|
||||
func TestAdminLogin_Success(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
hash, _ := data.HashPassword(testAdminPassword)
|
||||
dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: testAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
})
|
||||
|
||||
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
|
||||
"username": testAdminUsername,
|
||||
"password": testAdminPassword,
|
||||
})
|
||||
t.Logf("Login response: %s", rec.Body.String())
|
||||
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
if dataObj["token"] == "" || dataObj["token"] == nil {
|
||||
t.Fatal("expected non-empty token in login response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLogin_WrongPassword verifies wrong credentials return 4xx.
|
||||
func TestAdminLogin_WrongPassword(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
hash, _ := data.HashPassword(testAdminPassword)
|
||||
dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: testAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
})
|
||||
|
||||
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
|
||||
"username": testAdminUsername,
|
||||
"password": "wrong-password",
|
||||
})
|
||||
t.Logf("Login wrong-password response: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code == 200 {
|
||||
t.Fatal("expected failure for wrong password, got 200")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLogin_MissingFields verifies missing required fields return 4xx.
|
||||
func TestAdminLogin_MissingFields(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
rec := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
|
||||
"username": testAdminUsername,
|
||||
// password missing
|
||||
})
|
||||
t.Logf("Login missing-fields response: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code == 200 {
|
||||
t.Fatal("expected validation failure, got code=200")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminProtectedRoute_NoToken verifies protected routes reject unauthenticated requests.
|
||||
func TestAdminProtectedRoute_NoToken(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
protectedRoutes := []string{
|
||||
"/admin/api/v1/auth/me",
|
||||
"/admin/api/v1/api-keys",
|
||||
"/admin/api/v1/chains",
|
||||
"/admin/api/v1/wallets",
|
||||
"/admin/api/v1/orders",
|
||||
"/admin/api/v1/dashboard/overview",
|
||||
"/admin/api/v1/settings",
|
||||
"/admin/api/v1/rpc-nodes",
|
||||
"/admin/api/v1/notification-channels",
|
||||
}
|
||||
for _, path := range protectedRoutes {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("GET %s → %d", path, rec.Code)
|
||||
assertUnauthorized(t, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMe verifies the /auth/me route returns current user info.
|
||||
func TestAdminMe(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||
t.Logf("Me response: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
if dataObj["username"] != testAdminUsername {
|
||||
t.Fatalf("expected username=%s, got %v", testAdminUsername, dataObj["username"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLogout verifies the /auth/logout route succeeds with a valid token.
|
||||
func TestAdminLogout(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/auth/logout", nil, token)
|
||||
t.Logf("Logout response: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// TestAdminChangePassword verifies the change-password route works correctly.
|
||||
func TestAdminChangePassword(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
|
||||
"old_password": testAdminPassword,
|
||||
"new_password": "new-pass-456",
|
||||
}, token)
|
||||
t.Logf("ChangePassword response: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Verify old password no longer works.
|
||||
rec2 := doPost(e, "/admin/api/v1/auth/login", map[string]interface{}{
|
||||
"username": testAdminUsername,
|
||||
"password": testAdminPassword,
|
||||
})
|
||||
var resp2 map[string]interface{}
|
||||
json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
code2, _ := resp2["code"].(float64)
|
||||
if code2 == 200 {
|
||||
t.Fatal("old password should no longer work after change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
|
||||
// and hash-based "password changed" detection flow.
|
||||
func TestAdminInitPasswordFlow(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
const initPassword = "init-pass-123456"
|
||||
|
||||
adminHash, err := data.HashPassword(initPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
dao.Mdb.Create(&mdb.AdminUser{
|
||||
Username: testAdminUsername,
|
||||
PasswordHash: adminHash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
})
|
||||
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
|
||||
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
|
||||
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
|
||||
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
|
||||
|
||||
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
||||
respHash := assertOK(t, recHash)
|
||||
hashData, _ := respHash["data"].(map[string]interface{})
|
||||
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
|
||||
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
|
||||
}
|
||||
if got, _ := hashData["password_changed"].(bool); got {
|
||||
t.Fatalf("expected password_changed=false before change, got true")
|
||||
}
|
||||
|
||||
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
|
||||
respFetch := assertOK(t, recFetch)
|
||||
fetchData, _ := respFetch["data"].(map[string]interface{})
|
||||
if fetchData["username"] != testAdminUsername {
|
||||
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
|
||||
}
|
||||
if fetchData["password"] != initPassword {
|
||||
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
|
||||
}
|
||||
|
||||
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
|
||||
if recFetch2.Code != http.StatusBadRequest {
|
||||
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
|
||||
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
|
||||
}
|
||||
|
||||
token := adminLogin(t, e, testAdminUsername, initPassword)
|
||||
|
||||
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||
respMe1 := assertOK(t, recMe1)
|
||||
meData1, _ := respMe1["data"].(map[string]interface{})
|
||||
if got, _ := meData1["password_is_default"].(bool); !got {
|
||||
t.Fatalf("expected password_is_default=true before change, got %v", got)
|
||||
}
|
||||
|
||||
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
|
||||
"old_password": initPassword,
|
||||
"new_password": "new-pass-789",
|
||||
}, token)
|
||||
assertOK(t, recChange)
|
||||
|
||||
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
|
||||
respHash2 := assertOK(t, recHash2)
|
||||
hashData2, _ := respHash2["data"].(map[string]interface{})
|
||||
if got, _ := hashData2["password_changed"].(bool); !got {
|
||||
t.Fatalf("expected password_changed=true after change, got %v", got)
|
||||
}
|
||||
|
||||
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||
respMe2 := assertOK(t, recMe2)
|
||||
meData2, _ := respMe2["data"].(map[string]interface{})
|
||||
if got, _ := meData2["password_is_default"].(bool); got {
|
||||
t.Fatalf("expected password_is_default=false after change, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API Keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
|
||||
// stats, rotate, and delete for API keys.
|
||||
func TestAdminApiKeys_CRUD(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List — should contain seeded keys from setupTestEnv.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/api-keys", token)
|
||||
t.Logf("ListApiKeys: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Create a new key.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/api-keys", map[string]interface{}{
|
||||
"name": "test-created-key",
|
||||
}, token)
|
||||
t.Logf("CreateApiKey: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
keyID := dataObj["id"]
|
||||
if keyID == nil {
|
||||
t.Fatal("CreateApiKey response missing id")
|
||||
}
|
||||
keyIDStr := fmt.Sprintf("%.0f", keyID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, map[string]interface{}{
|
||||
"name": "renamed-key",
|
||||
}, token)
|
||||
t.Logf("UpdateApiKey: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Get secret.
|
||||
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/secret", token)
|
||||
t.Logf("GetApiKeySecret: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Get stats.
|
||||
rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/stats", token)
|
||||
t.Logf("GetApiKeyStats: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Change status.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/status", map[string]interface{}{
|
||||
"status": 2, // disable
|
||||
}, token)
|
||||
t.Logf("ChangeApiKeyStatus: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Rotate secret.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/rotate-secret", nil, token)
|
||||
t.Logf("RotateApiKeySecret: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Delete.
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, token)
|
||||
t.Logf("DeleteApiKey: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// ─── Chains ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminChains_ListAndUpdate verifies listing and updating chains.
|
||||
func TestAdminChains_ListAndUpdate(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List chains — seeded in setupTestEnv.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/chains", token)
|
||||
t.Logf("ListChains: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Update the tron chain display name.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/chains/tron", map[string]interface{}{
|
||||
"display_name": "TRON Updated",
|
||||
}, token)
|
||||
t.Logf("UpdateChain: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// ─── Chain Tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminChainTokens_CRUD verifies CRUD operations for chain tokens.
|
||||
func TestAdminChainTokens_CRUD(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List — seeded chain tokens.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/chain-tokens", token)
|
||||
t.Logf("ListChainTokens: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Create.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"symbol": "TEST",
|
||||
"contract_address": "0xTESTADDRESS",
|
||||
"decimals": 18,
|
||||
}, token)
|
||||
t.Logf("CreateChainToken: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
tokenID := dataObj["id"]
|
||||
if tokenID == nil {
|
||||
t.Fatal("CreateChainToken missing id")
|
||||
}
|
||||
tokenIDStr := fmt.Sprintf("%.0f", tokenID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, map[string]interface{}{
|
||||
"symbol": "TEST2",
|
||||
}, token)
|
||||
t.Logf("UpdateChainToken: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Change status.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr+"/status", map[string]interface{}{
|
||||
"enabled": false,
|
||||
}, token)
|
||||
t.Logf("ChangeChainTokenStatus: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Delete.
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/chain-tokens/"+tokenIDStr, token)
|
||||
t.Logf("DeleteChainToken: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// ─── RPC Nodes ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminRpcNodes_CRUD verifies CRUD operations for RPC nodes.
|
||||
func TestAdminRpcNodes_CRUD(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List — empty initially.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/rpc-nodes", token)
|
||||
t.Logf("ListRpcNodes: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Create.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": "http",
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
nodeID := dataObj["id"]
|
||||
if nodeID == nil {
|
||||
t.Fatal("CreateRpcNode missing id")
|
||||
}
|
||||
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
||||
"url": "https://eth-mainnet2.example.com",
|
||||
}, token)
|
||||
t.Logf("UpdateRpcNode: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Health check — network likely unreachable in test, but route must not 404/500.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
|
||||
t.Logf("HealthCheckRpcNode: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatalf("health-check route returned 404")
|
||||
}
|
||||
|
||||
// Delete.
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, token)
|
||||
t.Logf("DeleteRpcNode: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// ─── Wallets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminWallets_CRUD verifies wallet management routes.
|
||||
func TestAdminWallets_CRUD(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List — two wallets seeded in setupTestEnv.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/wallets", token)
|
||||
t.Logf("ListWallets: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
_ = resp
|
||||
|
||||
// Add a new wallet.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/wallets", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"address": "0xTestEthAddress001",
|
||||
}, token)
|
||||
t.Logf("AddWallet: %s", rec.Body.String())
|
||||
resp2 := assertOK(t, rec)
|
||||
dataObj, _ := resp2["data"].(map[string]interface{})
|
||||
walletID := dataObj["id"]
|
||||
if walletID == nil {
|
||||
t.Fatal("AddWallet missing id")
|
||||
}
|
||||
walletIDStr := fmt.Sprintf("%.0f", walletID.(float64))
|
||||
|
||||
// Get single wallet.
|
||||
rec = doGetAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
|
||||
t.Logf("GetWallet: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, map[string]interface{}{
|
||||
"address": "0xTestEthAddress002",
|
||||
}, token)
|
||||
t.Logf("UpdateWallet: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Change status.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/wallets/"+walletIDStr+"/status", map[string]interface{}{
|
||||
"status": 2, // disable
|
||||
}, token)
|
||||
t.Logf("ChangeWalletStatus: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Delete.
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/wallets/"+walletIDStr, token)
|
||||
t.Logf("DeleteWallet: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// TestAdminWallets_BatchImport verifies batch wallet import.
|
||||
func TestAdminWallets_BatchImport(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/wallets/batch-import", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"addresses": []string{"0xBatchAddr001", "0xBatchAddr002"},
|
||||
}, token)
|
||||
t.Logf("BatchImportWallets: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// ─── Orders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminOrders_List verifies listing orders (empty initially).
|
||||
func TestAdminOrders_List(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doGetAdmin(e, "/admin/api/v1/orders", token)
|
||||
t.Logf("ListOrders: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// TestAdminOrders_Export verifies the export endpoint is accessible.
|
||||
func TestAdminOrders_Export(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doGetAdmin(e, "/admin/api/v1/orders/export", token)
|
||||
t.Logf("ExportOrders: status=%d", rec.Code)
|
||||
// Should return 200 (even with empty result set).
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_GetNotFound verifies a non-existent trade_id returns a
|
||||
// non-200 business code (not a 404 or 500 HTTP error).
|
||||
func TestAdminOrders_GetNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doGetAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id", token)
|
||||
t.Logf("GetOrder (not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
// Should not be a server error.
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_CloseNotFound verifies closing a non-existent order returns
|
||||
// a graceful error (not 500).
|
||||
func TestAdminOrders_CloseNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/close", nil, token)
|
||||
t.Logf("CloseOrder (not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
|
||||
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/mark-paid", nil, token)
|
||||
t.Logf("MarkOrderPaid (not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
||||
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/nonexistent-trade-id/resend-callback", nil, token)
|
||||
t.Logf("ResendCallback (not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
||||
func TestAdminDashboard_AllRoutes(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
routes := []string{
|
||||
"/admin/api/v1/dashboard/overview",
|
||||
"/admin/api/v1/dashboard/asset-trend",
|
||||
"/admin/api/v1/dashboard/revenue-trend",
|
||||
"/admin/api/v1/dashboard/order-stats",
|
||||
"/admin/api/v1/dashboard/recent-orders",
|
||||
}
|
||||
for _, path := range routes {
|
||||
rec := doGetAdmin(e, path, token)
|
||||
t.Logf("GET %s → %d: %s", path, rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminSettings_ListAndUpsert verifies listing and upserting settings.
|
||||
func TestAdminSettings_ListAndUpsert(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List settings.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/settings", token)
|
||||
t.Logf("ListSettings: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Upsert a setting.
|
||||
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"},
|
||||
},
|
||||
}, token)
|
||||
t.Logf("UpsertSettings: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
|
||||
func TestAdminSettings_DeleteNonExistent(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
rec := doDeleteAdmin(e, "/admin/api/v1/settings/nonexistent.key", token)
|
||||
t.Logf("DeleteSetting (nonexistent): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
// Should not be a server error.
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notification Channels ───────────────────────────────────────────────────
|
||||
|
||||
// TestAdminNotificationChannels_CRUD verifies notification channel routes.
|
||||
func TestAdminNotificationChannels_CRUD(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// List — empty initially.
|
||||
rec := doGetAdmin(e, "/admin/api/v1/notification-channels", token)
|
||||
t.Logf("ListNotificationChannels: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Create a webhook channel.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
|
||||
"name": "test-webhook",
|
||||
"type": "webhook",
|
||||
"config": map[string]interface{}{"url": "http://localhost/webhook"},
|
||||
"events": map[string]bool{"order.paid": true},
|
||||
}, token)
|
||||
t.Logf("CreateNotificationChannel: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
chanID := dataObj["id"]
|
||||
if chanID == nil {
|
||||
t.Fatal("CreateNotificationChannel missing id")
|
||||
}
|
||||
chanIDStr := fmt.Sprintf("%.0f", chanID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, map[string]interface{}{
|
||||
"name": "renamed-webhook",
|
||||
}, token)
|
||||
t.Logf("UpdateNotificationChannel: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Change status.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr+"/status", map[string]interface{}{
|
||||
"enabled": false,
|
||||
}, token)
|
||||
t.Logf("ChangeNotificationChannelStatus: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
|
||||
// Delete.
|
||||
rec = doDeleteAdmin(e, "/admin/api/v1/notification-channels/"+chanIDStr, token)
|
||||
t.Logf("DeleteNotificationChannel: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
// TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility verifies
|
||||
// the admin API accepts common frontend telegram payload variants:
|
||||
// - type in mixed case ("Telegram")
|
||||
// - config in camelCase keys (botToken/chatId)
|
||||
// - events as string array
|
||||
func TestAdminNotificationChannels_TelegramFrontendPayloadCompatibility(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/notification-channels", map[string]interface{}{
|
||||
"name": "frontend-telegram",
|
||||
"type": "Telegram",
|
||||
"config": map[string]interface{}{
|
||||
"botToken": "123:ABC",
|
||||
"chatId": "-1001234567890",
|
||||
},
|
||||
"events": []string{"order.paid", "order.expired"},
|
||||
}, token)
|
||||
t.Logf("CreateFrontendStyleTelegramChannel: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
|
||||
dataObj, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing data object: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
eventsRaw, _ := dataObj["events"].(string)
|
||||
if eventsRaw == "" {
|
||||
t.Fatalf("missing events json in response data: %s", rec.Body.String())
|
||||
}
|
||||
var events map[string]bool
|
||||
if err := json.Unmarshal([]byte(eventsRaw), &events); err != nil {
|
||||
t.Fatalf("unmarshal events: %v", err)
|
||||
}
|
||||
if !events["order.paid"] || !events["order.expired"] {
|
||||
t.Fatalf("events not normalized as expected: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Orders list-with-sub ─────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel verifies that
|
||||
// sub-orders do NOT appear at the top level of /orders/list-with-sub and are
|
||||
// instead nested inside their parent's sub_orders array.
|
||||
func TestAdminOrders_ListWithSubExcludesSubOrdersFromTopLevel(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
// Seed Ethereum wallet and chain_token so SwitchNetwork can allocate
|
||||
// a sub-order on that network. The chain record is already seeded by
|
||||
// MdbTableInit; only the wallet address and token are needed here.
|
||||
dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Status: mdb.TokenStatusEnable,
|
||||
})
|
||||
if err := dao.Mdb.Where(mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT"}).
|
||||
FirstOrCreate(&mdb.ChainToken{
|
||||
Network: mdb.NetworkEthereum, Symbol: "USDT",
|
||||
ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
Decimals: 6, Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed ethereum USDT token: %v", err)
|
||||
}
|
||||
|
||||
// Create a parent order via the gmpay API.
|
||||
parentBody := signBody(map[string]interface{}{
|
||||
"order_id": "listsubtest-parent",
|
||||
"amount": 5.0,
|
||||
"currency": "CNY",
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
"notify_url": "https://merchant.example/callback",
|
||||
"redirect_url": "https://merchant.example/redirect",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", parentBody)
|
||||
t.Logf("CreateParent: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create parent: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var parentResp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &parentResp)
|
||||
parentTradeId := parentResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
|
||||
// Switch to Ethereum to create a sub-order (no signature required).
|
||||
rec = doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
"trade_id": parentTradeId,
|
||||
"token": "USDT",
|
||||
"network": "ethereum",
|
||||
})
|
||||
t.Logf("SwitchNetwork: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("switch network: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var switchResp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &switchResp)
|
||||
subTradeId := switchResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
|
||||
// Fetch list-with-sub.
|
||||
rec = doGetAdmin(e, "/admin/api/v1/orders/list-with-sub", token)
|
||||
t.Logf("ListWithSub: %s", rec.Body.String())
|
||||
resp := assertOK(t, rec)
|
||||
|
||||
dataObj, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing data object: %s", rec.Body.String())
|
||||
}
|
||||
listRaw, _ := dataObj["list"].([]interface{})
|
||||
|
||||
// Collect all trade_ids at the top level and in sub_orders.
|
||||
topLevelIds := map[string]bool{}
|
||||
subOrderIds := map[string]bool{}
|
||||
for _, item := range listRaw {
|
||||
entry := item.(map[string]interface{})
|
||||
tradeId := entry["trade_id"].(string)
|
||||
topLevelIds[tradeId] = true
|
||||
if subs, ok := entry["sub_orders"].([]interface{}); ok {
|
||||
for _, s := range subs {
|
||||
sub := s.(map[string]interface{})
|
||||
subOrderIds[sub["trade_id"].(string)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-order must NOT appear at the top level.
|
||||
if topLevelIds[subTradeId] {
|
||||
t.Fatalf("sub-order trade_id=%s appears at top level — should be nested only", subTradeId)
|
||||
}
|
||||
// Parent must appear at the top level.
|
||||
if !topLevelIds[parentTradeId] {
|
||||
t.Fatalf("parent trade_id=%s missing from top level", parentTradeId)
|
||||
}
|
||||
// Sub-order must appear under the parent's sub_orders.
|
||||
if !subOrderIds[subTradeId] {
|
||||
t.Fatalf("sub-order trade_id=%s not found in any sub_orders array", subTradeId)
|
||||
}
|
||||
}
|
||||
+230
-22
@@ -2,59 +2,267 @@ package route
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/assimon/luuu/controller/comm"
|
||||
"github.com/assimon/luuu/middleware"
|
||||
"github.com/GMWalletApp/epusdt/controller/admin"
|
||||
"github.com/GMWalletApp/epusdt/controller/comm"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// RegisterRoute 路由注册
|
||||
func RegisterRoute(e *echo.Echo) {
|
||||
e.Any("/", func(c echo.Context) error {
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
|
||||
})
|
||||
|
||||
payRoute := e.Group("/pay")
|
||||
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
||||
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||
|
||||
// payment routes
|
||||
paymentRoute := e.Group("/payments")
|
||||
|
||||
// for epusdt
|
||||
epusdtV1 := paymentRoute.Group("/epusdt/v1")
|
||||
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
|
||||
// add default token and currency for old plugin
|
||||
// gmpay v1 routes
|
||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
||||
// gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
|
||||
// gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if err := ctx.Bind(&body); err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
// epay v1 routes
|
||||
//
|
||||
// Signature uses the pid from the request as the api_keys lookup
|
||||
// key; the matching row's secret_key plays the role of the legacy
|
||||
// EPAY "key" value. Env-based (EPAY_PID/EPAY_KEY) fallback was
|
||||
// removed — the default seeded api_key is always available, and
|
||||
// having two sources of truth led to inbound/outbound sig mismatch.
|
||||
epayV1 := paymentRoute.Group("/epay/v1")
|
||||
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
|
||||
params := make(map[string]interface{})
|
||||
copyParams := func(values map[string][]string) {
|
||||
for k, v := range values {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := body["token"]; !ok {
|
||||
body["token"] = "usdt"
|
||||
params[k] = v[0]
|
||||
}
|
||||
if _, ok := body["currency"]; !ok {
|
||||
body["currency"] = "cny"
|
||||
}
|
||||
if _, ok := body["network"]; !ok {
|
||||
body["network"] = "TRON"
|
||||
|
||||
copyParams(ctx.QueryParams())
|
||||
|
||||
formParams, err := ctx.FormParams()
|
||||
if err != nil && ctx.Request().Method == http.MethodPost {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
|
||||
}
|
||||
if err == nil {
|
||||
copyParams(formParams)
|
||||
}
|
||||
|
||||
getString := func(m map[string]interface{}, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
signstr := getString(params, "sign")
|
||||
if signstr == "" {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
delete(params, "sign")
|
||||
delete(params, "sign_type")
|
||||
|
||||
pidStr := getString(params, "pid")
|
||||
if pidStr == "" {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
apiKeyRow, err := data.GetEnabledApiKey(pidStr)
|
||||
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
checkSignature, err := sign.Get(params, apiKeyRow.SecretKey)
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signstr)) != 1 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
if !middleware.IsIPWhitelisted(apiKeyRow.IpWhitelist, ctx.RealIP()) {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
_ = data.TouchApiKeyUsage(apiKeyRow.ID)
|
||||
|
||||
money := getString(params, "money")
|
||||
name := getString(params, "name")
|
||||
notifyURL := getString(params, "notify_url")
|
||||
outTradeNo := getString(params, "out_trade_no")
|
||||
returnURL := getString(params, "return_url")
|
||||
|
||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
||||
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||
"amount": amountFloat,
|
||||
"notify_url": notifyURL,
|
||||
"order_id": outTradeNo,
|
||||
"redirect_url": returnURL,
|
||||
"signature": signstr,
|
||||
"name": name,
|
||||
"payment_type": mdb.PaymentTypeEpay,
|
||||
}
|
||||
|
||||
ctx.Set("request_body", body)
|
||||
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
|
||||
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
|
||||
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||
ctx.Request().ContentLength = int64(len(jsonBytes))
|
||||
ctx.Request().Method = http.MethodPost
|
||||
ctx.Request().Header.Set("Content-Type", "application/json")
|
||||
|
||||
return comm.Ctrl.CreateTransaction(ctx)
|
||||
}, middleware.CheckApiSign())
|
||||
|
||||
// gmpay v1 routes
|
||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
|
||||
})
|
||||
|
||||
registerAdminRoutes(e)
|
||||
}
|
||||
|
||||
// registerAdminRoutes wires the management console API surface under
|
||||
// /admin/api/v1. Everything except /auth/login requires a valid JWT.
|
||||
func registerAdminRoutes(e *echo.Echo) {
|
||||
// CORS for the management console. The admin SPA is commonly served
|
||||
// from a different origin (local dev, CDN, etc.), so allow any origin
|
||||
// but require explicit echoing — browsers refuse wildcard + credentials.
|
||||
adminCORS := echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOriginFunc: func(origin string) (bool, error) { return true, nil },
|
||||
AllowMethods: []string{
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPatch,
|
||||
http.MethodPut,
|
||||
http.MethodDelete,
|
||||
http.MethodOptions,
|
||||
},
|
||||
AllowHeaders: []string{
|
||||
echo.HeaderOrigin,
|
||||
echo.HeaderContentType,
|
||||
echo.HeaderAuthorization,
|
||||
echo.HeaderAccept,
|
||||
echo.HeaderXRequestedWith,
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
adminV1 := e.Group("/admin/api/v1", adminCORS)
|
||||
|
||||
// Public (no JWT)
|
||||
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
||||
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
|
||||
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
|
||||
|
||||
// Authenticated
|
||||
authed := adminV1.Group("", middleware.CheckAdminJWT())
|
||||
authed.POST("/auth/logout", admin.Ctrl.Logout)
|
||||
authed.GET("/auth/me", admin.Ctrl.Me)
|
||||
authed.POST("/auth/password", admin.Ctrl.ChangePassword)
|
||||
|
||||
// API key management
|
||||
authed.GET("/api-keys", admin.Ctrl.ListApiKeys)
|
||||
authed.POST("/api-keys", admin.Ctrl.CreateApiKey)
|
||||
authed.PATCH("/api-keys/:id", admin.Ctrl.UpdateApiKey)
|
||||
authed.POST("/api-keys/:id/status", admin.Ctrl.ChangeApiKeyStatus)
|
||||
authed.POST("/api-keys/:id/rotate-secret", admin.Ctrl.RotateApiKeySecret)
|
||||
authed.DELETE("/api-keys/:id", admin.Ctrl.DeleteApiKey)
|
||||
authed.GET("/api-keys/:id/stats", admin.Ctrl.GetApiKeyStats)
|
||||
authed.GET("/api-keys/:id/secret", admin.Ctrl.GetApiKeySecret)
|
||||
|
||||
// Notification channels
|
||||
authed.GET("/notification-channels", admin.Ctrl.ListNotificationChannels)
|
||||
authed.POST("/notification-channels", admin.Ctrl.CreateNotificationChannel)
|
||||
authed.PATCH("/notification-channels/:id", admin.Ctrl.UpdateNotificationChannel)
|
||||
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
|
||||
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
|
||||
|
||||
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
||||
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
|
||||
|
||||
// Chains
|
||||
authed.GET("/chains", admin.Ctrl.ListChains)
|
||||
authed.PATCH("/chains/:network", admin.Ctrl.UpdateChain)
|
||||
|
||||
// Chain tokens (per-chain token catalog)
|
||||
authed.GET("/chain-tokens", admin.Ctrl.ListChainTokens)
|
||||
authed.POST("/chain-tokens", admin.Ctrl.CreateChainToken)
|
||||
authed.PATCH("/chain-tokens/:id", admin.Ctrl.UpdateChainToken)
|
||||
authed.POST("/chain-tokens/:id/status", admin.Ctrl.ChangeChainTokenStatus)
|
||||
authed.DELETE("/chain-tokens/:id", admin.Ctrl.DeleteChainToken)
|
||||
|
||||
// RPC nodes
|
||||
authed.GET("/rpc-nodes", admin.Ctrl.ListRpcNodes)
|
||||
authed.POST("/rpc-nodes", admin.Ctrl.CreateRpcNode)
|
||||
authed.PATCH("/rpc-nodes/:id", admin.Ctrl.UpdateRpcNode)
|
||||
authed.DELETE("/rpc-nodes/:id", admin.Ctrl.DeleteRpcNode)
|
||||
authed.POST("/rpc-nodes/:id/health-check", admin.Ctrl.HealthCheckRpcNode)
|
||||
|
||||
// Wallet (address) management
|
||||
authed.GET("/wallets", admin.Ctrl.AdminListWallets)
|
||||
authed.POST("/wallets", admin.Ctrl.AdminAddWallet)
|
||||
authed.GET("/wallets/:id", admin.Ctrl.AdminGetWallet)
|
||||
authed.PATCH("/wallets/:id", admin.Ctrl.AdminUpdateWallet)
|
||||
authed.POST("/wallets/:id/status", admin.Ctrl.AdminChangeWalletStatus)
|
||||
authed.DELETE("/wallets/:id", admin.Ctrl.AdminDeleteWallet)
|
||||
authed.POST("/wallets/batch-import", admin.Ctrl.AdminBatchImportWallets)
|
||||
|
||||
// Orders
|
||||
authed.GET("/orders", admin.Ctrl.ListOrders)
|
||||
authed.GET("/orders/export", admin.Ctrl.ExportOrders)
|
||||
authed.GET("/orders/list-with-sub", admin.Ctrl.ListOrdersWithSub)
|
||||
authed.GET("/orders/:trade_id", admin.Ctrl.GetOrder)
|
||||
authed.GET("/orders/:trade_id/with-sub", admin.Ctrl.GetOrderWithSub)
|
||||
authed.POST("/orders/:trade_id/close", admin.Ctrl.CloseOrder)
|
||||
authed.POST("/orders/:trade_id/mark-paid", admin.Ctrl.MarkOrderPaid)
|
||||
authed.POST("/orders/:trade_id/resend-callback", admin.Ctrl.ResendCallback)
|
||||
|
||||
// Dashboard
|
||||
authed.GET("/dashboard/overview", admin.Ctrl.Overview)
|
||||
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
||||
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
||||
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
||||
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
||||
|
||||
// Settings
|
||||
authed.GET("/settings", admin.Ctrl.ListSettings)
|
||||
authed.PUT("/settings", admin.Ctrl.UpsertSettings)
|
||||
authed.DELETE("/settings/:key", admin.Ctrl.DeleteSetting)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const testAPIToken = "test-secret-token"
|
||||
|
||||
func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// minimal viper config
|
||||
viper.Reset()
|
||||
viper.Set("db_type", "sqlite")
|
||||
viper.Set("app_uri", "http://localhost:8080")
|
||||
viper.Set("order_expiration_time", 10)
|
||||
viper.Set("api_rate_url", "")
|
||||
viper.Set("runtime_root_path", tmpDir)
|
||||
viper.Set("log_save_path", tmpDir)
|
||||
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
|
||||
viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db")
|
||||
|
||||
log.Init()
|
||||
|
||||
// init config paths
|
||||
os.Setenv("EPUSDT_CONFIG", tmpDir)
|
||||
defer os.Unsetenv("EPUSDT_CONFIG")
|
||||
|
||||
// init DB
|
||||
if err := dao.DBInit(); err != nil {
|
||||
t.Fatalf("DBInit: %v", err)
|
||||
}
|
||||
if err := dao.RuntimeInit(); err != nil {
|
||||
t.Fatalf("RuntimeInit: %v", err)
|
||||
}
|
||||
|
||||
// ensure tables exist (MdbTableInit uses sync.Once, so migrate directly)
|
||||
dao.Mdb.AutoMigrate(
|
||||
&mdb.Orders{},
|
||||
&mdb.WalletAddress{},
|
||||
&mdb.AdminUser{},
|
||||
&mdb.ApiKey{},
|
||||
&mdb.Setting{},
|
||||
&mdb.NotificationChannel{},
|
||||
&mdb.Chain{},
|
||||
&mdb.ChainToken{},
|
||||
&mdb.RpcNode{},
|
||||
)
|
||||
|
||||
// reset the settings cache so stale entries from a prior test don't leak
|
||||
_ = data.ReloadSettings()
|
||||
config.SettingsGetString = func(key string) string {
|
||||
return data.GetSettingString(key, "")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
config.SettingsGetString = nil
|
||||
})
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil {
|
||||
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
|
||||
// seed wallet addresses
|
||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
|
||||
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable})
|
||||
// seed chains so scanners know the test networks are "enabled".
|
||||
// Use ON CONFLICT DO NOTHING: MdbTableInit (called via DBInit) uses sync.Once
|
||||
// and may have already seeded these rows into this DB on the first test run.
|
||||
dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.Chain{
|
||||
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true},
|
||||
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true},
|
||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true},
|
||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true},
|
||||
})
|
||||
|
||||
// seed chain_tokens — GetSupportedAssets now reads from this table.
|
||||
// Same idempotency rationale as above.
|
||||
dao.Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
})
|
||||
|
||||
// Seed one universal api_keys row. The test's testAPIToken doubles
|
||||
// as both pid and secret_key so sign.Get(body, testAPIToken) calls
|
||||
// stay valid.
|
||||
dao.Mdb.Create(&mdb.ApiKey{
|
||||
Name: "test-universal",
|
||||
Pid: testAPIToken,
|
||||
SecretKey: testAPIToken,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
// Additional numeric-PID row for EPAY tests (EPAY pid must be numeric).
|
||||
dao.Mdb.Create(&mdb.ApiKey{
|
||||
Name: "test-epay-pid-1",
|
||||
Pid: "1",
|
||||
SecretKey: testAPIToken,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
|
||||
e := echo.New()
|
||||
RegisterRoute(e)
|
||||
return e
|
||||
}
|
||||
|
||||
func signBody(body map[string]interface{}) map[string]interface{} {
|
||||
// Signature middleware looks up api_keys by the "pid" field and
|
||||
// uses that row's secret_key as the signing bizKey.
|
||||
if _, ok := body["pid"]; !ok {
|
||||
body["pid"] = testAPIToken
|
||||
}
|
||||
sig, _ := sign.Get(body, testAPIToken)
|
||||
body["signature"] = sig
|
||||
return body
|
||||
}
|
||||
|
||||
func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
|
||||
jsonBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func signEpayValues(values url.Values) url.Values {
|
||||
signParams := make(map[string]interface{})
|
||||
for key, items := range values {
|
||||
if key == "sign" || key == "sign_type" || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
signParams[key] = items[0]
|
||||
}
|
||||
sig, _ := sign.Get(signParams, testAPIToken)
|
||||
values.Set("sign", sig)
|
||||
values.Set("sign_type", "MD5")
|
||||
return values
|
||||
}
|
||||
|
||||
func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRootPostRoute(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); body != "hello epusdt, https://github.com/GMwalletApp/epusdt" {
|
||||
t.Fatalf("unexpected body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
|
||||
func TestCreateOrderGmpayV1Solana(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "test-sol-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in response, got: %v", resp)
|
||||
}
|
||||
|
||||
if data["trade_id"] == nil || data["trade_id"] == "" {
|
||||
t.Error("expected trade_id in response")
|
||||
}
|
||||
if data["receive_address"] != "SolTestAddress001" {
|
||||
t.Errorf("expected solana address, got: %v", data["receive_address"])
|
||||
}
|
||||
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
|
||||
func TestCreateOrderGmpayV1SolNative(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "test-sol-native-001",
|
||||
"amount": 0.05,
|
||||
"token": "sol",
|
||||
"currency": "usd",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
t.Logf("Response: %v", resp)
|
||||
|
||||
// This may fail if rate API is not configured, which is expected in test
|
||||
// The important thing is the route accepts the request with network=solana token=sol
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Logf("Note: non-200 may be expected if rate API is not configured for SOL")
|
||||
}
|
||||
}
|
||||
|
||||
func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||
t.Helper()
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// signGmpayFormValues builds a signed url.Values for the GMPAY create-transaction endpoint.
|
||||
// The GMPAY middleware uses "signature" (not "sign") and "pid" (not numeric pid from EPAY).
|
||||
func signGmpayFormValues(values url.Values) url.Values {
|
||||
if values.Get("pid") == "" {
|
||||
values.Set("pid", testAPIToken)
|
||||
}
|
||||
params := make(map[string]interface{})
|
||||
for k, vs := range values {
|
||||
if k == "signature" || len(vs) == 0 {
|
||||
continue
|
||||
}
|
||||
params[k] = vs[0]
|
||||
}
|
||||
sig, _ := sign.Get(params, testAPIToken)
|
||||
values.Set("signature", sig)
|
||||
return values
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1FormData verifies that the GMPAY create-transaction endpoint
|
||||
// accepts application/x-www-form-urlencoded in addition to JSON.
|
||||
func TestCreateOrderGmpayV1FormData(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
values := signGmpayFormValues(url.Values{
|
||||
"order_id": {"test-form-001"},
|
||||
"amount": {"1.00"},
|
||||
"token": {"usdt"},
|
||||
"currency": {"cny"},
|
||||
"network": {"solana"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
})
|
||||
|
||||
rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values)
|
||||
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data in response, got: %v", resp)
|
||||
}
|
||||
if data["trade_id"] == nil || data["trade_id"] == "" {
|
||||
t.Error("expected trade_id in response")
|
||||
}
|
||||
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
|
||||
}
|
||||
|
||||
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/supported-assets
|
||||
// and returns a map of network → []token for easy assertions.
|
||||
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getSupportedNetworks: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := parseResp(t, rec)
|
||||
rawData, _ := resp["data"].(map[string]interface{})
|
||||
rawSupports, _ := rawData["supports"].([]interface{})
|
||||
result := make(map[string][]string, len(rawSupports))
|
||||
for _, item := range rawSupports {
|
||||
row := item.(map[string]interface{})
|
||||
network := row["network"].(string)
|
||||
rawTokens, _ := row["tokens"].([]interface{})
|
||||
tokens := make([]string, 0, len(rawTokens))
|
||||
for _, tok := range rawTokens {
|
||||
tokens = append(tokens, tok.(string))
|
||||
}
|
||||
result[network] = tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TestGetSupportedAssets_ChainTokenToggle verifies that:
|
||||
// - disabling a chain_token removes that token (and possibly the whole network)
|
||||
// from the /supported-assets response
|
||||
// - re-enabling it brings it back
|
||||
func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Baseline: tron should appear with USDT and TRX.
|
||||
before := getSupportedNetworks(t, e)
|
||||
if _, ok := before["tron"]; !ok {
|
||||
t.Fatal("expected tron in baseline supported-assets")
|
||||
}
|
||||
|
||||
// Disable the tron USDT chain_token.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "USDT").
|
||||
Update("enabled", false)
|
||||
|
||||
after := getSupportedNetworks(t, e)
|
||||
tronTokens := after["tron"]
|
||||
for _, tok := range tronTokens {
|
||||
if tok == "USDT" {
|
||||
t.Fatal("USDT should be absent after disabling tron USDT chain_token")
|
||||
}
|
||||
}
|
||||
t.Logf("After disabling tron USDT: tron tokens = %v", tronTokens)
|
||||
|
||||
// Disable the tron TRX chain_token as well — now tron has no tokens → network disappears.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "TRX").
|
||||
Update("enabled", false)
|
||||
|
||||
afterAllDisabled := getSupportedNetworks(t, e)
|
||||
if _, ok := afterAllDisabled["tron"]; ok {
|
||||
t.Fatal("tron should disappear from supported-assets when all its chain_tokens are disabled")
|
||||
}
|
||||
t.Logf("After disabling all tron tokens: networks = %v", afterAllDisabled)
|
||||
|
||||
// Re-enable tron USDT — tron reappears with only USDT.
|
||||
dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ? AND symbol = ?", "tron", "USDT").
|
||||
Update("enabled", true)
|
||||
|
||||
restored := getSupportedNetworks(t, e)
|
||||
tronRestored, ok := restored["tron"]
|
||||
if !ok {
|
||||
t.Fatal("tron should reappear after re-enabling USDT chain_token")
|
||||
}
|
||||
found := false
|
||||
for _, tok := range tronRestored {
|
||||
if tok == "USDT" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected USDT in restored tron tokens, got %v", tronRestored)
|
||||
}
|
||||
t.Logf("After re-enabling tron USDT: tron tokens = %v", tronRestored)
|
||||
}
|
||||
|
||||
// TestGetSupportedAssets_WalletAddressToggle verifies that:
|
||||
// - disabling ALL wallet addresses for a network removes that network from
|
||||
// the /supported-assets response (even if chain + tokens are still enabled)
|
||||
// - re-enabling any address brings the network back
|
||||
func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Baseline: solana should be present.
|
||||
before := getSupportedNetworks(t, e)
|
||||
if _, ok := before["solana"]; !ok {
|
||||
t.Fatal("expected solana in baseline supported-assets")
|
||||
}
|
||||
|
||||
// Disable the only solana wallet address.
|
||||
dao.Mdb.Model(&mdb.WalletAddress{}).
|
||||
Where("network = ?", mdb.NetworkSolana).
|
||||
Update("status", mdb.TokenStatusDisable)
|
||||
|
||||
after := getSupportedNetworks(t, e)
|
||||
if _, ok := after["solana"]; ok {
|
||||
t.Fatal("solana should disappear from supported-assets when all its wallet addresses are disabled")
|
||||
}
|
||||
t.Logf("After disabling solana wallets: networks = %v", after)
|
||||
|
||||
// Re-enable the solana wallet.
|
||||
dao.Mdb.Model(&mdb.WalletAddress{}).
|
||||
Where("network = ?", mdb.NetworkSolana).
|
||||
Update("status", mdb.TokenStatusEnable)
|
||||
|
||||
restored := getSupportedNetworks(t, e)
|
||||
if _, ok := restored["solana"]; !ok {
|
||||
t.Fatal("solana should reappear after re-enabling its wallet address")
|
||||
}
|
||||
t.Logf("After re-enabling solana wallets: solana tokens = %v", restored["solana"])
|
||||
}
|
||||
|
||||
func TestGetSupportedAssetsPublic(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
resp := parseResp(t, rec)
|
||||
if resp["status_code"].(float64) != 200 {
|
||||
t.Fatalf("expected status_code=200, got %v", resp["status_code"])
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object data, got %T", resp["data"])
|
||||
}
|
||||
supports, ok := data["supports"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected supports array, got %T", data["supports"])
|
||||
}
|
||||
if len(supports) < 2 {
|
||||
t.Fatalf("expected >= 2 network supports, got %d", len(supports))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, item := range supports {
|
||||
row := item.(map[string]interface{})
|
||||
network := row["network"].(string)
|
||||
seen[network] = true
|
||||
}
|
||||
for _, n := range []string{"tron", "solana"} {
|
||||
if !seen[n] {
|
||||
t.Fatalf("missing network support: %s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
|
||||
func TestCreateOrderNetworkIsolation(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Try to create a solana order — should get solana address, not tron
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": fmt.Sprintf("test-isolation-%d", 1),
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data, got: %v", resp)
|
||||
}
|
||||
if data["receive_address"] == "TTestTronAddress001" {
|
||||
t.Error("solana order should NOT get a tron address")
|
||||
}
|
||||
if data["receive_address"] != "SolTestAddress001" {
|
||||
t.Errorf("expected SolTestAddress001, got %v", data["receive_address"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
values := signEpayValues(url.Values{
|
||||
"pid": {"1"},
|
||||
"name": {"epay-get-001"},
|
||||
"type": {"alipay"},
|
||||
"money": {"1.00"},
|
||||
"out_trade_no": {"epay-get-001"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
"return_url": {"http://localhost/return"},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
|
||||
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
values := signEpayValues(url.Values{
|
||||
"pid": {"1"},
|
||||
"name": {"epay-post-001"},
|
||||
"type": {"alipay"},
|
||||
"money": {"1.00"},
|
||||
"out_trade_no": {"epay-post-001"},
|
||||
"notify_url": {"http://localhost/notify"},
|
||||
"return_url": {"http://localhost/return"},
|
||||
"sitename": {"example-shop"},
|
||||
})
|
||||
|
||||
rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
|
||||
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a
|
||||
// graceful JSON error (not 500) when the trade_id doesn't exist.
|
||||
func TestCheckStatus_NotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/nonexistent-trade-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route returned 404 with empty body — route may not be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
|
||||
// and a status field when the order exists.
|
||||
func TestCheckStatus_WithOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create an order first via the GMPAY route.
|
||||
body := signBody(map[string]interface{}{
|
||||
"order_id": "check-status-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(createRec.Body.Bytes(), &createResp)
|
||||
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
if tradeId == "" {
|
||||
t.Fatal("no trade_id in create response")
|
||||
}
|
||||
|
||||
// Now check status.
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["data"] == nil {
|
||||
t.Fatal("expected data in check-status response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id
|
||||
// with an unknown trade_id does not return an empty 404 (which would mean the
|
||||
// route is not registered). When the static HTML template is present the
|
||||
// controller renders it with a 404 status; when it is absent (test env) the
|
||||
// controller returns a 500 with a descriptive body — both outcomes are
|
||||
// acceptable because the route IS registered and functional.
|
||||
func TestCheckoutCounter_NotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter/nonexistent-trade-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("CheckoutCounter(not found): status=%d content-type=%s body=%s",
|
||||
rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
||||
// A completely empty 404 means the route is not registered at all.
|
||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
||||
t.Fatalf("route not registered (404 with empty body)")
|
||||
}
|
||||
// A 500 whose body mentions a missing file is expected in test environments
|
||||
// where the static directory is not present.
|
||||
if rec.Code >= 500 && rec.Body.Len() == 0 {
|
||||
t.Fatalf("unexpected server error with empty body: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
|
||||
// required fields and returns a graceful error when they are missing.
|
||||
func TestSwitchNetwork_MissingFields(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
// trade_id, token, network are all missing
|
||||
})
|
||||
t.Logf("SwitchNetwork(missing fields): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Must return a non-200 business code for validation failure.
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if code, _ := resp["code"].(float64); code == 200 {
|
||||
t.Fatal("expected validation error for missing fields, got code=200")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_OrderNotFound verifies that /pay/switch-network returns a
|
||||
// graceful error when the referenced trade_id doesn't exist.
|
||||
func TestSwitchNetwork_OrderNotFound(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
"trade_id": "nonexistent-trade-id",
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
})
|
||||
t.Logf("SwitchNetwork(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code >= 500 {
|
||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchNetwork_WithOrder verifies /pay/switch-network can create a
|
||||
// sub-order when given a valid parent trade_id.
|
||||
func TestSwitchNetwork_WithOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create a parent order on solana.
|
||||
createBody := signBody(map[string]interface{}{
|
||||
"order_id": "switch-net-parent-001",
|
||||
"amount": 1.00,
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "solana",
|
||||
"notify_url": "http://localhost/notify",
|
||||
})
|
||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create parent order failed: %d %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(createRec.Body.Bytes(), &createResp)
|
||||
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||
if tradeId == "" {
|
||||
t.Fatal("no trade_id in create response")
|
||||
}
|
||||
|
||||
// Switch to tron.
|
||||
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||
"trade_id": tradeId,
|
||||
"token": "USDT",
|
||||
"network": "tron",
|
||||
})
|
||||
t.Logf("SwitchNetwork(valid): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["data"] == nil {
|
||||
t.Fatal("expected data in switch-network response")
|
||||
}
|
||||
}
|
||||
+414
-139
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -23,236 +24,508 @@
|
||||
--color-popover-foreground:var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground:var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground:var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-border-hover: var(--border-hover);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--shadow-card: var(--card-shadow);
|
||||
--shadow-popover: var(--popover-shadow);
|
||||
--animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
}
|
||||
|
||||
/* ── Design tokens ── */
|
||||
:root, [data-theme="light"] {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground:oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground:oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground:oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--radius: 0.875rem;
|
||||
--success: oklch(0.721 0.193 143.56);
|
||||
--warning: oklch(0.741 0.188 55.68);
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground:oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground:oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground:oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--success: oklch(0.747 0.201 143.5);
|
||||
--warning: oklch(0.762 0.182 61.47);
|
||||
}
|
||||
|
||||
/* ── Ambient blobs ── */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fill-bar {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes slide-out-l {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100% - 20px)); }
|
||||
}
|
||||
@keyframes slide-out-r {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(100% + 20px)); }
|
||||
}
|
||||
@keyframes slide-in-r {
|
||||
from { transform: translateX(calc(100% + 20px)); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes slide-in-l {
|
||||
from { transform: translateX(calc(-100% - 20px)); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes blob-drift {
|
||||
0% { transform: translate(0,0) scale(1); }
|
||||
50% { transform: translate(30px,-20px) scale(1.06); }
|
||||
100% { transform: translate(-20px,30px) scale(0.96); }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Card */
|
||||
.card {
|
||||
@apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* Chip / pill button */
|
||||
.chip {
|
||||
@apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all;
|
||||
}
|
||||
.chip:hover { @apply bg-accent border-ring; }
|
||||
.chip:active { @apply opacity-60; }
|
||||
.chip.w-9 { @apply p-0; }
|
||||
|
||||
/* Copy rows */
|
||||
.row {
|
||||
@apply flex items-center px-4 py-3.5 transition-colors;
|
||||
}
|
||||
.row:active { @apply bg-muted; }
|
||||
|
||||
/* Icon button */
|
||||
.icon-btn {
|
||||
@apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors;
|
||||
}
|
||||
.icon-btn:hover { @apply bg-accent text-card-foreground border-border; }
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
@apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity];
|
||||
}
|
||||
.btn-primary:hover { @apply bg-primary/90; }
|
||||
.btn-primary:active { @apply scale-[0.97] opacity-90; }
|
||||
.btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; }
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all;
|
||||
}
|
||||
.btn-secondary:hover { @apply bg-accent border-ring; }
|
||||
.btn-secondary:active { @apply opacity-70; }
|
||||
|
||||
/* State icon circle */
|
||||
.state-icon {
|
||||
@apply size-20 rounded-full flex items-center justify-center mx-auto;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.menu {
|
||||
@apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.menu--up { transform: translateY(8px); }
|
||||
.menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; }
|
||||
.menu-item {
|
||||
@apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors;
|
||||
}
|
||||
.menu-item:hover { @apply bg-accent; }
|
||||
.menu-item.is-selected { @apply text-primary font-semibold; }
|
||||
.menu-item + .menu-item { @apply border-t border-border; }
|
||||
.select-trigger.is-open { opacity: 0.8; }
|
||||
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* QR wrapper */
|
||||
.qr-wrapper {
|
||||
@apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block;
|
||||
}
|
||||
|
||||
/* Timer SVG rings */
|
||||
.timer-ring-track { stroke: var(--border); transition: stroke .3s; }
|
||||
.timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; }
|
||||
.timer-icon { @apply text-muted-foreground; }
|
||||
|
||||
/* Flippable status card */
|
||||
.status-card {
|
||||
position: relative;
|
||||
perspective: 1600px;
|
||||
transition: height 0.38s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.status-card-inner {
|
||||
position: relative; width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
|
||||
}
|
||||
.status-card.is-flipped .status-card-inner { transform: rotateY(180deg); }
|
||||
.status-face {
|
||||
@apply absolute inset-0 w-full;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.status-face-front { z-index: 2; }
|
||||
.status-face-back { transform: rotateY(180deg); }
|
||||
.state-panel-shell { @apply pt-px; }
|
||||
.state-screen { @apply hidden; }
|
||||
.state-screen.is-active { @apply flex; }
|
||||
}
|
||||
|
||||
/* ── Panel viewport: hide scrollbars ── */
|
||||
#panel-viewport { scrollbar-width: none; }
|
||||
#panel-viewport::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Toast ── */
|
||||
#toast {
|
||||
@apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999;
|
||||
@apply bg-foreground/95 text-background;
|
||||
@apply dark:bg-card/95 dark:text-card-foreground;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
transition: opacity 0.22s, transform 0.22s;
|
||||
}
|
||||
|
||||
@keyframes state-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
|
||||
::view-transition-old(root) { z-index: 1; }
|
||||
::view-transition-new(root) { z-index: 9; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-card, .status-card-inner, .status-face, .timer-ring-progress {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/payment.css" />
|
||||
<script defer src="https://cdn.jsdmirror.com/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script defer src="https://cdn.jsdmirror.com/npm/qrcodejs@latest/qrcode.min.js"></script>
|
||||
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-background text-foreground min-h-screen flex flex-col items-center justify-center font-sans transition-colors duration-300">
|
||||
|
||||
<!-- Ambient background blobs -->
|
||||
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center w-full max-w-[400px] px-4 py-8 relative z-10">
|
||||
|
||||
<body
|
||||
class="bg-background text-foreground min-h-screen flex flex-col items-center font-sans transition-colors duration-300">
|
||||
<!-- Nav bar -->
|
||||
<div class="w-full flex items-center justify-between mb-6">
|
||||
<header class="w-full max-w-sm px-4 pt-8 pb-6 flex items-center justify-between relative z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-[10px] shadow-sm" />
|
||||
<span class="text-[17px] font-semibold tracking-[-0.3px] text-card-foreground">GM Pay</span>
|
||||
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-sm shadow-sm" />
|
||||
<span class="text-lg font-semibold tracking-tight text-card-foreground">GM Pay</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Language pill -->
|
||||
<div class="select-wrap relative select-none" id="dd-lang">
|
||||
<div class="select-trigger ios-pill flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
||||
<span class="text-[13px] font-medium" id="lang-label">EN</span>
|
||||
<i data-lucide="chevron-down" class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11" height="11" stroke-width="2.5"></i>
|
||||
<div class="select-trigger chip flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
||||
<span class="text-sm font-medium" id="lang-label">EN</span>
|
||||
<i data-lucide="chevron-down"
|
||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11"
|
||||
height="11" stroke-width="2.5"></i>
|
||||
</div>
|
||||
<div class="select-menu ios-menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-[160px] overflow-hidden opacity-0 -translate-y-2 pointer-events-none transition-all duration-200" id="dd-lang-menu">
|
||||
<div class="select-option ios-menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English</div>
|
||||
<div class="select-option ios-menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
||||
<div class="select-option ios-menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
||||
<div class="select-option ios-menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
||||
<div class="select-option ios-menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
||||
<div class="select-option ios-menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</div>
|
||||
<div class="select-menu menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-40" id="dd-lang-menu">
|
||||
<div class="select-option menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English
|
||||
</div>
|
||||
<div class="select-option menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
||||
<div class="select-option menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
||||
<div class="select-option menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
||||
<div class="select-option menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
||||
<div class="select-option menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme toggle -->
|
||||
<button class="ios-pill w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)" title="Toggle theme">
|
||||
<button class="chip w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)"
|
||||
title="Toggle theme">
|
||||
<i data-lucide="moon" id="icon-moon" class="hidden" width="16" height="16" stroke-width="1.8"></i>
|
||||
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
|
||||
</button>
|
||||
<!-- Ambient background blobs -->
|
||||
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] size-[420px] -top-30 -left-25 bg-[color-mix(in_srgb,var(--muted)_60%,transparent)]">
|
||||
</div>
|
||||
<div
|
||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-6s] size-[340px] -bottom-20 -right-15 bg-[color-mix(in_srgb,var(--success)_14%,transparent)]">
|
||||
</div>
|
||||
<div
|
||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-12s] size-[260px] top-[45%] left-[55%] bg-[color-mix(in_srgb,var(--warning)_10%,transparent)]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex flex-col items-center justify-center w-full max-w-sm px-5 relative">
|
||||
|
||||
<!-- Steps -->
|
||||
<div id="step-progress" class="flex items-center gap-2 w-full mb-4 select-none">
|
||||
<div id="step-bar-1" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||
<div id="step-fill-1" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||
</div>
|
||||
<div id="step-bar-2" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||
<div id="step-fill-2" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero amount card -->
|
||||
<div id="order-info" class="glass-card w-full px-6 pt-6 pb-5 mb-3">
|
||||
<p class="text-[13px] font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
||||
<!-- Order info -->
|
||||
<aside id="order-info" class="card w-full px-6 pt-6 pb-5 mb-4">
|
||||
<p class="text-sm font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
||||
<div class="flex items-end gap-3 justify-between">
|
||||
<span class="text-[42px] font-bold leading-none tracking-[-0.04em] text-card-foreground font-nunito" id="display-amount">--</span>
|
||||
<button class="ios-icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
||||
<span class="text-4xl font-bold leading-none tracking-tighter text-card-foreground font-nunito"
|
||||
id="display-amount">--</span>
|
||||
<button class="icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
||||
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
|
||||
<div class="mt-3 pt-3 border-t border-border/50">
|
||||
<table class="w-full text-[13px] text-muted-foreground border-separate border-spacing-y-1">
|
||||
<table class="w-full text-sm text-muted-foreground border-separate border-spacing-y-1">
|
||||
<tbody>
|
||||
<tr id="display-fiat"></tr>
|
||||
<tr id="display-order-id"></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Panel viewport: clips horizontal slide -->
|
||||
<div class="relative w-full" id="panel-viewport">
|
||||
|
||||
<!-- Step 1: Select coin + network -->
|
||||
<section id="step1-panel" class="w-full mb-4 pb-4">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<!-- Network -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||
data-i18n="network_label">Network</p>
|
||||
<div class="select-wrap relative select-none" id="dd-network">
|
||||
<div
|
||||
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||
onclick="toggleSelect('dd-network')">
|
||||
<span id="network-label" class="text-sm font-semibold text-card-foreground leading-none"></span>
|
||||
<i data-lucide="chevron-down"
|
||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||
height="13" stroke-width="2.5"></i>
|
||||
</div>
|
||||
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-network-menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Currency -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||
data-i18n="currency_label">Currency</p>
|
||||
<div class="select-wrap relative select-none" id="dd-token">
|
||||
<div
|
||||
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||
onclick="toggleSelect('dd-token')">
|
||||
<span id="token-label" class="text-sm font-semibold text-card-foreground"></span>
|
||||
<i data-lucide="chevron-down"
|
||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||
height="13" stroke-width="2.5"></i>
|
||||
</div>
|
||||
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-token-menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-confirm-step1" class="btn-primary w-full" onclick="confirmStep1()"
|
||||
data-i18n="confirm">Confirm</button>
|
||||
</section>
|
||||
|
||||
<!-- Flippable payment/state card -->
|
||||
<div id="status-card" class="status-card w-full">
|
||||
<div class="status-card-inner">
|
||||
<div class="status-face status-face-front">
|
||||
<div id="payment-panel" class="w-full">
|
||||
|
||||
<!-- QR section -->
|
||||
<div class="glass-card w-full px-5 py-5 mb-3">
|
||||
<!-- Scan title + small ring -->
|
||||
<!-- Payment panel (Step 2) -->
|
||||
<section id="payment-panel" class="w-full pb-4" style="display:none">
|
||||
<!-- QR + address card -->
|
||||
<div class="card w-full px-5 pt-5 pb-0 mb-4 overflow-hidden">
|
||||
<!-- Scan title + timer -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-[13px] font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay</p>
|
||||
<p class="text-sm font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay
|
||||
</p>
|
||||
<div id="timer-row" class="relative w-10 h-10 shrink-0">
|
||||
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
|
||||
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3"/>
|
||||
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3"/>
|
||||
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="#34c759" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="125.66" stroke-dashoffset="0"/>
|
||||
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="var(--success)"
|
||||
stroke-width="3" stroke-linecap="round" stroke-dasharray="125.66" stroke-dashoffset="0" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Big countdown above QR -->
|
||||
<p class="text-[32px] font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
||||
<div class="flex justify-center">
|
||||
<!-- Countdown -->
|
||||
<p class="text-3xl font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
||||
<!-- QR code -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="qr-wrapper">
|
||||
<div id="qrcode" class="w-44 h-44"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hidden inline countdown (used by JS, not shown) -->
|
||||
<span id="countdown-inline" class="sr-only"></span>
|
||||
|
||||
<!-- Copy rows -->
|
||||
<div class="glass-card w-full overflow-hidden mb-3">
|
||||
<!-- Amount row -->
|
||||
<div id="copy-amt-box" class="ios-row cursor-pointer select-none">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-0.5" data-i18n="amount_to_pay">Amount to pay</p>
|
||||
<p class="text-[15px] font-semibold text-card-foreground" id="field-amount">--</p>
|
||||
</div>
|
||||
<span class="ios-icon-btn shrink-0 pointer-events-none" id="btn-copy-amt2">
|
||||
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="h-px bg-border/50 mx-4"></div>
|
||||
<!-- Address row -->
|
||||
<div id="copy-addr-box" class="ios-row cursor-pointer select-none">
|
||||
<!-- Address row (edge-to-edge inside card) -->
|
||||
<div class="h-px bg-border/50 -mx-5"></div>
|
||||
<div id="copy-addr-box" class="row cursor-pointer select-none -mx-5">
|
||||
<div class="flex-1 min-w-0 pr-3">
|
||||
<p class="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-0.5" data-i18n="payment_address">Payment address</p>
|
||||
<p class="text-[13px] font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-0.5"
|
||||
data-i18n="payment_address">Payment address</p>
|
||||
<p class="text-sm font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
||||
</div>
|
||||
<span class="ios-icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
||||
<span class="icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
||||
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<button class="ios-btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
||||
<span id="countdown-inline" class="sr-only"></span>
|
||||
<!-- Connect Wallet (above I have transferred) -->
|
||||
<button class="btn-secondary w-full mb-3" id="btn-connect-wallet" onclick="connectWallet()">
|
||||
<i data-lucide="wallet" width="16" height="16" stroke-width="1.8" class="mr-1.5 shrink-0"></i>
|
||||
<span data-i18n="connect_wallet">Connect Wallet to Pay</span>
|
||||
</button>
|
||||
<!-- I have transferred -->
|
||||
<button class="btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
||||
<span data-i18n="i_have_transferred">I have transferred</span>
|
||||
</button>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
|
||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13" stroke-width="2.2"></i>
|
||||
<span class="text-[12px] text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking blockchain</span>
|
||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13"
|
||||
stroke-width="2.2"></i>
|
||||
<span class="text-xs text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking
|
||||
blockchain</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- State: Success -->
|
||||
<section id="screen-success"
|
||||
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||
aria-live="polite" style="display:none">
|
||||
<div class="state-icon bg-success/15 mb-6">
|
||||
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="var(--success)"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
||||
<p class="text-sm text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22"
|
||||
stroke-width="2"></i>
|
||||
</section>
|
||||
|
||||
<div class="status-face status-face-back">
|
||||
<div id="state-panel" class="w-full state-panel-shell">
|
||||
<div class="glass-card w-full flex items-center justify-center min-h-[300px] py-10 px-6">
|
||||
<!-- State: Expired -->
|
||||
<section id="screen-expired" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||
<div class="state-icon bg-destructive/12 mb-6">
|
||||
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="var(--destructive)"></i>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
||||
<p class="text-sm text-muted-foreground" data-i18n="expired_sub">Please initiate a new payment</p>
|
||||
</div>
|
||||
<button class="btn-secondary w-full" onclick="goBack()" data-i18n="back">Back</button>
|
||||
</section>
|
||||
|
||||
<!-- Success -->
|
||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-success">
|
||||
<div class="ios-state-icon bg-[rgba(52,199,89,.15)] mb-6">
|
||||
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="#34c759"></i>
|
||||
<!-- State: Timeout -->
|
||||
<section id="screen-timeout" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||
<div class="state-icon bg-warning/12 mb-6">
|
||||
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="var(--warning)"></i>
|
||||
</div>
|
||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
||||
<p class="text-[14px] text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22" stroke-width="2"></i>
|
||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
||||
<p class="text-sm text-muted-foreground" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
||||
</div>
|
||||
|
||||
<!-- Expired -->
|
||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-expired">
|
||||
<div class="ios-state-icon bg-[rgba(255,59,48,.12)] mb-6">
|
||||
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="#ff3b30"></i>
|
||||
</div>
|
||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
||||
<p class="text-[14px] text-muted-foreground mb-7" data-i18n="expired_sub">Please initiate a new payment</p>
|
||||
<button class="ios-btn-secondary" onclick="goBack()" data-i18n="back">Back</button>
|
||||
</div>
|
||||
|
||||
<!-- Timeout -->
|
||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-timeout">
|
||||
<div class="ios-state-icon bg-[rgba(255,149,0,.12)] mb-6">
|
||||
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="#ff9500"></i>
|
||||
</div>
|
||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
||||
<p class="text-[14px] text-muted-foreground mb-7" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="ios-btn-secondary" onclick="goBack()" data-i18n="back">Back</button>
|
||||
<button class="ios-btn-primary px-7" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
||||
</div>
|
||||
<button class="btn-secondary flex-1" onclick="goBack()" data-i18n="back">Back</button>
|
||||
<button class="btn-primary flex-1" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-not-found">
|
||||
<div class="ios-state-icon bg-[rgba(142,142,147,.12)] mb-6">
|
||||
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="#8e8e93"></i>
|
||||
</div>
|
||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
||||
<p class="text-[14px] text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already expired</p>
|
||||
<!-- State: Not Found -->
|
||||
<section id="screen-not-found"
|
||||
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||
aria-live="polite" style="display:none">
|
||||
<div class="state-icon bg-muted-foreground/12 mb-6">
|
||||
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="var(--muted-foreground)"></i>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
||||
<p class="text-sm text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already
|
||||
expired
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /panel viewport -->
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="flex items-center justify-center gap-2.5 mt-6 text-[12px] text-muted-foreground select-none">
|
||||
<footer class="flex items-center justify-center gap-2.5 pb-6 text-xs text-muted-foreground select-none relative z-10">
|
||||
<span class="flex items-center gap-1.5">
|
||||
Powered by
|
||||
<a href="https://www.gmwallet.app" target="_blank" rel="noopener" class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
|
||||
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-[3px]" />
|
||||
<a href="https://www.gmwallet.app" target="_blank" rel="noopener"
|
||||
class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
|
||||
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-xs" />
|
||||
GM Wallet
|
||||
</a>
|
||||
</span>
|
||||
<span class="opacity-30">|</span>
|
||||
<a href="https://github.com/GMwalletApp/epusdt" target="_blank" rel="noopener" class="flex items-center gap-1 hover:opacity-70 transition-opacity">
|
||||
<a href="https://github.com/GMwalletApp" target="_blank" rel="noopener"
|
||||
class="flex items-center gap-1 hover:opacity-70 transition-opacity">
|
||||
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<!-- Toast -->
|
||||
<div id="toast">Copied</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast">Copied</div>
|
||||
|
||||
<!-- Order data injected by server (Go template) -->
|
||||
<script>
|
||||
<!-- Order data injected by server (Go template) -->
|
||||
<script>
|
||||
var ORDER = {
|
||||
tradeId: "{{.TradeId}}",
|
||||
amount: "{{.Amount}}",
|
||||
@@ -263,9 +536,11 @@
|
||||
receiveAddress: "{{.ReceiveAddress}}",
|
||||
expirationTime: "{{.ExpirationTime}}",
|
||||
redirectUrl: "{{.RedirectUrl}}",
|
||||
createdAt: "{{.CreatedAt}}"
|
||||
createdAt: "{{.CreatedAt}}",
|
||||
is_selected: "{{.IsSelected}}"
|
||||
};
|
||||
</script>
|
||||
<script defer src="/static/payment.js"></script>
|
||||
</script>
|
||||
<script defer src="/static/payment.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,395 +0,0 @@
|
||||
/* =============================================
|
||||
iOS 26 Liquid Glass Design System
|
||||
============================================= */
|
||||
|
||||
/* ---------- CSS Variables ---------- */
|
||||
:root, [data-theme="light"] {
|
||||
--background: #f2f2f7;
|
||||
--foreground: #1c1c1e;
|
||||
--card: rgba(255,255,255,0.72);
|
||||
--card-foreground: #1c1c1e;
|
||||
--popover: rgba(255,255,255,0.88);
|
||||
--popover-foreground:#1c1c1e;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground:#1c1c1e;
|
||||
--primary-hover: #e5e5ea;
|
||||
--secondary: rgba(120,120,128,0.12);
|
||||
--muted: rgba(120,120,128,0.08);
|
||||
--muted-foreground: #8e8e93;
|
||||
--accent: rgba(255,255,255,0.12);
|
||||
--accent-foreground: #1c1c1e;
|
||||
--border: rgba(60,60,67,0.12);
|
||||
--border-hover: rgba(60,60,67,0.22);
|
||||
--success: #34c759;
|
||||
--warning: #ff9500;
|
||||
--destructive: #ff3b30;
|
||||
|
||||
--glass-bg: rgba(255,255,255,0.65);
|
||||
--glass-border: rgba(255,255,255,0.8);
|
||||
--glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset;
|
||||
--card-shadow: 0 4px 24px rgba(0,0,0,.08);
|
||||
--popover-shadow: 0 12px 40px rgba(0,0,0,.14);
|
||||
|
||||
--blob-1: rgba(200,200,200,0.18);
|
||||
--blob-2: rgba(52,199,89,0.14);
|
||||
--blob-3: rgba(255,149,0,0.10);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--background: #000000;
|
||||
--foreground: #f5f5f7;
|
||||
--card: rgba(28,28,30,0.82);
|
||||
--card-foreground: #f5f5f7;
|
||||
--popover: rgba(44,44,46,0.92);
|
||||
--popover-foreground:#e5e5ea;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground:#1c1c1e;
|
||||
--primary-hover: #e5e5ea;
|
||||
--secondary: rgba(120,120,128,0.18);
|
||||
--muted: rgba(120,120,128,0.12);
|
||||
--muted-foreground: #636366;
|
||||
--accent: rgba(255,255,255,0.15);
|
||||
--accent-foreground: #ffffff;
|
||||
--border: rgba(255,255,255,0.1);
|
||||
--border-hover: rgba(255,255,255,0.18);
|
||||
--success: #30d158;
|
||||
--warning: #ff9f0a;
|
||||
--destructive: #ff453a;
|
||||
|
||||
--glass-bg: rgba(28,28,30,0.75);
|
||||
--glass-border: rgba(255,255,255,0.10);
|
||||
--glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset;
|
||||
--card-shadow: 0 4px 32px rgba(0,0,0,.55);
|
||||
--popover-shadow: 0 12px 40px rgba(0,0,0,.5);
|
||||
|
||||
--blob-1: rgba(255,255,255,0.14);
|
||||
--blob-2: rgba(48,209,88,0.10);
|
||||
--blob-3: rgba(255,159,10,0.08);
|
||||
}
|
||||
|
||||
/* ---------- Ambient blobs ---------- */
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: blob-drift 18s ease-in-out infinite alternate;
|
||||
}
|
||||
.blob-1 {
|
||||
width: 420px; height: 420px;
|
||||
background: var(--blob-1);
|
||||
top: -120px; left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.blob-2 {
|
||||
width: 340px; height: 340px;
|
||||
background: var(--blob-2);
|
||||
bottom: -80px; right: -60px;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
.blob-3 {
|
||||
width: 260px; height: 260px;
|
||||
background: var(--blob-3);
|
||||
top: 45%; left: 55%;
|
||||
animation-delay: -12s;
|
||||
}
|
||||
@keyframes blob-drift {
|
||||
0% { transform: translate(0,0) scale(1); }
|
||||
50% { transform: translate(30px,-20px) scale(1.06); }
|
||||
100% { transform: translate(-20px,30px) scale(0.96); }
|
||||
}
|
||||
|
||||
/* ---------- Liquid Glass Card ---------- */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.6);
|
||||
backdrop-filter: blur(28px) saturate(1.6);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
/* ---------- Flippable Status Card ---------- */
|
||||
.status-card {
|
||||
position: relative;
|
||||
perspective: 1600px;
|
||||
transition: height 0.38s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.status-card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
|
||||
}
|
||||
.status-card.is-flipped .status-card-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.status-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.status-face-front {
|
||||
z-index: 2;
|
||||
}
|
||||
.status-face-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.state-panel-shell {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.state-screen {
|
||||
display: none;
|
||||
}
|
||||
.state-screen.is-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ---------- Timer ---------- */
|
||||
.timer-ring-track {
|
||||
stroke: var(--border);
|
||||
transition: stroke .3s;
|
||||
}
|
||||
.timer-ring-progress {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
transition: stroke-dashoffset 1s linear, stroke .5s;
|
||||
}
|
||||
.timer-icon {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---------- QR wrapper ---------- */
|
||||
.qr-wrapper {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ---------- iOS Pill button ---------- */
|
||||
.ios-pill {
|
||||
background: var(--glass-bg);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 99px;
|
||||
padding: 6px 13px;
|
||||
color: var(--card-foreground);
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.ios-pill:hover { opacity: 0.82; }
|
||||
.ios-pill:active { opacity: 0.6; }
|
||||
.ios-pill.w-9 { padding: 0; justify-content: center; }
|
||||
|
||||
/* ---------- iOS Rows ---------- */
|
||||
.ios-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ios-row:active { background: var(--muted); }
|
||||
|
||||
/* ---------- Icon button ---------- */
|
||||
.ios-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 10px;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ios-icon-btn:hover { background: var(--accent); color: var(--primary); }
|
||||
|
||||
/* ---------- Primary CTA button ---------- */
|
||||
.ios-btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 16px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s, opacity 0.15s;
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset;
|
||||
}
|
||||
[data-theme="dark"] .ios-btn-primary {
|
||||
box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset;
|
||||
}
|
||||
.ios-btn-primary:hover { background: var(--primary-hover); }
|
||||
.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; }
|
||||
.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* ---------- Secondary button ---------- */
|
||||
.ios-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--secondary);
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 13px;
|
||||
padding: 11px 28px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.ios-btn-secondary:hover { background: var(--accent); }
|
||||
.ios-btn-secondary:active { opacity: 0.7; }
|
||||
|
||||
/* ---------- State icon circle ---------- */
|
||||
.ios-state-icon {
|
||||
width: 76px; height: 76px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- Context Menu ---------- */
|
||||
.ios-menu {
|
||||
background: var(--popover);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(1.8);
|
||||
backdrop-filter: blur(30px) saturate(1.8);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--popover-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ios-menu.is-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
.ios-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--card-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ios-menu-item:hover { background: var(--accent); }
|
||||
.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; }
|
||||
.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); }
|
||||
|
||||
/* ---------- Select states ---------- */
|
||||
.select-trigger.is-open { opacity: 0.8; }
|
||||
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 36px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
background: rgba(28,28,30,0.88);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s, transform 0.22s;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-theme="light"] #toast {
|
||||
background: rgba(44,44,46,0.9);
|
||||
}
|
||||
|
||||
/* ---------- Animations ---------- */
|
||||
@keyframes state-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- View Transition ---------- */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root) { z-index: 1; }
|
||||
::view-transition-new(root) { z-index: 9; }
|
||||
|
||||
/* ---------- Debug toolbar ---------- */
|
||||
#debug-bar {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
||||
}
|
||||
#debug-toggle {
|
||||
width: 36px; height: 36px; border-radius: 50%; border: none;
|
||||
background: #7c3aed; color: #fff; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(124,58,237,.5);
|
||||
transition: background .15s, transform .1s;
|
||||
}
|
||||
#debug-toggle:hover { background: #6d28d9; }
|
||||
#debug-toggle:active { transform: scale(.92); }
|
||||
#debug-panel {
|
||||
display: none; flex-direction: column; gap: 5px;
|
||||
background: rgba(15,15,20,.92);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(124,58,237,.35); border-radius: 14px;
|
||||
padding: 12px; min-width: 150px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
||||
}
|
||||
#debug-panel.is-open { display: flex; }
|
||||
.debug-label {
|
||||
font-size: 10px; font-weight: 700; color: #a78bfa;
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
margin-bottom: 4px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.debug-btn {
|
||||
background: rgba(255,255,255,.07); color: #d1d5db;
|
||||
border: 1px solid rgba(255,255,255,.1); border-radius: 8px;
|
||||
padding: 6px 10px; font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; text-align: left; font-family: inherit;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; }
|
||||
.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; }
|
||||
.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; }
|
||||
.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; }
|
||||
.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; }
|
||||
.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; }
|
||||
.debug-btn--not-found {
|
||||
background: rgba(142,142,147,.15);
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-card,
|
||||
.status-card-inner,
|
||||
.status-face,
|
||||
.timer-ring-progress {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+682
-245
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user