mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a7963b335b | |||
| d7fa90112d |
@@ -0,0 +1,111 @@
|
|||||||
|
name: Bug 反馈
|
||||||
|
description: 有一个 bug 需要解决
|
||||||
|
title: "发现bug:"
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# 提 ISSUE 须知
|
||||||
|
|
||||||
|
请先阅读文档:
|
||||||
|
- [epusdt 文档](https://epusdt.com/)
|
||||||
|
|
||||||
|
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
|
||||||
|
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
|
||||||
|
|
||||||
|
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checks
|
||||||
|
attributes:
|
||||||
|
label: 提交前检查
|
||||||
|
options:
|
||||||
|
- label: 我已阅读文档并检查常见问题
|
||||||
|
required: true
|
||||||
|
- label: 我已搜索现有 issue,确认不是重复问题
|
||||||
|
required: true
|
||||||
|
- label: 我已尽量提供可复现信息(代码、配置、日志)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: epusdt_version
|
||||||
|
attributes:
|
||||||
|
label: 1. epusdt 版本
|
||||||
|
description: 请提供版本号或 commit hash,避免只写“最新版”
|
||||||
|
placeholder: |
|
||||||
|
例如:
|
||||||
|
- v1.2.3
|
||||||
|
- commit: abcdef1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: runtime
|
||||||
|
attributes:
|
||||||
|
label: 2. 运行环境版本
|
||||||
|
description: 请提供语言/运行时及相关依赖版本
|
||||||
|
placeholder: |
|
||||||
|
例如:
|
||||||
|
- Go/Python/Node 版本:
|
||||||
|
- 数据库及版本:
|
||||||
|
- Docker 版本(如有):
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: os_info
|
||||||
|
attributes:
|
||||||
|
label: 3. 操作系统信息
|
||||||
|
description: Windows / Linux / macOS 或其他系统,请写明版本
|
||||||
|
placeholder: |
|
||||||
|
例如:
|
||||||
|
- Ubuntu 22.04
|
||||||
|
- macOS 14.6
|
||||||
|
- Windows Server 2022
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro_code
|
||||||
|
attributes:
|
||||||
|
label: 4. 可复现代码/配置
|
||||||
|
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
|
||||||
|
render: shell
|
||||||
|
placeholder: |
|
||||||
|
# 最小复现步骤
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
# 关键请求/配置(脱敏后)
|
||||||
|
curl -X POST https://xxx/api/...
|
||||||
|
-H "Authorization: Bearer ***"
|
||||||
|
-d '{"amount":100,"order_id":"test-001"}'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: stacktrace
|
||||||
|
attributes:
|
||||||
|
label: 5. 错误日志 / 堆栈信息
|
||||||
|
description: 请粘贴完整报错信息、状态码、关键日志
|
||||||
|
render: shell
|
||||||
|
placeholder: |
|
||||||
|
例如:
|
||||||
|
- HTTP 状态码:
|
||||||
|
- 错误码:
|
||||||
|
- 错误堆栈:
|
||||||
|
- 关键日志片段:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: 补充信息
|
||||||
|
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
|
||||||
|
placeholder: 其他你认为有帮助的信息(可选)
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 安全漏洞报告
|
||||||
|
url: https://github.com/ginta/epusdt/security/advisories/new
|
||||||
|
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
|
||||||
@@ -1,73 +1,170 @@
|
|||||||
## Epusdt (Easy Payment Usdt)
|
# Epusdt — Easy Payment USDT
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="wiki/img/usdtlogo.png">
|
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||||
</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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
## 项目简介
|
<p align="center">
|
||||||
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
|
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||||
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql`和`redis`
|
<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>
|
||||||
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
|
<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>
|
||||||
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
|
</p>
|
||||||
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
|
|
||||||
|
|
||||||
## 项目特点
|
<p align="center">
|
||||||
- 支持私有化部署,无需担心钱包被篡改和吞单😁
|
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||||
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
|
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
|
||||||
- 多钱包地址轮询,提高订单并发率
|
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
|
||||||
- 异步队列响应,优雅及高性能
|
<a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||||
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
|
</p>
|
||||||
- 支持`http api`,其他系统亦可接入
|
|
||||||
- `Telegram`机器人接入,便捷使用和支付消息快速通知
|
---
|
||||||
|
|
||||||
|
## 🌍 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/assimon/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
|
Epusdt
|
||||||
├── plugins ---> (已集成的插件库,例如dujiaoka)
|
├── plugins/ → 已集成的系统插件(独角数卡等)
|
||||||
├── src ---> (项目核心目录)
|
├── src/ → 项目核心代码
|
||||||
├── sdk ---> (接入SDK)
|
├── sdk/ → 接入 SDK
|
||||||
├── sql ---> (安装sql文件或更新sql文件)
|
├── sql/ → 数据库安装 / 升级脚本
|
||||||
└── wiki ---> (知识库)
|
└── 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 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||||
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
|
|
||||||
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
|
|
||||||
|
|
||||||
## 设计实现
|
|
||||||
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
|
|
||||||
监听钱包地址`usdt`代币入账事件,通过`金额差异`和`时效性`来判定交易归属信息,
|
|
||||||
可参考下方`流程图`
|
|
||||||
```
|
```
|
||||||
简单的原理:
|
工作流程:
|
||||||
1.客户需要支付20.05usdt
|
1. 客户发起支付,需支付 20.05 USDT
|
||||||
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
|
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
||||||
3.发起支付的时候,我们可以判定钱包address_1的20.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟。
|
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
||||||
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
|
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||||
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功!
|
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||||
```
|
```
|
||||||
### 流程图:
|
|
||||||

|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 声明
|
---
|
||||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
|
||||||
|
|
||||||
|
## 💬 社区与支持
|
||||||
|
|
||||||
|
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/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>
|
||||||
|
|||||||
+7
-11
@@ -1,14 +1,14 @@
|
|||||||
app_name=epusdt
|
app_name=epusdt
|
||||||
app_uri=https://dujiaoka.com
|
app_uri=https://epusdt.com
|
||||||
log_level=info
|
log_level=info
|
||||||
http_access_log=false
|
http_access_log=false
|
||||||
sql_debug=false
|
sql_debug=false
|
||||||
http_listen=:8000
|
http_listen=:8000
|
||||||
|
|
||||||
static_path=/static
|
static_path=/static
|
||||||
runtime_root_path=/runtime
|
runtime_root_path=./runtime
|
||||||
|
|
||||||
log_save_path=/logs
|
log_save_path=./logs
|
||||||
log_max_size=32
|
log_max_size=32
|
||||||
log_max_age=7
|
log_max_age=7
|
||||||
max_backups=3
|
max_backups=3
|
||||||
@@ -50,14 +50,10 @@ queue_concurrency=10
|
|||||||
queue_poll_interval_ms=1000
|
queue_poll_interval_ms=1000
|
||||||
callback_retry_base_seconds=5
|
callback_retry_base_seconds=5
|
||||||
|
|
||||||
tg_bot_token=
|
|
||||||
tg_proxy=
|
|
||||||
tg_manage=
|
|
||||||
|
|
||||||
api_auth_token=
|
|
||||||
|
|
||||||
order_expiration_time=10
|
order_expiration_time=10
|
||||||
order_notice_max_retry=0
|
order_notice_max_retry=0
|
||||||
forced_usdt_rate=
|
|
||||||
api_rate_url=
|
api_rate_url=
|
||||||
tron_grid_api_key=
|
|
||||||
|
# Set to true to re-run the install wizard on next startup.
|
||||||
|
install=true
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
|
*.log
|
||||||
@@ -5,10 +5,13 @@ import (
|
|||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/assimon/luuu/config"
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/mq"
|
"github.com/assimon/luuu/mq"
|
||||||
"github.com/assimon/luuu/task"
|
"github.com/assimon/luuu/task"
|
||||||
"github.com/assimon/luuu/telegram"
|
"github.com/assimon/luuu/telegram"
|
||||||
|
appjwt "github.com/assimon/luuu/util/jwt"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
|
"github.com/gookit/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
var initOnce sync.Once
|
var initOnce sync.Once
|
||||||
@@ -18,6 +21,46 @@ func InitApp() {
|
|||||||
config.Init()
|
config.Init()
|
||||||
log.Init()
|
log.Init()
|
||||||
dao.Init()
|
dao.Init()
|
||||||
|
// Wire settings-table lookups into the config package so
|
||||||
|
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// config.Init() computes RateApiUrl before SettingsGetString is
|
||||||
|
// installed, so refresh the cache once DB-backed settings are available.
|
||||||
|
config.RateApiUrl = config.GetRateApiUrl()
|
||||||
|
// Seed admin account and JWT secret so the management console is
|
||||||
|
// immediately usable on a fresh install. Both are idempotent.
|
||||||
|
adminPwd, isNew, err := data.EnsureDefaultAdmin()
|
||||||
|
if err != nil {
|
||||||
|
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||||
|
}
|
||||||
|
if isNew {
|
||||||
|
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||||
|
color.Yellow.Println("║ Default admin account created. Change the password via admin console! ║")
|
||||||
|
color.Yellow.Printf("║ Username: admin ║\n")
|
||||||
|
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd)
|
||||||
|
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||||
|
}
|
||||||
|
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||||
|
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
|
||||||
|
}
|
||||||
|
// Seed one universal default API key on fresh installs. The seeded
|
||||||
|
// key (PID=1000) works for all three gateway flows.
|
||||||
|
_, err = data.EnsureDefaultApiKey()
|
||||||
|
if err != nil {
|
||||||
|
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
|
||||||
|
}
|
||||||
mq.Start()
|
mq.Start()
|
||||||
go telegram.BotStart()
|
go telegram.BotStart()
|
||||||
go task.Start()
|
go task.Start()
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/bootstrap"
|
"github.com/assimon/luuu/bootstrap"
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/assimon/luuu/config"
|
||||||
|
"github.com/assimon/luuu/install"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/assimon/luuu/middleware"
|
||||||
"github.com/assimon/luuu/route"
|
"github.com/assimon/luuu/route"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
@@ -37,6 +39,14 @@ var startCmd = &cobra.Command{
|
|||||||
Short: "start",
|
Short: "start",
|
||||||
Long: "start http service",
|
Long: "start http service",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// If no config file exists, or if install=true is set in the config,
|
||||||
|
// run the first-run install API on the same port as the main server.
|
||||||
|
// The wizard writes the .env (with install=false) and shuts itself
|
||||||
|
// down so bootstrap.InitApp() can read it normally on the same port.
|
||||||
|
if config.NeedsInstall() {
|
||||||
|
envPath, _ := config.ResolveConfigPath()
|
||||||
|
install.RunInstallServer(install.DefaultInstallAddr, envPath)
|
||||||
|
}
|
||||||
bootstrap.InitApp()
|
bootstrap.InitApp()
|
||||||
printBanner()
|
printBanner()
|
||||||
HttpServerStart()
|
HttpServerStart()
|
||||||
@@ -53,6 +63,20 @@ func HttpServerStart() {
|
|||||||
route.RegisterRoute(e)
|
route.RegisterRoute(e)
|
||||||
e.Static(config.StaticPath, config.StaticFilePath)
|
e.Static(config.StaticPath, config.StaticFilePath)
|
||||||
|
|
||||||
|
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||||
|
// of the working directory. main.go extracts www/ next to the binary.
|
||||||
|
wwwRoot := "./www"
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||||
|
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
|
||||||
|
HTML5: true,
|
||||||
|
Index: "index.html",
|
||||||
|
Root: wwwRoot,
|
||||||
|
}))
|
||||||
|
|
||||||
httpListen := viper.GetString("http_listen")
|
httpListen := viper.GetString("http_listen")
|
||||||
go func() {
|
go func() {
|
||||||
if err = e.Start(httpListen); err != http.ErrServerClosed {
|
if err = e.Start(httpListen); err != http.ErrServerClosed {
|
||||||
@@ -86,13 +110,28 @@ func customHTTPErrorHandler(err error, e echo.Context) {
|
|||||||
Message: msg,
|
Message: msg,
|
||||||
RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
|
RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
|
||||||
}
|
}
|
||||||
|
// echo.HTTPError carries a real HTTP status (401 for auth failures,
|
||||||
|
// 404 for missing routes, etc.). Propagate it instead of flattening
|
||||||
|
// everything to 200 — clients rely on the status code.
|
||||||
if he, ok := err.(*echo.HTTPError); ok {
|
if he, ok := err.(*echo.HTTPError); ok {
|
||||||
e.String(http.StatusOK, he.Message.(string))
|
resp.StatusCode = he.Code
|
||||||
|
if s, ok := he.Message.(string); ok {
|
||||||
|
resp.Message = s
|
||||||
|
} else if he.Message != nil {
|
||||||
|
resp.Message = http.StatusText(he.Code)
|
||||||
|
}
|
||||||
|
_ = e.JSON(he.Code, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Internal RspError: propagate Code as both the JSON status_code and
|
||||||
|
// the real HTTP status when it maps to one (400/401/...); business
|
||||||
|
// codes (>=1000) map to HTTP 400 so clients get a proper 4xx while
|
||||||
|
// still reading the granular code from the body.
|
||||||
if he, ok := err.(*constant.RspError); ok {
|
if he, ok := err.(*constant.RspError); ok {
|
||||||
resp.StatusCode = he.Code
|
resp.StatusCode = he.Code
|
||||||
resp.Message = he.Msg
|
resp.Message = he.Msg
|
||||||
|
_ = e.JSON(he.HttpStatus(), resp)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = e.JSON(http.StatusOK, resp)
|
_ = e.JSON(http.StatusInternalServerError, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-4
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// parseFloat is the minimal helper used by the settings bridge; kept
|
||||||
|
// here so settings_bridge.go stays import-free.
|
||||||
|
func parseFloat(s string) (float64, error) {
|
||||||
|
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
HTTPAccessLog bool
|
HTTPAccessLog bool
|
||||||
SQLDebug bool
|
SQLDebug bool
|
||||||
@@ -124,6 +131,57 @@ func resolveConfigFilePath() (string, error) {
|
|||||||
return normalizeConfiguredPath(".env")
|
return normalizeConfiguredPath(".env")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NeedsInstall returns true when the first-run install wizard should run.
|
||||||
|
// It returns true if the config file does not exist yet, or if the file
|
||||||
|
// exists and contains install=true (an explicit reset flag).
|
||||||
|
// Existing installs that have no install key are NOT affected.
|
||||||
|
func NeedsInstall() bool {
|
||||||
|
envPath, exists := ResolveConfigPath()
|
||||||
|
if !exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Read just the install key without triggering full Init().
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigFile(envPath)
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
// Unreadable config → treat as needing install.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveConfigPath returns the absolute path to the .env file and whether
|
||||||
|
// it currently exists on disk. Unlike resolveConfigFilePath it does not
|
||||||
|
// return an error when the file is absent — callers (e.g. the install
|
||||||
|
// wizard) need the target path even before the file is written.
|
||||||
|
func ResolveConfigPath() (path string, exists bool) {
|
||||||
|
candidate := explicitConfigPath
|
||||||
|
if candidate == "" {
|
||||||
|
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
|
||||||
|
candidate = e
|
||||||
|
} else {
|
||||||
|
candidate = ".env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve relative paths against cwd.
|
||||||
|
p := strings.TrimSpace(candidate)
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err == nil {
|
||||||
|
p = filepath.Join(cwd, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
p = filepath.Join(p, ".env")
|
||||||
|
info, err = os.Stat(p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return p, false
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeConfiguredPath(input string) (string, error) {
|
func normalizeConfiguredPath(input string) (string, error) {
|
||||||
path := strings.TrimSpace(input)
|
path := strings.TrimSpace(input)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -178,11 +236,19 @@ func GetAppUri() string {
|
|||||||
return viper.GetString("app_uri")
|
return viper.GetString("app_uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetApiAuthToken() string {
|
func GetRateApiUrl() string {
|
||||||
return viper.GetString("api_auth_token")
|
// settings table wins (admin-configurable); .env and env var remain
|
||||||
|
// as fallbacks for smooth migration from the old layout.
|
||||||
|
if db := settingsRateApiUrl(); db != "" {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
return GetRateApiUrlFromEnv()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRateApiUrl() string {
|
// GetRateApiUrlFromEnv returns the rate API URL read only from the
|
||||||
|
// .env / environment variables, bypassing the settings table. Used
|
||||||
|
// by bootstrap to seed the settings table from the initial .env value.
|
||||||
|
func GetRateApiUrlFromEnv() string {
|
||||||
rateURL := viper.GetString("api_rate_url")
|
rateURL := viper.GetString("api_rate_url")
|
||||||
if rateURL == "" {
|
if rateURL == "" {
|
||||||
rateURL = os.Getenv("API_RATE_URL")
|
rateURL = os.Getenv("API_RATE_URL")
|
||||||
@@ -190,7 +256,6 @@ func GetRateApiUrl() string {
|
|||||||
if rateURL == "" {
|
if rateURL == "" {
|
||||||
log.Println("api_rate_url is empty")
|
log.Println("api_rate_url is empty")
|
||||||
}
|
}
|
||||||
RateApiUrl = rateURL
|
|
||||||
return rateURL
|
return rateURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +315,11 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUsdtRate() float64 {
|
func GetUsdtRate() float64 {
|
||||||
|
// Prefer the DB-backed override (admin-configurable). Fall back to
|
||||||
|
// the legacy .env var, then the hardcoded 6.4 default.
|
||||||
|
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||||
|
return forced
|
||||||
|
}
|
||||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
||||||
if forcedUsdtRate > 0 {
|
if forcedUsdtRate > 0 {
|
||||||
return forcedUsdtRate
|
return forcedUsdtRate
|
||||||
@@ -328,3 +398,15 @@ func GetCallbackRetryBaseDuration() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSolanaRpcUrl() string {
|
||||||
|
rpcUrl := viper.GetString("solana_rpc_url")
|
||||||
|
if rpcUrl == "" {
|
||||||
|
return "https://api.mainnet-beta.solana.com"
|
||||||
|
}
|
||||||
|
return rpcUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEthereumWsUrl() string {
|
||||||
|
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,8 +67,27 @@ func TestResolveConfigFilePathUsesCurrentDirectoryByDefault(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve config path: %v", err)
|
t.Fatalf("resolve config path: %v", err)
|
||||||
}
|
}
|
||||||
if got != configPath {
|
|
||||||
t.Fatalf("config path = %s, want %s", got, configPath)
|
gotAbs, err := filepath.Abs(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abs got: %v", err)
|
||||||
|
}
|
||||||
|
wantAbs, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abs want: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotReal, err := filepath.EvalSymlinks(gotAbs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval symlinks got: %v", err)
|
||||||
|
}
|
||||||
|
wantReal, err := filepath.EvalSymlinks(wantAbs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval symlinks want: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotReal != wantReal {
|
||||||
|
t.Fatalf("config path = %s, want %s", gotReal, wantReal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 fall through to the .env / default path.
|
||||||
|
|
||||||
|
// 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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/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,157 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
appjwt "github.com/assimon/luuu/util/jwt"
|
||||||
|
"github.com/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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 default
|
||||||
|
// password so the UI can show a prominent reminder.
|
||||||
|
isDefault := data.VerifyPassword(user.PasswordHash, "admin")
|
||||||
|
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/assimon/luuu/controller"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/notify"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/request"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/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 exchange rate (0 = use api)
|
||||||
|
// rate.api_url (string) — external rate API URL
|
||||||
|
// 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, 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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminAddWalletRequest is the payload for adding a wallet via admin.
|
||||||
|
type AdminAddWalletRequest struct {
|
||||||
|
Network string `json:"network" validate:"required" example:"tron"`
|
||||||
|
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
|
||||||
|
Remark string `json:"remark" example:"主钱包"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
|
||||||
|
type AdminUpdateWalletRequest struct {
|
||||||
|
Remark *string `json:"remark" example:"已更新的备注"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
|
||||||
|
type AdminChangeWalletStatusRequest struct {
|
||||||
|
// 状态 1=启用 2=禁用
|
||||||
|
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBatchImportRequest is the payload for batch importing wallets.
|
||||||
|
type AdminBatchImportRequest struct {
|
||||||
|
Network string `json:"network" validate:"required" example:"tron"`
|
||||||
|
// Addresses listed as observation-only wallets. Private-key import
|
||||||
|
// and API-managed modes are out of scope for v1.
|
||||||
|
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletListItem extends WalletAddress with order count.
|
||||||
|
type WalletListItem struct {
|
||||||
|
mdb.WalletAddress
|
||||||
|
OrderCount int64 `json:"order_count" example:"5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListWallets extends the read model with per-wallet order counts
|
||||||
|
// so the UI can render the "订单数" column without an N+1 follow-up.
|
||||||
|
// @Summary List wallets (admin)
|
||||||
|
// @Description Returns wallets with per-wallet order counts
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Produce json
|
||||||
|
// @Param network query string false "Network filter"
|
||||||
|
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
|
||||||
|
// @Param keyword query string false "Search by address or remark"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets [get]
|
||||||
|
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
||||||
|
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||||
|
status := strings.TrimSpace(ctx.QueryParam("status"))
|
||||||
|
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
|
||||||
|
|
||||||
|
tx := dao.Mdb.Model(&mdb.WalletAddress{})
|
||||||
|
if network != "" {
|
||||||
|
tx = tx.Where("network = ?", network)
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
if s, err := strconv.Atoi(status); err == nil {
|
||||||
|
tx = tx.Where("status = ?", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
kw := "%" + keyword + "%"
|
||||||
|
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
|
||||||
|
}
|
||||||
|
var rows []mdb.WalletAddress
|
||||||
|
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := data.CountOrdersByAddress()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
out := make([]WalletListItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAddWallet wraps the existing create with admin-exposed Remark +
|
||||||
|
// Source=manual so audit fields are populated.
|
||||||
|
// @Summary Add wallet (admin)
|
||||||
|
// @Description Create a wallet with admin-specific fields (remark, source=manual)
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets [post]
|
||||||
|
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||||
|
req := new(AdminAddWalletRequest)
|
||||||
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
// Patch remark/source separately to avoid changing the existing
|
||||||
|
// package-level AddWalletAddressWithNetwork signature (used by
|
||||||
|
// Telegram bot + legacy endpoint).
|
||||||
|
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
|
||||||
|
if req.Remark != "" {
|
||||||
|
fields["remark"] = req.Remark
|
||||||
|
}
|
||||||
|
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
|
||||||
|
row.Remark = req.Remark
|
||||||
|
row.Source = mdb.WalletSourceManual
|
||||||
|
return c.SucJson(ctx, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetWallet returns detail including order count for one wallet.
|
||||||
|
// @Summary Get wallet (admin)
|
||||||
|
// @Description Returns wallet detail with order count
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Wallet ID"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets/{id} [get]
|
||||||
|
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
wallet, err := data.GetWalletAddressById(id)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if wallet.ID == 0 {
|
||||||
|
return c.FailJson(ctx, errors.New("wallet not found"))
|
||||||
|
}
|
||||||
|
counts, _ := data.CountOrdersByAddress()
|
||||||
|
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateWallet only patches remark (status has its own endpoint).
|
||||||
|
// @Summary Update wallet (admin)
|
||||||
|
// @Description Patch wallet remark
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Wallet ID"
|
||||||
|
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
|
||||||
|
// @Success 200 {object} response.ApiResponse
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets/{id} [patch]
|
||||||
|
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
req := new(AdminUpdateWalletRequest)
|
||||||
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
fields := map[string]interface{}{}
|
||||||
|
if req.Remark != nil {
|
||||||
|
fields["remark"] = *req.Remark
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return c.SucJson(ctx, nil)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChangeWalletStatus reuses existing helper.
|
||||||
|
// @Summary Change wallet status (admin)
|
||||||
|
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Wallet ID"
|
||||||
|
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
|
||||||
|
// @Success 200 {object} response.ApiResponse
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets/{id}/status [post]
|
||||||
|
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
req := new(AdminChangeWalletStatusRequest)
|
||||||
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteWallet soft-deletes the wallet.
|
||||||
|
// @Summary Delete wallet (admin)
|
||||||
|
// @Description Soft-delete a wallet
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Wallet ID"
|
||||||
|
// @Success 200 {object} response.ApiResponse
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets/{id} [delete]
|
||||||
|
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBatchImportWallets accepts a list of addresses and creates
|
||||||
|
// them all as observation wallets. Per-row failures are collected and
|
||||||
|
// returned so the UI can surface which addresses already existed.
|
||||||
|
// @Summary Batch import wallets
|
||||||
|
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
||||||
|
// @Tags Admin Wallets
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/wallets/batch-import [post]
|
||||||
|
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||||
|
req := new(AdminBatchImportRequest)
|
||||||
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
out := make([]result, 0, len(req.Addresses))
|
||||||
|
for _, addr := range req.Addresses {
|
||||||
|
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||||
|
Update("source", mdb.WalletSourceImport).Error
|
||||||
|
out = append(out, result{Address: addr, OK: true})
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, out)
|
||||||
|
}
|
||||||
@@ -1,13 +1,48 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/middleware"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/assimon/luuu/model/request"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/assimon/luuu/model/service"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
|
||||||
|
// Returns nil when the middleware didn't run (should not happen on authed routes).
|
||||||
|
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
||||||
|
if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTransaction 创建交易
|
// CreateTransaction 创建交易
|
||||||
|
// @Summary Create transaction
|
||||||
|
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||||
|
// @Tags Payment
|
||||||
|
// @Accept json
|
||||||
|
// @Accept x-www-form-urlencoded
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||||
|
// @Param order_id formData string false "Merchant order ID"
|
||||||
|
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||||
|
// @Param token formData string false "Crypto token (e.g. usdt)"
|
||||||
|
// @Param network formData string false "Network (e.g. tron)"
|
||||||
|
// @Param amount formData number false "Amount"
|
||||||
|
// @Param notify_url formData string false "Callback URL"
|
||||||
|
// @Param signature formData string false "MD5 signature"
|
||||||
|
// @Param redirect_url formData string false "Redirect URL"
|
||||||
|
// @Param name formData string false "Order name"
|
||||||
|
// @Param payment_type formData string false "Payment type"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||||
req := new(request.CreateTransactionRequest)
|
req := new(request.CreateTransactionRequest)
|
||||||
if err = ctx.Bind(req); err != nil {
|
if err = ctx.Bind(req); err != nil {
|
||||||
@@ -16,9 +51,95 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
|||||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
resp, err := service.CreateTransaction(req)
|
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, resp)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,29 +12,47 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CheckoutCounter 收银台
|
// 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) {
|
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrOrder {
|
if err == service.ErrOrder {
|
||||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
// Unknown trade id: render the page with empty payload
|
||||||
if err != nil {
|
// (client side shows a friendly "order not found" screen).
|
||||||
return ctx.String(http.StatusOK, err.Error())
|
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{}
|
emptyResp := response.CheckoutCounterResponse{}
|
||||||
return tmpl.Execute(ctx.Response(), emptyResp)
|
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"))
|
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||||
if err != nil {
|
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)
|
return tmpl.Execute(ctx.Response(), resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckStatus 支付状态检测
|
// CheckStatus 支付状态检测
|
||||||
|
// @Summary Check payment status
|
||||||
|
// @Description Check the payment status of an order by trade ID
|
||||||
|
// @Tags Payment
|
||||||
|
// @Produce json
|
||||||
|
// @Param trade_id path string true "Trade ID"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /pay/check-status/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
order, err := service.GetOrderInfoByTradeId(tradeId)
|
order, err := service.GetOrderInfoByTradeId(tradeId)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package comm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/response"
|
||||||
|
"github.com/assimon/luuu/util/constant"
|
||||||
|
"github.com/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)
|
||||||
|
}
|
||||||
+27
-7
@@ -5,8 +5,10 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcutil v1.0.2
|
github.com/btcsuite/btcutil v1.0.2
|
||||||
github.com/dromara/carbon/v2 v2.6.15
|
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/gagliardetto/solana-go v1.16.0
|
||||||
github.com/go-resty/resty/v2 v2.11.0
|
github.com/go-resty/resty/v2 v2.11.0
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||||
github.com/gookit/color v1.5.0
|
github.com/gookit/color v1.5.0
|
||||||
github.com/gookit/goutil v0.4.6
|
github.com/gookit/goutil v0.4.6
|
||||||
github.com/gookit/validate v1.3.1
|
github.com/gookit/validate v1.3.1
|
||||||
@@ -22,6 +24,7 @@ require (
|
|||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
gopkg.in/telebot.v3 v3.0.0
|
gopkg.in/telebot.v3 v3.0.0
|
||||||
gorm.io/driver/mysql v1.5.1
|
gorm.io/driver/mysql v1.5.1
|
||||||
gorm.io/driver/postgres v1.5.2
|
gorm.io/driver/postgres v1.5.2
|
||||||
@@ -32,19 +35,32 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.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/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/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/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/fatih/color v1.18.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||||
github.com/gagliardetto/treeout v0.1.4 // 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-sql-driver/mysql v1.7.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gookit/filter v1.1.2 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/mitchellh/go-testing-interface v1.14.1 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sagikazarmark/locafero v0.9.0 // 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/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.14.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
|
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||||
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/ratelimit v0.3.1 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.47.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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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/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.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
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 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
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=
|
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/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/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/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/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 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI=
|
||||||
github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
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/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 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
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/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/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
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 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
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/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 h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:YoxBxG5H+WucKjfEo8X+QvYOzED7ue3Mfpq6jtpIbYs=
|
||||||
github.com/gookit/validate v1.3.1/go.mod h1:mzcr3U5XtAlQVAgbW0wbmDGiuefU/MNuDkXFMBNb1ko=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU=
|
||||||
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
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/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:vsYEeeYy077cB5yMpgI+ubA7iVRZEtrzHhcvRhd27gA=
|
||||||
github.com/labstack/echo/v4 v4.6.0/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
|
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 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
|
||||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
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/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 h1:H4kdU3LBOjdxfQy8s02/wG1rNmp8GMiqzgel0axewtI=
|
||||||
github.com/libtnb/sqlite v1.0.1/go.mod h1:zuagCP0nvIJwr4e0kdzq9Yc2Pw5HlVOoLT6ZmzmjOes=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
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 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.7.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.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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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.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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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/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 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
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/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 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs=
|
||||||
github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I=
|
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 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
|
||||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
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.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
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/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 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
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.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.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.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
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/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.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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=
|
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -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-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-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-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-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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
@@ -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/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-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-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=
|
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 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-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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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/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.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
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 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo=
|
||||||
gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg=
|
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=
|
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/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
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,336 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
"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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||||
|
// of the working directory. main.go extracts www/ next to the binary.
|
||||||
|
wwwRoot := "./www"
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||||
|
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
|
HTML5: true,
|
||||||
|
Index: "index.html",
|
||||||
|
Root: wwwRoot,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Build a human-readable URL for the console hint.
|
||||||
|
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,175 @@
|
|||||||
|
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 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
viper.Set("callback_retry_base_seconds", 1)
|
viper.Set("callback_retry_base_seconds", 1)
|
||||||
viper.Set("queue_concurrency", 4)
|
viper.Set("queue_concurrency", 4)
|
||||||
viper.Set("queue_poll_interval_ms", 50)
|
viper.Set("queue_poll_interval_ms", 50)
|
||||||
viper.Set("api_auth_token", "test-token")
|
|
||||||
|
|
||||||
config.HTTPAccessLog = false
|
config.HTTPAccessLog = false
|
||||||
config.SQLDebug = false
|
config.SQLDebug = false
|
||||||
@@ -38,12 +37,44 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
||||||
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
|
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
|
||||||
|
|
||||||
mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{})
|
mustMigrate(t, mainDB,
|
||||||
|
&mdb.Orders{},
|
||||||
|
&mdb.WalletAddress{},
|
||||||
|
&mdb.ApiKey{},
|
||||||
|
&mdb.Setting{},
|
||||||
|
&mdb.NotificationChannel{},
|
||||||
|
&mdb.Chain{},
|
||||||
|
&mdb.ChainToken{},
|
||||||
|
&mdb.RpcNode{},
|
||||||
|
&mdb.AdminUser{},
|
||||||
|
)
|
||||||
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
|
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
|
||||||
|
|
||||||
dao.Mdb = mainDB
|
dao.Mdb = mainDB
|
||||||
dao.RuntimeDB = runtimeDB
|
dao.RuntimeDB = runtimeDB
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
closeDB(t, runtimeDB)
|
closeDB(t, runtimeDB)
|
||||||
closeDB(t, mainDB)
|
closeDB(t, mainDB)
|
||||||
|
|||||||
+99
@@ -1,11 +1,110 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/assimon/luuu/command"
|
"github.com/assimon/luuu/command"
|
||||||
"github.com/gookit/color"
|
"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() {
|
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() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
color.Error.Println("[Start Server Err!!!] ", err)
|
color.Error.Println("[Start Server Err!!!] ", err)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
appjwt "github.com/assimon/luuu/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,37 +2,142 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io/ioutil"
|
"crypto/subtle"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
"github.com/assimon/luuu/util/json"
|
"github.com/assimon/luuu/util/json"
|
||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/assimon/luuu/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Context keys populated by CheckApiSign after successful verification.
|
||||||
|
// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id.
|
||||||
|
const (
|
||||||
|
ApiKeyIDKey = "api_key_id"
|
||||||
|
ApiKeyRowKey = "api_key_row"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckApiSign validates the body signature against the secret_key of
|
||||||
|
// the api_keys row matching the submitted "pid" field. A single row is
|
||||||
|
// valid for all gateway flows — identification is always by pid.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Extract the pid from the request body.
|
||||||
|
// 2. Look up the enabled row by pid; if missing, return signature error.
|
||||||
|
// 3. Verify signature == MD5(sorted_params + secret_key).
|
||||||
|
// 4. Enforce IP whitelist (empty = allow any).
|
||||||
|
// 5. Bump call_count / last_used_at (best-effort).
|
||||||
|
// 6. Stash api_key_id + row in context and rewind the body.
|
||||||
func CheckApiSign() echo.MiddlewareFunc {
|
func CheckApiSign() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
params, err := ioutil.ReadAll(ctx.Request().Body)
|
params, err := io.ReadAll(ctx.Request().Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return constant.SignatureErr
|
return constant.SignatureErr
|
||||||
}
|
}
|
||||||
|
// Rewind the body for downstream bindings regardless of outcome.
|
||||||
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(params))
|
||||||
|
|
||||||
m := make(map[string]interface{})
|
m := make(map[string]interface{})
|
||||||
err = json.Cjson.Unmarshal(params, &m)
|
contentType := ctx.Request().Header.Get("Content-Type")
|
||||||
|
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||||
|
values, parseErr := url.ParseQuery(string(params))
|
||||||
|
if parseErr != nil {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
for k, vs := range values {
|
||||||
|
if len(vs) > 0 {
|
||||||
|
m[k] = vs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err = json.Cjson.Unmarshal(params, &m); err != nil {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
}
|
||||||
signature, ok := m["signature"]
|
signature, ok := m["signature"]
|
||||||
if !ok {
|
if !ok {
|
||||||
return constant.SignatureErr
|
return constant.SignatureErr
|
||||||
}
|
}
|
||||||
checkSignature, err := sign.Get(m, config.GetApiAuthToken())
|
identifier := extractPid(m)
|
||||||
|
if identifier == "" {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := data.GetEnabledApiKey(identifier)
|
||||||
|
if err != nil || row.ID == 0 {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSignature, err := sign.Get(m, row.SecretKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return constant.SignatureErr
|
return constant.SignatureErr
|
||||||
}
|
}
|
||||||
if checkSignature != signature {
|
signatureStr, _ := signature.(string)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 {
|
||||||
return constant.SignatureErr
|
return constant.SignatureErr
|
||||||
}
|
}
|
||||||
ctx.Request().Body = ioutil.NopCloser(bytes.NewBuffer(params))
|
|
||||||
|
if !IsIPWhitelisted(row.IpWhitelist, ctx.RealIP()) {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = data.TouchApiKeyUsage(row.ID)
|
||||||
|
|
||||||
|
ctx.Set(ApiKeyIDKey, row.ID)
|
||||||
|
ctx.Set(ApiKeyRowKey, row)
|
||||||
return next(ctx)
|
return next(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractPid reads the "pid" field from the request body. JSON numbers
|
||||||
|
// unmarshal to float64 (EPAY merchants typically send numeric pid); we
|
||||||
|
// format with -1 precision to drop trailing zeros so "1000" matches
|
||||||
|
// regardless of whether the client sent a string or a number.
|
||||||
|
func extractPid(m map[string]interface{}) string {
|
||||||
|
v, ok := m["pid"]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIPWhitelisted checks the CSV IP list. Empty list = open.
|
||||||
|
// Entries may be single IPs or CIDR blocks.
|
||||||
|
func IsIPWhitelisted(csv, remote string) bool {
|
||||||
|
csv = strings.TrimSpace(csv)
|
||||||
|
if csv == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
remoteIP := net.ParseIP(remote)
|
||||||
|
for _, raw := range strings.Split(csv, ",") {
|
||||||
|
entry := strings.TrimSpace(raw)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(entry, "/") {
|
||||||
|
_, cidr, err := net.ParseCIDR(entry)
|
||||||
|
if err == nil && remoteIP != nil && cidr.Contains(remoteIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry == remote {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,250 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
|
|
||||||
// 自动建表
|
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
|
||||||
|
// the minimum static rows: chains, chain tokens, default settings, and
|
||||||
|
// a Telegram notification channel migrated from legacy settings keys.
|
||||||
|
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
|
||||||
|
// established. The install wizard (see install/installer.go) writes the
|
||||||
|
// .env file before bootstrap runs, so this function always finds the DB
|
||||||
|
// available.
|
||||||
func MdbTableInit() {
|
func MdbTableInit() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
if err := Mdb.AutoMigrate(&mdb.Orders{}); err != nil {
|
migrations := []struct {
|
||||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
name string
|
||||||
// panic(err)
|
model interface{}
|
||||||
return
|
}{
|
||||||
|
{"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{}},
|
||||||
}
|
}
|
||||||
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
|
for _, m := range migrations {
|
||||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
if err := Mdb.AutoMigrate(m.model); err != nil {
|
||||||
// panic(err)
|
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func RuntimeInit() error {
|
|||||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
|
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
|
||||||
return err
|
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 {
|
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAdminUsername = "admin"
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
return password, true, dao.Mdb.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomAdminPassword() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("password_hash", hash).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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
|
||||||
|
}
|
||||||
+181
-18
@@ -28,10 +28,31 @@ func normalizeLockAmount(amount float64) (int64, string) {
|
|||||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLockNetwork(network string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(network))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLockAddress(network, address string) string {
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if isEVMNetwork(normalizeLockNetwork(network)) {
|
||||||
|
return strings.ToLower(address)
|
||||||
|
}
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLockToken(token string) string {
|
func normalizeLockToken(token string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(token))
|
return strings.ToUpper(strings.TrimSpace(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||||
|
network = normalizeLockNetwork(network)
|
||||||
|
address = normalizeLockAddress(network, address)
|
||||||
|
if isEVMNetwork(network) {
|
||||||
|
return tx.Where("lower(address) = ?", address)
|
||||||
|
}
|
||||||
|
return tx.Where("address = ?", address)
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||||
order := new(mdb.Orders)
|
order := new(mdb.Orders)
|
||||||
@@ -108,17 +129,152 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
|
|||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
|
// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent.
|
||||||
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
|
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)
|
scaledAmount, _ := normalizeLockAmount(amount)
|
||||||
var lock mdb.TransactionLock
|
var lock mdb.TransactionLock
|
||||||
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||||
Where("address = ?", address).
|
Where("network = ?", network).
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
Where("token = ?", normalizeLockToken(token)).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount).
|
||||||
Where("expires_at > ?", time.Now()).
|
Where("expires_at > ?", time.Now())
|
||||||
Limit(1).
|
query = applyLockAddressFilter(query, network, address)
|
||||||
Find(&lock).Error
|
err := query.Limit(1).Find(&lock).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -128,12 +284,15 @@ func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, am
|
|||||||
return lock.TradeId, nil
|
return lock.TradeId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockTransaction reserves an address+token+amount pair in sqlite until expiration.
|
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||||
func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||||
|
network = normalizeLockNetwork(network)
|
||||||
|
address = normalizeLockAddress(network, address)
|
||||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||||
normalizedToken := normalizeLockToken(token)
|
normalizedToken := normalizeLockToken(token)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
lock := &mdb.TransactionLock{
|
lock := &mdb.TransactionLock{
|
||||||
|
Network: network,
|
||||||
Address: address,
|
Address: address,
|
||||||
Token: normalizedToken,
|
Token: normalizedToken,
|
||||||
AmountScaled: scaledAmount,
|
AmountScaled: scaledAmount,
|
||||||
@@ -143,11 +302,12 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("address = ?", address).
|
expiredQuery := tx.Where("network = ?", network).
|
||||||
Where("token = ?", normalizedToken).
|
Where("token = ?", normalizedToken).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount).
|
||||||
Where("expires_at <= ?", now).
|
Where("expires_at <= ?", now)
|
||||||
Delete(&mdb.TransactionLock{}).Error; err != nil {
|
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||||
|
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
|
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||||
@@ -165,14 +325,17 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnLockTransaction releases the reservation for address+token+amount.
|
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||||
func UnLockTransaction(address string, token string, amount float64) error {
|
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||||
|
network = normalizeLockNetwork(network)
|
||||||
|
address = normalizeLockAddress(network, address)
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
scaledAmount, _ := normalizeLockAmount(amount)
|
||||||
return dao.RuntimeDB.
|
query := dao.RuntimeDB.
|
||||||
Where("address = ?", address).
|
Where("network = ?", network).
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
Where("token = ?", normalizeLockToken(token)).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount)
|
||||||
Delete(&mdb.TransactionLock{}).Error
|
query = applyLockAddressFilter(query, network, address)
|
||||||
|
return query.Delete(&mdb.TransactionLock{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnLockTransactionByTradeId(tradeID string) error {
|
func UnLockTransactionByTradeId(tradeID string) error {
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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/assimon/luuu/internal/testutil"
|
||||||
|
"github.com/assimon/luuu/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,117 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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 any enabled row when none are healthy yet (bootstrap case
|
||||||
|
// where the health check hasn't run). Returns nil if nothing is defined.
|
||||||
|
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 := rows[:0]
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Status == mdb.RpcNodeStatusOk {
|
||||||
|
healthy = append(healthy, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates := healthy
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
candidates = rows // bootstrap: no health check has run yet
|
||||||
|
}
|
||||||
|
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,166 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/assimon/luuu/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddWalletAddress 创建钱包
|
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
|
||||||
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if exist.ID > 0 {
|
if exist.ID > 0 {
|
||||||
return nil, constant.WalletAddressAlreadyExists
|
return nil, constant.WalletAddressAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for a soft-deleted record with the same (network, address) and restore it.
|
||||||
|
deleted := new(mdb.WalletAddress)
|
||||||
|
err = dao.Mdb.Unscoped().
|
||||||
|
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
|
||||||
|
Limit(1).Find(deleted).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if deleted.ID > 0 {
|
||||||
|
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||||
|
"deleted_at": nil,
|
||||||
|
"status": mdb.TokenStatusEnable,
|
||||||
|
}).Error
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
|
||||||
walletAddress := &mdb.WalletAddress{
|
walletAddress := &mdb.WalletAddress{
|
||||||
|
Network: network,
|
||||||
Address: address,
|
Address: address,
|
||||||
Status: mdb.TokenStatusEnable,
|
Status: mdb.TokenStatusEnable,
|
||||||
}
|
}
|
||||||
@@ -23,7 +72,19 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
return walletAddress, err
|
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) {
|
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
|
||||||
walletAddress := new(mdb.WalletAddress)
|
walletAddress := new(mdb.WalletAddress)
|
||||||
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
||||||
@@ -50,6 +111,25 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
|||||||
return WalletAddressList, err
|
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 获得所有钱包地址
|
// GetAllWalletAddress 获得所有钱包地址
|
||||||
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||||
var WalletAddressList []mdb.WalletAddress
|
var WalletAddressList []mdb.WalletAddress
|
||||||
@@ -57,6 +137,22 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
|||||||
return WalletAddressList, err
|
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 启用禁用钱包
|
// ChangeWalletAddressStatus 启用禁用钱包
|
||||||
func ChangeWalletAddressStatus(id uint64, status int) error {
|
func ChangeWalletAddressStatus(id uint64, status int) error {
|
||||||
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).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/assimon/luuu/internal/testutil"
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/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 {
|
type BaseModel struct {
|
||||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"`
|
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
|
||||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
|
||||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
CallBackConfirmNo = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentTypeEpay = "Epay"
|
||||||
|
)
|
||||||
|
|
||||||
type Orders struct {
|
type Orders struct {
|
||||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
|
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"`
|
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
|
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
||||||
Amount float64 `gorm:"column:amount" json:"amount"`
|
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||||
Currency string `gorm:"column:currency" json:"currency"`
|
Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
|
Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
|
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
|
||||||
Token string `gorm:"column:token" json:"token"`
|
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||||
Network string `gorm:"column:network" json:"network"`
|
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||||
Status int `gorm:"column:status;default:1" json:"status"`
|
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
|
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
||||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
|
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
|
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,54 @@
|
|||||||
|
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"
|
||||||
|
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 {
|
type TransactionLock struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
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"`
|
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
|
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
|
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"`
|
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
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"`
|
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||||
|
|||||||
@@ -5,10 +5,27 @@ const (
|
|||||||
TokenStatusDisable = 2
|
TokenStatusDisable = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NetworkTron = "tron"
|
||||||
|
NetworkSolana = "solana"
|
||||||
|
NetworkEthereum = "ethereum"
|
||||||
|
NetworkBsc = "bsc"
|
||||||
|
NetworkPolygon = "polygon"
|
||||||
|
NetworkPlasma = "plasma"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WalletSourceManual = "manual"
|
||||||
|
WalletSourceImport = "import"
|
||||||
|
)
|
||||||
|
|
||||||
type WalletAddress struct {
|
type WalletAddress struct {
|
||||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
|
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
|
||||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"`
|
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
|
||||||
Status int64 `gorm:"column:status;default:1" json:"status"`
|
// 状态 1=启用 2=禁用
|
||||||
|
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||||
|
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
|
||||||
|
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
|
||||||
BaseModel
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const (
|
|||||||
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
|
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
|
||||||
|
|
||||||
type BaseRequest struct {
|
type BaseRequest struct {
|
||||||
Page int `json:"page"` // 页数
|
Page int `json:"page" example:"1"` // 页数
|
||||||
PageSize int `json:"page_size"` // 每页条数
|
PageSize int `json:"page_size" example:"20"` // 每页条数
|
||||||
OrderField string `json:"order_field"` // 排序字段
|
OrderField string `json:"order_field" example:"created_at"` // 排序字段
|
||||||
OrderFunc string `json:"order_func"` // 排序方法
|
OrderFunc string `json:"order_func" enums:"DESC,ASC" example:"DESC"` // 排序方法
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import "github.com/gookit/validate"
|
|||||||
|
|
||||||
// CreateTransactionRequest 创建交易请求
|
// CreateTransactionRequest 创建交易请求
|
||||||
type CreateTransactionRequest struct {
|
type CreateTransactionRequest struct {
|
||||||
OrderId string `json:"order_id" validate:"required|maxLen:32"`
|
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||||
Currency string `json:"currency" validate:"required"` // 法币 如:cny
|
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||||
Token string `json:"token" validate:"required"` // 币种 如:usdt
|
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
|
||||||
Network string `json:"network" validate:"required"` // 网络 如:TRON
|
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
||||||
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
|
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||||
NotifyUrl string `json:"notify_url" validate:"required"`
|
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||||
Signature string `json:"signature" validate:"required"`
|
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||||
RedirectUrl string `json:"redirect_url"`
|
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||||
|
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||||
|
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||||
@@ -36,3 +38,18 @@ type OrderProcessingRequest struct {
|
|||||||
TradeId string
|
TradeId string
|
||||||
BlockTransactionId 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 创建订单成功返回
|
// CreateTransactionResponse 创建订单成功返回
|
||||||
type CreateTransactionResponse struct {
|
type CreateTransactionResponse struct {
|
||||||
TradeId string `json:"trade_id"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
OrderId string `json:"order_id"` // 客户交易id
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||||
Currency string `json:"currency"` // 订单货币类型 CNY USD......
|
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
PaymentUrl string `json:"payment_url"` // 收银台地址
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderNotifyResponse 订单异步回调结构体
|
// OrderNotifyResponse 订单异步回调结构体
|
||||||
type OrderNotifyResponse struct {
|
type OrderNotifyResponse struct {
|
||||||
TradeId string `json:"trade_id"` // epusdt订单号
|
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||||
OrderId string `json:"order_id"` // 客户交易id
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
BlockTransactionId string `json:"block_transaction_id"` // 区块id
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
Signature string `json:"signature"` // 签名
|
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||||
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||||
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
|
Status int `json:"status" enums:"1,2,3" example:"2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||||
|
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
|
package response
|
||||||
|
|
||||||
type CheckoutCounterResponse struct {
|
type CheckoutCounterResponse struct {
|
||||||
TradeId string `json:"trade_id"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
|
||||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
||||||
Token string `json:"token"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
Currency string `json:"currency"` // 法币币种 CNY USD ...
|
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||||
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Network string `json:"network"` // 网络 TRON ETH ...
|
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
||||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
RedirectUrl string `json:"redirect_url"`
|
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||||
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
|
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||||
|
IsSelected bool `json:"is_selected" example:"false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
TradeId string `json:"trade_id"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||||
Status int `json:"status"`
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
|
Status int `json:"status" enums:"1,2,3" example:"1"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -28,16 +28,39 @@ const (
|
|||||||
IncrementalMaximumNumber = 100
|
IncrementalMaximumNumber = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
var gCreateTransactionLock sync.Mutex
|
var (
|
||||||
var gOrderProcessingLock sync.Mutex
|
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.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
|
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
@@ -61,7 +84,10 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
return nil, constant.OrderAlreadyExists
|
return nil, constant.OrderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddress()
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -71,7 +97,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
|
|
||||||
tradeID := GenerateCode()
|
tradeID := GenerateCode()
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,9 +114,13 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
ActualAmount: availableAmount,
|
ActualAmount: availableAmount,
|
||||||
ReceiveAddress: availableAddress,
|
ReceiveAddress: availableAddress,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
Network: network,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: req.NotifyUrl,
|
NotifyUrl: req.NotifyUrl,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
|
Name: req.Name,
|
||||||
|
PaymentType: req.PaymentType,
|
||||||
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
}
|
}
|
||||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -148,22 +178,104 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
return err
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
|
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
|
||||||
func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||||
availableAddress := ""
|
availableAddress := ""
|
||||||
availableAmount := amount
|
availableAmount := amount
|
||||||
|
|
||||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||||
for _, address := range walletAddress {
|
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 {
|
if err == nil {
|
||||||
return address.Address, nil
|
return normalizedAddress, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, data.ErrTransactionLocked) {
|
if errors.Is(err, data.ErrTransactionLocked) {
|
||||||
continue
|
continue
|
||||||
@@ -208,3 +320,145 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
|||||||
}
|
}
|
||||||
return order, nil
|
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,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
|||||||
OrderId: orderID,
|
OrderId: orderID,
|
||||||
Currency: "CNY",
|
Currency: "CNY",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
}
|
}
|
||||||
@@ -31,11 +33,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
t.Fatalf("add wallet: %v", err)
|
t.Fatalf("add wallet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create first transaction: %v", err)
|
t.Fatalf("create first transaction: %v", err)
|
||||||
}
|
}
|
||||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
|
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create second transaction: %v", err)
|
t.Fatalf("create second transaction: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get first runtime lock: %v", err)
|
t.Fatalf("get first runtime lock: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +63,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get second runtime lock: %v", err)
|
t.Fatalf("get second runtime lock: %v", err)
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,49 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||||
|
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Address: mixedAddress,
|
||||||
|
Status: mdb.TokenStatusEnable,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_evm_1", 1)
|
||||||
|
req.Network = mdb.NetworkEthereum
|
||||||
|
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedAddress := strings.ToLower(mixedAddress)
|
||||||
|
if resp.ReceiveAddress != expectedAddress {
|
||||||
|
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load order: %v", err)
|
||||||
|
}
|
||||||
|
if order.ReceiveAddress != expectedAddress {
|
||||||
|
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if tradeID != resp.TradeId {
|
||||||
|
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -78,7 +123,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
|||||||
t.Fatalf("add wallet: %v", err)
|
t.Fatalf("add wallet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create transaction: %v", err)
|
t.Fatalf("create transaction: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,6 +131,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
|||||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_1",
|
BlockTransactionId: "block_1",
|
||||||
@@ -108,7 +154,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
|||||||
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get runtime lock after processing: %v", err)
|
t.Fatalf("get runtime lock after processing: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +171,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
|||||||
t.Fatalf("add wallet: %v", err)
|
t.Fatalf("add wallet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create transaction: %v", err)
|
t.Fatalf("create transaction: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,6 +179,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
|||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_1",
|
BlockTransactionId: "block_1",
|
||||||
@@ -166,7 +213,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
|||||||
t.Fatalf("add wallet: %v", err)
|
t.Fatalf("add wallet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create transaction: %v", err)
|
t.Fatalf("create transaction: %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,6 +227,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
|||||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_expired",
|
BlockTransactionId: "block_expired",
|
||||||
@@ -211,11 +259,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
|||||||
t.Fatalf("add wallet: %v", err)
|
t.Fatalf("add wallet: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create first transaction: %v", err)
|
t.Fatalf("create first transaction: %v", err)
|
||||||
}
|
}
|
||||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
|
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create second transaction: %v", err)
|
t.Fatalf("create second transaction: %v", err)
|
||||||
}
|
}
|
||||||
@@ -239,6 +287,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
|||||||
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: "shared_block",
|
BlockTransactionId: "shared_block",
|
||||||
@@ -298,3 +347,285 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
|||||||
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
|
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||||
|
t.Fatalf("add ethereum wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "usdt",
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch network create sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", parentResp.TradeId).
|
||||||
|
Update("status", mdb.StatusExpired).Error; err != nil {
|
||||||
|
t.Fatalf("force parent expired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: subResp.ReceiveAddress,
|
||||||
|
Token: strings.ToUpper(subResp.Token),
|
||||||
|
Network: strings.ToLower(subResp.Network),
|
||||||
|
TradeId: subResp.TradeId,
|
||||||
|
Amount: subResp.ActualAmount,
|
||||||
|
BlockTransactionId: "block_sub_parent_not_wait",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when parent order is not wait-pay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||||
|
t.Fatalf("add ethereum wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "usdt",
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch network create sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete parent order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: subResp.ReceiveAddress,
|
||||||
|
Token: strings.ToUpper(subResp.Token),
|
||||||
|
Network: strings.ToLower(subResp.Network),
|
||||||
|
TradeId: subResp.TradeId,
|
||||||
|
Amount: subResp.ActualAmount,
|
||||||
|
BlockTransactionId: "block_sub_parent_missing",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when parent order is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
|
||||||
|
// when a sub-order is paid, the parent order is marked as paid but its own
|
||||||
|
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
|
||||||
|
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
|
||||||
|
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
|
||||||
|
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||||
|
t.Fatalf("add ethereum wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the parent's original fields before any payment.
|
||||||
|
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load original parent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "usdt",
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch network create sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the sub-order to get its DB primary-key ID.
|
||||||
|
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: subResp.ReceiveAddress,
|
||||||
|
Token: strings.ToUpper(subResp.Token),
|
||||||
|
Network: strings.ToLower(subResp.Network),
|
||||||
|
TradeId: subResp.TradeId,
|
||||||
|
Amount: subResp.ActualAmount,
|
||||||
|
BlockTransactionId: "block_sub_paid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order processing sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-order must be paid with the block hash and no pending callback.
|
||||||
|
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload sub-order: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if sub.BlockTransactionId != "block_sub_paid" {
|
||||||
|
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
|
||||||
|
}
|
||||||
|
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
|
||||||
|
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent must be paid but its own fields must be unchanged.
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload parent order: %v", err)
|
||||||
|
}
|
||||||
|
if parent.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if parent.BlockTransactionId != "" {
|
||||||
|
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
|
||||||
|
}
|
||||||
|
if parent.ReceiveAddress != originalParent.ReceiveAddress {
|
||||||
|
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
|
||||||
|
}
|
||||||
|
if parent.ActualAmount != originalParent.ActualAmount {
|
||||||
|
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
|
||||||
|
}
|
||||||
|
if parent.PayBySubId != subOrder.ID {
|
||||||
|
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
|
||||||
|
}
|
||||||
|
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||||
|
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
|
||||||
|
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
|
||||||
|
// well as the parent's lock) are released.
|
||||||
|
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||||
|
t.Fatalf("add ethereum wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
|
||||||
|
t.Fatalf("add bsc wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create two sub-orders on different networks.
|
||||||
|
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "usdt",
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "usdt",
|
||||||
|
Network: mdb.NetworkBsc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to bsc sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay the Ethereum sub-order.
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: subEthResp.ReceiveAddress,
|
||||||
|
Token: strings.ToUpper(subEthResp.Token),
|
||||||
|
Network: strings.ToLower(subEthResp.Network),
|
||||||
|
TradeId: subEthResp.TradeId,
|
||||||
|
Amount: subEthResp.ActualAmount,
|
||||||
|
BlockTransactionId: "block_sib_eth",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("order processing eth sub-order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSC sibling must be expired.
|
||||||
|
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload bsc sub-order: %v", err)
|
||||||
|
}
|
||||||
|
if subBsc.Status != mdb.StatusExpired {
|
||||||
|
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent runtime lock must be released.
|
||||||
|
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||||
|
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check parent runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if parentLock != "" {
|
||||||
|
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSC sibling runtime lock must be released.
|
||||||
|
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||||
|
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if sibLock != "" {
|
||||||
|
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ethereum sub-order runtime lock must also be released.
|
||||||
|
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||||
|
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check eth sub-order runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if ethLock != "" {
|
||||||
|
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
|||||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: orderInfo.RedirectUrl,
|
RedirectUrl: orderInfo.RedirectUrl,
|
||||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||||
|
IsSelected: orderInfo.IsSelected,
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+578
-170
@@ -1,22 +1,451 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/config"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/request"
|
||||||
|
"github.com/assimon/luuu/util/constant"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
"github.com/assimon/luuu/util/math"
|
||||||
"github.com/gagliardetto/solana-go"
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := config.GetSolanaRpcUrl()
|
||||||
|
|
||||||
|
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 (
|
const (
|
||||||
// Mint token
|
// Mint token
|
||||||
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
||||||
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
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 (
|
const (
|
||||||
@@ -24,6 +453,49 @@ const (
|
|||||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
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) {
|
func FindATAAddress(owner, mint string) (string, error) {
|
||||||
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,191 +515,127 @@ func FindATAAddress(owner, mint string) (string, error) {
|
|||||||
return ata.String(), nil
|
return ata.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
|
||||||
InstructionTransfer = 3
|
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
InstructionTransferChecked = 12
|
programID := instruction.Get("programId").String()
|
||||||
)
|
parsedType := instruction.Get("parsed.type").String()
|
||||||
|
|
||||||
type TransferInfo struct {
|
if programID == SystemProgramID && parsedType == "transfer" {
|
||||||
ProgramID string
|
return parseSystemTransfer(instruction, txData)
|
||||||
Type string
|
}
|
||||||
|
|
||||||
Source string
|
if programID == TokenProgramID || programID == Token2022ProgramID {
|
||||||
Destination string
|
switch parsedType {
|
||||||
Mint string
|
case "transfer":
|
||||||
Authority string
|
return parseSplTransfer(instruction, txData)
|
||||||
|
case "transferChecked":
|
||||||
|
return parseSplTransferChecked(instruction, txData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Amount uint64
|
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
|
||||||
Decimals *uint8
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTokenProgram(programID solana.PublicKey) bool {
|
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
p := programID.String()
|
info := instruction.Get("parsed.info")
|
||||||
return p == TokenProgramID || p == Token2022ProgramID
|
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) {
|
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
|
||||||
if len(data) < 9 {
|
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
|
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 {
|
// Look up mint and decimals from postTokenBalances using the destination ATA
|
||||||
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
|
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 {
|
d := decimals
|
||||||
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
|
return &TransferInfo{
|
||||||
}
|
Source: source,
|
||||||
|
Destination: destination,
|
||||||
amount := binary.LittleEndian.Uint64(data[1:9])
|
Mint: mint,
|
||||||
|
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||||
sourceIdx := accountIndexes[0]
|
RawAmount: rawAmount.Uint64(),
|
||||||
destIdx := accountIndexes[1]
|
Decimals: &d,
|
||||||
authIdx := accountIndexes[2]
|
BlockTime: blockTime,
|
||||||
|
}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransferCheckedInfo struct {
|
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
|
||||||
ProgramID string
|
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
Type string
|
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
|
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||||
Destination string
|
if !ok {
|
||||||
Mint string
|
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if data[0] != InstructionTransferChecked {
|
return &TransferInfo{
|
||||||
return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
|
Source: source,
|
||||||
}
|
Destination: destination,
|
||||||
|
Mint: mint,
|
||||||
if len(accountIndexes) < 4 {
|
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||||
return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
|
RawAmount: rawAmount.Uint64(),
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
Decimals: &decimals,
|
Decimals: &decimals,
|
||||||
}
|
BlockTime: blockTime,
|
||||||
|
}, nil
|
||||||
return info, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTransactionTransfers
|
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
|
||||||
func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
|
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
|
||||||
signature, err := solana.SignatureFromBase58(sig)
|
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
|
||||||
if err != nil {
|
accountIndex := -1
|
||||||
return nil, fmt.Errorf("invalid signature: %w", err)
|
for i, key := range accountKeys {
|
||||||
}
|
if key.Get("pubkey").String() == ataAddress {
|
||||||
|
accountIndex = i
|
||||||
tx, err := client.GetTransaction(
|
break
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if accountIndex == -1 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
return results, nil
|
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
|
||||||
}
|
for _, balance := range balances {
|
||||||
|
if int(balance.Get("accountIndex").Int()) == accountIndex {
|
||||||
func toUint16Slice(in []uint16) []uint16 {
|
mint := balance.Get("mint").String()
|
||||||
out := make([]uint16, len(in))
|
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
|
||||||
for i, v := range in {
|
return mint, decimals, true
|
||||||
out[i] = uint16(v)
|
}
|
||||||
}
|
}
|
||||||
return out
|
return "", 0, false
|
||||||
}
|
|
||||||
|
|
||||||
func DecodeBase64InstructionData(s string) ([]byte, error) {
|
|
||||||
return base64.StdEncoding.DecodeString(s)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,349 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gagliardetto/solana-go/rpc"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSolTransaction(t *testing.T) {
|
func TestSolClientHealthy(t *testing.T) {
|
||||||
client := rpc.New("https://api.mainnet-beta.solana.com")
|
bodyData, err := SolRetryClient("getHealth", nil)
|
||||||
sig := ""
|
|
||||||
|
|
||||||
txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
t.Fatalf("SolRetryClient failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range txInfo {
|
var result map[string]interface{}
|
||||||
fmt.Println("type =", item.Type)
|
err = json.Unmarshal(bodyData, &result)
|
||||||
fmt.Println("program =", item.ProgramID)
|
if err != nil {
|
||||||
fmt.Println("from =", item.Source)
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
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) {
|
||||||
|
// 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) {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
fmt.Printf("%v\n", string(txData))
|
||||||
|
|
||||||
|
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 TestFindATAAddress(t *testing.T) {
|
func TestFindATAAddress(t *testing.T) {
|
||||||
owner := ""
|
tests := []struct {
|
||||||
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
name string
|
||||||
|
owner string
|
||||||
ata, err := FindATAAddress(owner, mint)
|
mint string
|
||||||
if err != nil {
|
want string
|
||||||
panic(err)
|
}{
|
||||||
|
{
|
||||||
|
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) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,18 +14,32 @@ import (
|
|||||||
"github.com/assimon/luuu/model/data"
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/assimon/luuu/model/request"
|
||||||
"github.com/assimon/luuu/telegram"
|
"github.com/assimon/luuu/notify"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
"github.com/assimon/luuu/util/http_client"
|
"github.com/assimon/luuu/util/http_client"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
"github.com/assimon/luuu/util/math"
|
"github.com/assimon/luuu/util/math"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/gookit/goutil/stdutil"
|
"github.com/gookit/goutil/stdutil"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
|
||||||
|
const tronNodeDefault = "https://api.trongrid.io"
|
||||||
|
|
||||||
|
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
||||||
|
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
|
||||||
|
// if none exists it falls back to the hard-coded default URL and the
|
||||||
|
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
|
||||||
|
func resolveTronNode() (string, string) {
|
||||||
|
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
|
return strings.TrimRight(node.Url, "/"), node.ApiKey
|
||||||
|
}
|
||||||
|
return tronNodeDefault, config.TRON_GRID_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -43,16 +58,29 @@ func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
// Native TRX is gated by a chain_tokens row with empty
|
||||||
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
|
// contract_address and symbol=TRX. Admin can disable this row to
|
||||||
}
|
// stop scanning native transfers without touching the chain toggle.
|
||||||
}()
|
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if trxCfg == nil || trxCfg.ID == 0 {
|
||||||
|
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trxDecimals := trxCfg.Decimals
|
||||||
|
if trxDecimals <= 0 {
|
||||||
|
trxDecimals = 6
|
||||||
|
}
|
||||||
|
|
||||||
client := http_client.GetHttpClient()
|
client := http_client.GetHttpClient()
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||||
endTime := carbon.Now().TimestampMilli()
|
endTime := carbon.Now().TimestampMilli()
|
||||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
|
tronBaseURL, tronAPIKey := resolveTronNode()
|
||||||
|
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
resp, err := client.R().SetQueryParams(map[string]string{
|
||||||
"order_by": "block_timestamp,desc",
|
"order_by": "block_timestamp,desc",
|
||||||
@@ -60,17 +88,20 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
"only_to": "true",
|
"only_to": "true",
|
||||||
"min_timestamp": stdutil.ToString(startTime),
|
"min_timestamp": stdutil.ToString(startTime),
|
||||||
"max_timestamp": stdutil.ToString(endTime),
|
"max_timestamp": stdutil.ToString(endTime),
|
||||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != http.StatusOK {
|
if resp.StatusCode() != http.StatusOK {
|
||||||
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
|
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||||
if !success {
|
if !success {
|
||||||
panic("TRX API response indicates failure")
|
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||||
@@ -104,15 +135,19 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
|
||||||
|
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
txID := transfer.Get("txID").String()
|
txID := transfer.Get("txID").String()
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if tradeID == "" {
|
if tradeID == "" {
|
||||||
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
||||||
@@ -122,7 +157,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||||
createTime := order.CreatedAt.TimestampMilli()
|
createTime := order.CreatedAt.TimestampMilli()
|
||||||
@@ -133,7 +169,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: "TRX",
|
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: txID,
|
BlockTransactionId: txID,
|
||||||
@@ -144,7 +181,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
sendPaymentNotification(order)
|
||||||
@@ -154,16 +192,33 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
// Build contract -> token map for the TRON network. If nothing is
|
||||||
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
|
// configured, skip — preserves the previous behavior of only watching
|
||||||
|
// admin-approved tokens.
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||||
|
for i := range tokens {
|
||||||
|
c := strings.TrimSpace(tokens[i].ContractAddress)
|
||||||
|
if c == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}()
|
contractTokens[c] = &tokens[i]
|
||||||
|
}
|
||||||
|
|
||||||
client := http_client.GetHttpClient()
|
client := http_client.GetHttpClient()
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||||
endTime := carbon.Now().TimestampMilli()
|
endTime := carbon.Now().TimestampMilli()
|
||||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
|
tronBaseURL, tronAPIKey := resolveTronNode()
|
||||||
|
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
resp, err := client.R().SetQueryParams(map[string]string{
|
||||||
"order_by": "block_timestamp,desc",
|
"order_by": "block_timestamp,desc",
|
||||||
@@ -171,17 +226,20 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
"only_to": "true",
|
"only_to": "true",
|
||||||
"min_timestamp": stdutil.ToString(startTime),
|
"min_timestamp": stdutil.ToString(startTime),
|
||||||
"max_timestamp": stdutil.ToString(endTime),
|
"max_timestamp": stdutil.ToString(endTime),
|
||||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != http.StatusOK {
|
if resp.StatusCode() != http.StatusOK {
|
||||||
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
|
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||||
if !success {
|
if !success {
|
||||||
panic("TRC20 API response indicates failure")
|
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||||
@@ -192,12 +250,15 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
|
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
|
||||||
|
|
||||||
for i, transfer := range transfers {
|
for i, transfer := range transfers {
|
||||||
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
|
contractAddr := transfer.Get("token_info.address").String()
|
||||||
|
cfg, ok := contractTokens[contractAddr]
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if transfer.Get("to").String() != address {
|
if transfer.Get("to").String() != address {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
|
||||||
|
|
||||||
valueStr := transfer.Get("value").String()
|
valueStr := transfer.Get("value").String()
|
||||||
decimalQuant, err := decimal.NewFromString(valueStr)
|
decimalQuant, err := decimal.NewFromString(valueStr)
|
||||||
@@ -206,25 +267,33 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
||||||
|
if tokenDecimals <= 0 {
|
||||||
|
tokenDecimals = int64(cfg.Decimals)
|
||||||
|
}
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
|
||||||
|
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
txID := transfer.Get("transaction_id").String()
|
txID := transfer.Get("transaction_id").String()
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
||||||
}
|
|
||||||
if tradeID == "" {
|
|
||||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
|
if tradeID == "" {
|
||||||
|
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||||
createTime := order.CreatedAt.TimestampMilli()
|
createTime := order.CreatedAt.TimestampMilli()
|
||||||
@@ -235,7 +304,8 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: "USDT",
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: txID,
|
BlockTransactionId: txID,
|
||||||
@@ -246,7 +316,8 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
panic(err)
|
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
sendPaymentNotification(order)
|
||||||
@@ -254,6 +325,121 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
|
||||||
|
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
|
||||||
|
// 管理后台新增 token 即可立即生效,无需代码改动。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
net := evmChainLogLabel(chainNetwork)
|
||||||
|
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == nil || token.ID == 0 {
|
||||||
|
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
||||||
|
if tokenSym == "" {
|
||||||
|
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decimals := token.Decimals
|
||||||
|
if decimals <= 0 {
|
||||||
|
decimals = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
divisor := decimal.New(1, int32(decimals))
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
||||||
|
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||||
|
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
|
||||||
|
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
|
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) {
|
func sendPaymentNotification(order *mdb.Orders) {
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"🎉 <b>收款成功通知</b>\n\n"+
|
"🎉 <b>收款成功通知</b>\n\n"+
|
||||||
@@ -263,6 +449,7 @@ func sendPaymentNotification(order *mdb.Orders) {
|
|||||||
"📋 <b>订单信息</b>\n"+
|
"📋 <b>订单信息</b>\n"+
|
||||||
"├ 交易号:<code>%s</code>\n"+
|
"├ 交易号:<code>%s</code>\n"+
|
||||||
"├ 订单号:<code>%s</code>\n"+
|
"├ 订单号:<code>%s</code>\n"+
|
||||||
|
"├ 网络:<code>%s</code>\n"+
|
||||||
"└ 钱包地址:<code>%s</code>\n\n"+
|
"└ 钱包地址:<code>%s</code>\n\n"+
|
||||||
"⏰ <b>时间信息</b>\n"+
|
"⏰ <b>时间信息</b>\n"+
|
||||||
"├ 创建时间:%s\n"+
|
"├ 创建时间:%s\n"+
|
||||||
@@ -273,9 +460,32 @@ func sendPaymentNotification(order *mdb.Orders) {
|
|||||||
strings.ToUpper(order.Token),
|
strings.ToUpper(order.Token),
|
||||||
order.TradeId,
|
order.TradeId,
|
||||||
order.OrderId,
|
order.OrderId,
|
||||||
|
networkDisplay(order.Network),
|
||||||
order.ReceiveAddress,
|
order.ReceiveAddress,
|
||||||
order.CreatedAt.ToDateTimeString(),
|
order.CreatedAt.ToDateTimeString(),
|
||||||
carbon.Now().ToDateTimeString(),
|
carbon.Now().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,92 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/config"
|
||||||
|
"github.com/assimon/luuu/internal/testutil"
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
|
||||||
|
// hard-coded default when the rpc_nodes table has no TRON row.
|
||||||
|
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
config.TRON_GRID_API_KEY = "fallback-key"
|
||||||
|
|
||||||
|
gotURL, gotKey := resolveTronNode()
|
||||||
|
if gotURL != tronNodeDefault {
|
||||||
|
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
|
||||||
|
}
|
||||||
|
if gotKey != "fallback-key" {
|
||||||
|
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
config.TRON_GRID_API_KEY = "fallback-key"
|
||||||
|
|
||||||
|
// 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 := resolveTronNode()
|
||||||
|
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 and
|
||||||
|
// the fallback is used.
|
||||||
|
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
config.TRON_GRID_API_KEY = "fallback-key"
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "https://disabled-tron.example.com",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotURL, gotKey := resolveTronNode()
|
||||||
|
if gotURL != tronNodeDefault {
|
||||||
|
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
|
||||||
|
}
|
||||||
|
if gotKey != "fallback-key" {
|
||||||
|
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-29
@@ -2,7 +2,9 @@ package mq
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +18,29 @@ import (
|
|||||||
"github.com/assimon/luuu/util/sign"
|
"github.com/assimon/luuu/util/sign"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resolveOrderApiKey returns the api_keys row that signed the order.
|
||||||
|
// Every order must carry an ApiKeyID (the default key is always seeded
|
||||||
|
// at bootstrap, and the middleware/EPAY inline flow always stamps it).
|
||||||
|
// If the row is missing or disabled, we return an error — using any
|
||||||
|
// other secret would produce a signature the merchant can't verify.
|
||||||
|
// The admin can resend the callback after fixing the key.
|
||||||
|
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
|
||||||
|
if order.ApiKeyID == 0 {
|
||||||
|
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
|
||||||
|
}
|
||||||
|
row, err := data.GetApiKeyByID(order.ApiKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
|
||||||
|
}
|
||||||
|
if row.Status != mdb.ApiKeyStatusEnable {
|
||||||
|
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
const batchSize = 100
|
const batchSize = 100
|
||||||
|
|
||||||
const sqliteBusyRetryAttempts = 3
|
const sqliteBusyRetryAttempts = 3
|
||||||
@@ -23,6 +48,7 @@ const sqliteBusyRetryAttempts = 3
|
|||||||
type expirableOrder struct {
|
type expirableOrder struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
TradeId string `gorm:"column:trade_id"`
|
TradeId string `gorm:"column:trade_id"`
|
||||||
|
Network string `gorm:"column:network"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address"`
|
ReceiveAddress string `gorm:"column:receive_address"`
|
||||||
Token string `gorm:"column:token"`
|
Token string `gorm:"column:token"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||||
@@ -65,7 +91,7 @@ func processExpiredOrders() {
|
|||||||
var orders []expirableOrder
|
var orders []expirableOrder
|
||||||
err := withSQLiteBusyRetry(func() error {
|
err := withSQLiteBusyRetry(func() error {
|
||||||
return dao.Mdb.Model(&mdb.Orders{}).
|
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("status = ?", mdb.StatusWaitPay).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Order("id asc").
|
Order("id asc").
|
||||||
@@ -89,7 +115,7 @@ func processExpiredOrders() {
|
|||||||
if !expired {
|
if !expired {
|
||||||
continue
|
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)
|
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,39 +187,100 @@ func processCallback(tradeID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendOrderCallback(order *mdb.Orders) error {
|
func sendOrderCallback(order *mdb.Orders) error {
|
||||||
client := http_client.GetHttpClient()
|
apiKeyRow, err := resolveOrderApiKey(order)
|
||||||
orderResp := response.OrderNotifyResponse{
|
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
|
||||||
TradeId: order.TradeId,
|
return errors.New("no api key row available for callback")
|
||||||
OrderId: order.OrderId,
|
|
||||||
Amount: order.Amount,
|
|
||||||
ActualAmount: order.ActualAmount,
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: order.Token,
|
|
||||||
BlockTransactionId: order.BlockTransactionId,
|
|
||||||
Status: mdb.StatusPaySuccess,
|
|
||||||
}
|
}
|
||||||
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
orderResp.Signature = signature
|
|
||||||
|
|
||||||
resp, err := client.R().
|
switch order.PaymentType {
|
||||||
SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)").
|
case mdb.PaymentTypeEpay:
|
||||||
SetBody(orderResp).
|
// EPAY uses pid (integer) and secret_key as "key".
|
||||||
Post(order.NotifyUrl)
|
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
||||||
if err != nil {
|
if convErr != nil {
|
||||||
return err
|
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != http.StatusOK {
|
notifyData := response.OrderNotifyResponseEpay{
|
||||||
return errors.New(resp.Status())
|
PID: pidInt,
|
||||||
}
|
TradeNo: order.TradeId,
|
||||||
if string(resp.Body()) != "ok" {
|
OutTradeNo: order.OrderId,
|
||||||
return errors.New("not ok")
|
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,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
BlockTransactionId: order.BlockTransactionId,
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
}
|
||||||
|
signature, err := sign.Get(orderResp, apiKeyRow.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
orderResp.Signature = signature
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)").
|
||||||
|
SetBody(orderResp).
|
||||||
|
Post(order.NotifyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != http.StatusOK {
|
||||||
|
return errors.New(resp.Status())
|
||||||
|
}
|
||||||
|
if !isCallbackAck(resp.Body()) {
|
||||||
|
return errors.New("not ok")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCallbackAck(body []byte) bool {
|
||||||
|
ack := strings.ToLower(strings.TrimSpace(string(body)))
|
||||||
|
return ack == "ok" || ack == "success"
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupExpiredTransactionLocks() {
|
func cleanupExpiredTransactionLocks() {
|
||||||
if err := data.CleanupExpiredTransactionLocks(); err != nil {
|
if err := data.CleanupExpiredTransactionLocks(); err != nil {
|
||||||
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)
|
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)
|
||||||
|
|||||||
+407
-4
@@ -1,6 +1,7 @@
|
|||||||
package mq
|
package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/assimon/luuu/model/dao"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/sign"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
|
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
|
||||||
@@ -27,6 +29,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
ActualAmount: 1,
|
ActualAmount: 1,
|
||||||
ReceiveAddress: "wallet_1",
|
ReceiveAddress: "wallet_1",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
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 {
|
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)
|
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)
|
t.Fatalf("lock expired order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +51,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
ActualAmount: 1.01,
|
ActualAmount: 1.01,
|
||||||
ReceiveAddress: "wallet_1",
|
ReceiveAddress: "wallet_1",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
|
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
|
||||||
t.Fatalf("create recent order: %v", err)
|
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)
|
t.Fatalf("lock recent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if expired.Status != mdb.StatusExpired {
|
if expired.Status != mdb.StatusExpired {
|
||||||
t.Fatalf("expired order status = %d, want %d", 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 {
|
if err != nil {
|
||||||
t.Fatalf("expired order lock lookup: %v", err)
|
t.Fatalf("expired order lock lookup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if recent.Status != mdb.StatusWaitPay {
|
if recent.Status != mdb.StatusWaitPay {
|
||||||
t.Fatalf("recent order status = %d, want %d", 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 {
|
if err != nil {
|
||||||
t.Fatalf("recent order lock lookup: %v", err)
|
t.Fatalf("recent order lock lookup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,7 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback",
|
BlockTransactionId: "block_callback",
|
||||||
CallbackNum: 1,
|
CallbackNum: 1,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
|
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
t.Fatalf("create callback order: %v", err)
|
t.Fatalf("create callback order: %v", err)
|
||||||
@@ -216,6 +221,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_restart",
|
BlockTransactionId: "block_callback_restart",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
|
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
t.Fatalf("create callback order: %v", err)
|
t.Fatalf("create callback order: %v", err)
|
||||||
@@ -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) {
|
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
|
||||||
t.Helper()
|
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/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/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,806 @@
|
|||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
-19
@@ -2,13 +2,22 @@ package route
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/controller/admin"
|
||||||
"github.com/assimon/luuu/controller/comm"
|
"github.com/assimon/luuu/controller/comm"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/assimon/luuu/middleware"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/constant"
|
||||||
|
"github.com/assimon/luuu/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoute 路由注册
|
// RegisterRoute 路由注册
|
||||||
@@ -20,41 +29,238 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
payRoute := e.Group("/pay")
|
payRoute := e.Group("/pay")
|
||||||
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
||||||
|
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||||
|
|
||||||
// payment routes
|
// payment routes
|
||||||
paymentRoute := e.Group("/payments")
|
paymentRoute := e.Group("/payments")
|
||||||
|
|
||||||
// for epusdt
|
// gmpay v1 routes
|
||||||
epusdtV1 := paymentRoute.Group("/epusdt/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
|
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||||
// add default token and currency for old plugin
|
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{})
|
// epay v1 routes
|
||||||
if err := ctx.Bind(&body); err != nil {
|
//
|
||||||
return comm.Ctrl.FailJson(ctx, err)
|
// 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
|
||||||
|
}
|
||||||
|
params[k] = v[0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, ok := body["token"]; !ok {
|
|
||||||
body["token"] = "usdt"
|
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 _, ok := body["currency"]; !ok {
|
if err == nil {
|
||||||
body["currency"] = "cny"
|
copyParams(formParams)
|
||||||
}
|
}
|
||||||
if _, ok := body["network"]; !ok {
|
|
||||||
body["network"] = "TRON"
|
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("request_body", body)
|
||||||
|
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
|
||||||
|
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(body)
|
jsonBytes, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, err)
|
return comm.Ctrl.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
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)
|
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
|
||||||
}, middleware.CheckApiSign())
|
})
|
||||||
|
|
||||||
// gmpay v1 routes
|
|
||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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,687 @@
|
|||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
"github.com/assimon/luuu/util/sign"
|
||||||
|
"github.com/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("forced_usdt_rate", 7.0)
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+476
-201
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -13,8 +14,8 @@
|
|||||||
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
||||||
--font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
--font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -23,249 +24,523 @@
|
|||||||
--color-popover-foreground:var(--popover-foreground);
|
--color-popover-foreground:var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground:var(--primary-foreground);
|
--color-primary-foreground:var(--primary-foreground);
|
||||||
--color-primary-hover: var(--primary-hover);
|
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground:var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-border-hover: var(--border-hover);
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
--color-warning: var(--warning);
|
--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);
|
--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>
|
</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/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/qrcodejs@latest/qrcode.min.js"></script>
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
||||||
</head>
|
</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 -->
|
<!-- 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">
|
<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" />
|
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-sm shadow-sm" />
|
||||||
<span class="text-[17px] font-semibold tracking-[-0.3px] text-card-foreground">GM Pay</span>
|
<span class="text-lg font-semibold tracking-tight text-card-foreground">GM Pay</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Language pill -->
|
<!-- Language pill -->
|
||||||
<div class="select-wrap relative select-none" id="dd-lang">
|
<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')">
|
<div class="select-trigger chip flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
||||||
<span class="text-[13px] font-medium" id="lang-label">EN</span>
|
<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>
|
<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>
|
||||||
<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-menu menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-40" 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 menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English
|
||||||
<div class="select-option ios-menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
<div class="select-option menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
<div class="select-option menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
<div class="select-option menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Theme toggle -->
|
<!-- 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="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>
|
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<!-- Ambient background blobs -->
|
||||||
</div>
|
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
|
<div
|
||||||
<!-- Hero amount card -->
|
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 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>
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<tbody>
|
|
||||||
<tr id="display-fiat"></tr>
|
|
||||||
<tr id="display-order-id"></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 -->
|
|
||||||
<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>
|
|
||||||
<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"/>
|
|
||||||
</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">
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<span class="ios-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 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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 class="status-face status-face-back">
|
</div>
|
||||||
<div id="state-panel" class="w-full state-panel-shell">
|
<div
|
||||||
<div class="glass-card w-full flex items-center justify-center min-h-[300px] py-10 px-6">
|
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)]">
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 flex flex-col items-center justify-center w-full max-w-sm px-5 relative">
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div id="step-progress" class="flex items-center gap-2 w-full mb-4 select-none">
|
||||||
|
<div id="step-bar-1" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||||
|
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||||
|
<div id="step-fill-1" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||||
|
</div>
|
||||||
|
<div id="step-bar-2" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||||
|
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||||
|
<div id="step-fill-2" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order info -->
|
||||||
|
<aside id="order-info" class="card w-full px-6 pt-6 pb-5 mb-4">
|
||||||
|
<p class="text-sm font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
||||||
|
<div class="flex items-end gap-3 justify-between">
|
||||||
|
<span class="text-4xl font-bold leading-none tracking-tighter text-card-foreground font-nunito"
|
||||||
|
id="display-amount">--</span>
|
||||||
|
<button class="icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
||||||
|
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-border/50">
|
||||||
|
<table class="w-full text-sm text-muted-foreground border-separate border-spacing-y-1">
|
||||||
|
<tbody>
|
||||||
|
<tr id="display-fiat"></tr>
|
||||||
|
<tr id="display-order-id"></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Panel viewport: clips horizontal slide -->
|
||||||
|
<div class="relative w-full" id="panel-viewport">
|
||||||
|
|
||||||
|
<!-- Step 1: Select coin + network -->
|
||||||
|
<section id="step1-panel" class="w-full mb-4 pb-4">
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<!-- Network -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||||
|
data-i18n="network_label">Network</p>
|
||||||
|
<div class="select-wrap relative select-none" id="dd-network">
|
||||||
|
<div
|
||||||
|
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||||
|
onclick="toggleSelect('dd-network')">
|
||||||
|
<span id="network-label" class="text-sm font-semibold text-card-foreground leading-none"></span>
|
||||||
|
<i data-lucide="chevron-down"
|
||||||
|
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||||
|
height="13" stroke-width="2.5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-network-menu">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Currency -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||||
|
data-i18n="currency_label">Currency</p>
|
||||||
|
<div class="select-wrap relative select-none" id="dd-token">
|
||||||
|
<div
|
||||||
|
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||||
|
onclick="toggleSelect('dd-token')">
|
||||||
|
<span id="token-label" class="text-sm font-semibold text-card-foreground"></span>
|
||||||
|
<i data-lucide="chevron-down"
|
||||||
|
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||||
|
height="13" stroke-width="2.5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-token-menu">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-confirm-step1" class="btn-primary w-full" onclick="confirmStep1()"
|
||||||
|
data-i18n="confirm">Confirm</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Payment panel (Step 2) -->
|
||||||
|
<section id="payment-panel" class="w-full pb-4" style="display:none">
|
||||||
|
<!-- QR + address card -->
|
||||||
|
<div class="card w-full px-5 pt-5 pb-0 mb-4 overflow-hidden">
|
||||||
|
<!-- Scan title + timer -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-sm font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay
|
||||||
|
</p>
|
||||||
|
<div id="timer-row" class="relative w-10 h-10 shrink-0">
|
||||||
|
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
|
||||||
|
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||||
|
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||||
|
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="var(--success)"
|
||||||
|
stroke-width="3" stroke-linecap="round" stroke-dasharray="125.66" stroke-dashoffset="0" />
|
||||||
|
</svg>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Countdown -->
|
||||||
|
<p class="text-3xl font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
||||||
|
<!-- QR code -->
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<div class="qr-wrapper">
|
||||||
|
<div id="qrcode" class="w-44 h-44"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Address row (edge-to-edge inside card) -->
|
||||||
|
<div class="h-px bg-border/50 -mx-5"></div>
|
||||||
|
<div id="copy-addr-box" class="row cursor-pointer select-none -mx-5">
|
||||||
|
<div class="flex-1 min-w-0 pr-3">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-0.5"
|
||||||
|
data-i18n="payment_address">Payment address</p>
|
||||||
|
<p class="text-sm font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
||||||
|
</div>
|
||||||
|
<span class="icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
||||||
|
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="countdown-inline" class="sr-only"></span>
|
||||||
|
<!-- Connect Wallet (above I have transferred) -->
|
||||||
|
<button class="btn-secondary w-full mb-3" id="btn-connect-wallet" onclick="connectWallet()">
|
||||||
|
<i data-lucide="wallet" width="16" height="16" stroke-width="1.8" class="mr-1.5 shrink-0"></i>
|
||||||
|
<span data-i18n="connect_wallet">Connect Wallet to Pay</span>
|
||||||
|
</button>
|
||||||
|
<!-- I have transferred -->
|
||||||
|
<button class="btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
||||||
|
<span data-i18n="i_have_transferred">I have transferred</span>
|
||||||
|
</button>
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
|
||||||
|
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13"
|
||||||
|
stroke-width="2.2"></i>
|
||||||
|
<span class="text-xs text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking
|
||||||
|
blockchain</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Success -->
|
||||||
|
<section id="screen-success"
|
||||||
|
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||||
|
aria-live="polite" style="display:none">
|
||||||
|
<div class="state-icon bg-success/15 mb-6">
|
||||||
|
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="var(--success)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
||||||
|
<p class="text-sm text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
||||||
|
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22"
|
||||||
|
stroke-width="2"></i>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Expired -->
|
||||||
|
<section id="screen-expired" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||||
|
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||||
|
<div class="state-icon bg-destructive/12 mb-6">
|
||||||
|
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="var(--destructive)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="expired_sub">Please initiate a new payment</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary w-full" onclick="goBack()" data-i18n="back">Back</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Timeout -->
|
||||||
|
<section id="screen-timeout" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||||
|
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||||
|
<div class="state-icon bg-warning/12 mb-6">
|
||||||
|
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="var(--warning)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="btn-secondary flex-1" onclick="goBack()" data-i18n="back">Back</button>
|
||||||
|
<button class="btn-primary flex-1" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Not Found -->
|
||||||
|
<section id="screen-not-found"
|
||||||
|
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||||
|
aria-live="polite" style="display:none">
|
||||||
|
<div class="state-icon bg-muted-foreground/12 mb-6">
|
||||||
|
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="var(--muted-foreground)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already
|
||||||
|
expired
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div><!-- /panel viewport -->
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<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">
|
<span class="flex items-center gap-1.5">
|
||||||
Powered by
|
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">
|
<a href="https://www.gmwallet.app" target="_blank" rel="noopener"
|
||||||
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-[3px]" />
|
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
|
GM Wallet
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="opacity-30">|</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>
|
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
<!-- Toast -->
|
||||||
|
<div id="toast">Copied</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Order data injected by server (Go template) -->
|
||||||
<div id="toast">Copied</div>
|
<script>
|
||||||
|
var ORDER = {
|
||||||
<!-- Order data injected by server (Go template) -->
|
tradeId: "{{.TradeId}}",
|
||||||
<script>
|
amount: "{{.Amount}}",
|
||||||
var ORDER = {
|
actualAmount: "{{.ActualAmount}}",
|
||||||
tradeId: "{{.TradeId}}",
|
token: "{{.Token}}",
|
||||||
amount: "{{.Amount}}",
|
currency: "{{.Currency}}",
|
||||||
actualAmount: "{{.ActualAmount}}",
|
network: "{{.Network}}",
|
||||||
token: "{{.Token}}",
|
receiveAddress: "{{.ReceiveAddress}}",
|
||||||
currency: "{{.Currency}}",
|
expirationTime: "{{.ExpirationTime}}",
|
||||||
network: "{{.Network}}",
|
redirectUrl: "{{.RedirectUrl}}",
|
||||||
receiveAddress: "{{.ReceiveAddress}}",
|
createdAt: "{{.CreatedAt}}",
|
||||||
expirationTime: "{{.ExpirationTime}}",
|
is_selected: "{{.IsSelected}}"
|
||||||
redirectUrl: "{{.RedirectUrl}}",
|
};
|
||||||
createdAt: "{{.CreatedAt}}"
|
</script>
|
||||||
};
|
<script defer src="/static/payment.js"></script>
|
||||||
</script>
|
|
||||||
<script defer src="/static/payment.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+709
-272
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -7,14 +7,35 @@ import (
|
|||||||
|
|
||||||
func Start() {
|
func Start() {
|
||||||
log.Sugar.Info("[task] Starting task scheduler...")
|
log.Sugar.Info("[task] Starting task scheduler...")
|
||||||
|
// The ETH listener short-circuits internally when chain is disabled
|
||||||
|
// or no tokens are configured, so always launch the goroutine.
|
||||||
|
go StartEthereumWebSocketListener()
|
||||||
|
go StartBscWebSocketListener()
|
||||||
|
go StartPolygonWebSocketListener()
|
||||||
|
go StartPlasmaWebSocketListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
// trc20钱包监听
|
// TRC20 polling
|
||||||
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
|
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
||||||
|
// Solana polling
|
||||||
|
_, err = c.AddJob("@every 5s", ListenSolJob{})
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
|
||||||
|
// RPC node health checks
|
||||||
|
_, err = c.AddJob("@every 30s", RpcHealthJob{})
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
|
||||||
c.Start()
|
c.Start()
|
||||||
log.Sugar.Info("[task] Task scheduler started")
|
log.Sugar.Info("[task] Task scheduler started")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/service"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bscDefaultWsURL = "wss://bsc.drpc.org"
|
||||||
|
|
||||||
|
type bscRecipientSnapshot struct {
|
||||||
|
addrs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bscWatchedRecipients atomic.Pointer[bscRecipientSnapshot]
|
||||||
|
|
||||||
|
// StartBscWebSocketListener drives the BSC listener. Checks chain
|
||||||
|
// enable status and reloads contract addresses from chain_tokens every
|
||||||
|
// 10s so admin-side toggles take effect without a restart.
|
||||||
|
func StartBscWebSocketListener() {
|
||||||
|
for {
|
||||||
|
if data.IsChainEnabled(mdb.NetworkBsc) {
|
||||||
|
if contracts := loadChainTokenContracts(mdb.NetworkBsc, "[BSC-WS]"); len(contracts) > 0 {
|
||||||
|
runBscListener(contracts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBscListener(contracts []common.Address) {
|
||||||
|
ctx, cancel := chainEnabledWatchdog(mdb.NetworkBsc, "[BSC-WS]", chainTokenFingerprint(mdb.NetworkBsc))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[BSC-WS] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storeBscRecipientsFromWallets(wallets)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[BSC-WS] refresh wallet addresses: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storeBscRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL)
|
||||||
|
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: contracts,
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := vLog.Topics[0].String()
|
||||||
|
if event != transferEventHash.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
|
||||||
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
|
||||||
|
if !isWatchedBscRecipient(toAddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessEvmERC20Transfer(mdb.NetworkBsc, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeBscRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
a := strings.TrimSpace(w.Address)
|
||||||
|
if !common.IsHexAddress(a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
||||||
|
}
|
||||||
|
bscWatchedRecipients.Store(&bscRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedBscRecipient(to common.Address) bool {
|
||||||
|
snap := bscWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chainEnabledWatchdog returns a cancellable context whose cancel() is
|
||||||
|
// invoked when either:
|
||||||
|
// 1. IsChainEnabled(network) returns false — admin disabled the chain
|
||||||
|
// 2. The enabled-token fingerprint changes — admin added/removed/
|
||||||
|
// toggled a chain_tokens row for this network
|
||||||
|
//
|
||||||
|
// Both cases need the listener to exit so the outer loop can reconnect
|
||||||
|
// with the fresh token set (EVM WebSocket subscriptions are fixed at
|
||||||
|
// connect time; to pick up a new contract we must re-subscribe).
|
||||||
|
//
|
||||||
|
// initialFingerprint is the fingerprint computed BEFORE connecting; the
|
||||||
|
// watchdog compares every 10s tick against this baseline. Caller must
|
||||||
|
// defer the returned cancel func to release the goroutine.
|
||||||
|
func chainEnabledWatchdog(network, logPrefix, initialFingerprint string) (context.Context, context.CancelFunc) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
log.Sugar.Infof("%s chain disabled, stopping listener", logPrefix)
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fp := chainTokenFingerprint(network); fp != initialFingerprint {
|
||||||
|
log.Sugar.Infof("%s chain_tokens changed (was %q → now %q), reconnecting", logPrefix, initialFingerprint, fp)
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ctx, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// chainTokenFingerprint returns a stable string representing the
|
||||||
|
// enabled-token set for a network. Used by chainEnabledWatchdog to
|
||||||
|
// detect admin changes between polls.
|
||||||
|
func chainTokenFingerprint(network string) string {
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
parts = append(parts, strings.ToLower(strings.TrimSpace(t.ContractAddress))+"|"+strings.ToUpper(strings.TrimSpace(t.Symbol)))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadChainTokenContracts reads enabled tokens for a network and returns
|
||||||
|
// their contract addresses as ethereum-go common.Address values. Rows
|
||||||
|
// with blank contract_address (e.g. Solana native SOL marker) are
|
||||||
|
// skipped. Callers use the length to decide whether to connect or idle.
|
||||||
|
func loadChainTokenContracts(network, logPrefix string) []common.Address {
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("%s load chain_tokens err=%v", logPrefix, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
addrs := make([]common.Address, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
c := strings.TrimSpace(t.ContractAddress)
|
||||||
|
if c == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs = append(addrs, common.HexToAddress(c))
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
|
||||||
|
// given network, falling back to the provided default (usually the
|
||||||
|
// public node URL) when no row is configured.
|
||||||
|
func resolveChainWsURL(network, fallback string) string {
|
||||||
|
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
||||||
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
|
return node.Url
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/config"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/service"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
|
||||||
|
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||||
|
|
||||||
|
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
|
||||||
|
|
||||||
|
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
|
||||||
|
// legacy .env value (if set) before the shared default — keeps existing
|
||||||
|
// deployments working until the admin adds an rpc_nodes row.
|
||||||
|
func resolveEthereumWsURL() string {
|
||||||
|
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
|
||||||
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
|
return node.Url
|
||||||
|
}
|
||||||
|
if u := config.GetEthereumWsUrl(); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return ethereumDefaultWsURL
|
||||||
|
}
|
||||||
|
|
||||||
|
type ethRecipientSnapshot struct {
|
||||||
|
addrs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
|
||||||
|
|
||||||
|
func StartEthereumWebSocketListener() {
|
||||||
|
// Wait until the chain is enabled AND at least one token contract
|
||||||
|
// is configured. Polls every 10s so admin-side toggles kick in
|
||||||
|
// without a restart. Once conditions are met we proceed to connect;
|
||||||
|
// if the websocket later drops we exit the loop and rely on the
|
||||||
|
// process-level restart to reconnect (same as before this refactor).
|
||||||
|
for {
|
||||||
|
if data.IsChainEnabled(mdb.NetworkEthereum) {
|
||||||
|
if contracts := loadChainTokenContracts(mdb.NetworkEthereum, "[ETH-WS]"); len(contracts) > 0 {
|
||||||
|
runEthereumListener(contracts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEthereumListener(contracts []common.Address) {
|
||||||
|
ctx, cancel := chainEnabledWatchdog(mdb.NetworkEthereum, "[ETH-WS]", chainTokenFingerprint(mdb.NetworkEthereum))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[ETH-WS] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
StoreEthRecipientsFromWallets(wallets)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[ETH-WS] refresh wallet addresses: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
StoreEthRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsURL := resolveEthereumWsURL()
|
||||||
|
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: contracts,
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener(ctx, "[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event := vLog.Topics[0].String()
|
||||||
|
if event != transferEventHash.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
if !isWatchedEthRecipient(toAddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessEvmERC20Transfer(mdb.NetworkEthereum, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
a := strings.TrimSpace(w.Address)
|
||||||
|
if !common.IsHexAddress(a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
||||||
|
}
|
||||||
|
ethWatchedRecipients.Store(ðRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedEthRecipient(to common.Address) bool {
|
||||||
|
snap := ethWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs,
|
||||||
|
// and dispatches each log to handleLog. It retries on transient errors
|
||||||
|
// with exponential backoff. The ctx lets the caller trigger a clean
|
||||||
|
// exit — e.g. when admin disables the chain, the caller cancels the
|
||||||
|
// context and the function returns instead of reconnecting forever.
|
||||||
|
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
|
const (
|
||||||
|
minBackoff = 2 * time.Second
|
||||||
|
maxBackoff = 60 * time.Second
|
||||||
|
rejoinWait = 3 * time.Second
|
||||||
|
)
|
||||||
|
failWait := minBackoff
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ethclient.Dial(wsURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
||||||
|
if !sleepOrDone(ctx, failWait) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failWait = nextBackoff(failWait, maxBackoff)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logsCh := make(chan types.Log)
|
||||||
|
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
||||||
|
if !sleepOrDone(ctx, failWait) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failWait = nextBackoff(failWait, maxBackoff)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failWait = minBackoff
|
||||||
|
|
||||||
|
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix)
|
||||||
|
|
||||||
|
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sleepOrDone(ctx, rejoinWait) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
|
defer func() {
|
||||||
|
sub.Unsubscribe()
|
||||||
|
client.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
||||||
|
return
|
||||||
|
case err := <-sub.Err():
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
||||||
|
} else {
|
||||||
|
log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case vLog, ok := <-logsCh:
|
||||||
|
if !ok {
|
||||||
|
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleLog(client, vLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepOrDone waits for d or for ctx cancellation, whichever comes
|
||||||
|
// first. Returns true if the sleep completed normally, false if ctx
|
||||||
|
// was cancelled (caller should exit).
|
||||||
|
func sleepOrDone(ctx context.Context, d time.Duration) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-t.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextBackoff(cur, max time.Duration) time.Duration {
|
||||||
|
n := cur * 2
|
||||||
|
if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/service"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
|
||||||
|
|
||||||
|
type plasmaRecipientSnapshot struct {
|
||||||
|
addrs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var plasmaWatchedRecipients atomic.Pointer[plasmaRecipientSnapshot]
|
||||||
|
|
||||||
|
// StartPlasmaWebSocketListener drives the Plasma listener with dynamic
|
||||||
|
// chain/token config reload every 10s.
|
||||||
|
func StartPlasmaWebSocketListener() {
|
||||||
|
for {
|
||||||
|
if data.IsChainEnabled(mdb.NetworkPlasma) {
|
||||||
|
if contracts := loadChainTokenContracts(mdb.NetworkPlasma, "[PLASMA-WS]"); len(contracts) > 0 {
|
||||||
|
runPlasmaListener(contracts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlasmaListener(contracts []common.Address) {
|
||||||
|
ctx, cancel := chainEnabledWatchdog(mdb.NetworkPlasma, "[PLASMA-WS]", chainTokenFingerprint(mdb.NetworkPlasma))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[PLASMA-WS] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storePlasmaRecipientsFromWallets(wallets)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[PLASMA-WS] refresh wallet addresses: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storePlasmaRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL)
|
||||||
|
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: contracts,
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := vLog.Topics[0].String()
|
||||||
|
if event != transferEventHash.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
|
||||||
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
|
||||||
|
if !isWatchedPlasmaRecipient(toAddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessEvmERC20Transfer(mdb.NetworkPlasma, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func storePlasmaRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
a := strings.TrimSpace(w.Address)
|
||||||
|
if !common.IsHexAddress(a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
||||||
|
}
|
||||||
|
plasmaWatchedRecipients.Store(&plasmaRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedPlasmaRecipient(to common.Address) bool {
|
||||||
|
snap := plasmaWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/service"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
|
||||||
|
|
||||||
|
type polygonRecipientSnapshot struct {
|
||||||
|
addrs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var polygonWatchedRecipients atomic.Pointer[polygonRecipientSnapshot]
|
||||||
|
|
||||||
|
// StartPolygonWebSocketListener drives the Polygon listener with
|
||||||
|
// dynamic chain/token config reload every 10s.
|
||||||
|
func StartPolygonWebSocketListener() {
|
||||||
|
for {
|
||||||
|
if data.IsChainEnabled(mdb.NetworkPolygon) {
|
||||||
|
if contracts := loadChainTokenContracts(mdb.NetworkPolygon, "[POLYGON-WS]"); len(contracts) > 0 {
|
||||||
|
runPolygonListener(contracts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPolygonListener(contracts []common.Address) {
|
||||||
|
ctx, cancel := chainEnabledWatchdog(mdb.NetworkPolygon, "[POLYGON-WS]", chainTokenFingerprint(mdb.NetworkPolygon))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[POLYGON-WS] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storePolygonRecipientsFromWallets(wallets)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[POLYGON-WS] refresh wallet addresses: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storePolygonRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL)
|
||||||
|
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: contracts,
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := vLog.Topics[0].String()
|
||||||
|
if event != transferEventHash.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
|
||||||
|
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
||||||
|
|
||||||
|
if !isWatchedPolygonRecipient(toAddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessEvmERC20Transfer(mdb.NetworkPolygon, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func storePolygonRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
a := strings.TrimSpace(w.Address)
|
||||||
|
if !common.IsHexAddress(a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
||||||
|
}
|
||||||
|
polygonWatchedRecipients.Store(&polygonRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedPolygonRecipient(to common.Address) bool {
|
||||||
|
snap := polygonWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/model/service"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListenSolJob struct{}
|
||||||
|
|
||||||
|
var gListenSolJobLock sync.Mutex
|
||||||
|
|
||||||
|
func (r ListenSolJob) Run() {
|
||||||
|
gListenSolJobLock.Lock()
|
||||||
|
defer gListenSolJobLock.Unlock()
|
||||||
|
log.Sugar.Debug("[ListenSolJob] Job triggered")
|
||||||
|
if !data.IsChainEnabled(mdb.NetworkSolana) {
|
||||||
|
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(walletAddress) <= 0 {
|
||||||
|
log.Sugar.Debug("[ListenSolJob] No available wallet addresses")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[ListenSolJob] Found %d wallet addresses to monitor", len(walletAddress))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, address := range walletAddress {
|
||||||
|
log.Sugar.Infof("[ListenSolJob] Listening to address: %s", address.Address)
|
||||||
|
wg.Add(1)
|
||||||
|
go service.SolCallBack(address.Address, &wg)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
log.Sugar.Debug("[ListenSolJob] Job completed")
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/assimon/luuu/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenTrc20Job struct {
|
type ListenTrc20Job struct{}
|
||||||
}
|
|
||||||
|
|
||||||
var gListenTrc20JobLock sync.Mutex
|
var gListenTrc20JobLock sync.Mutex
|
||||||
|
|
||||||
@@ -17,7 +17,11 @@ func (r ListenTrc20Job) Run() {
|
|||||||
gListenTrc20JobLock.Lock()
|
gListenTrc20JobLock.Lock()
|
||||||
defer gListenTrc20JobLock.Unlock()
|
defer gListenTrc20JobLock.Unlock()
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
||||||
walletAddress, err := data.GetAvailableWalletAddress()
|
if !data.IsChainEnabled(mdb.NetworkTron) {
|
||||||
|
log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rpcProbeTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// RpcHealthJob periodically probes every enabled rpc_nodes row and
|
||||||
|
// writes status/last_latency_ms. Results drive SelectRpcNode weighted
|
||||||
|
// picking at runtime.
|
||||||
|
type RpcHealthJob struct{}
|
||||||
|
|
||||||
|
var gRpcHealthJobLock sync.Mutex
|
||||||
|
|
||||||
|
func (r RpcHealthJob) Run() {
|
||||||
|
gRpcHealthJobLock.Lock()
|
||||||
|
defer gRpcHealthJobLock.Unlock()
|
||||||
|
|
||||||
|
nodes, err := data.ListRpcNodes("")
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range nodes {
|
||||||
|
if !nodes[i].Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n mdb.RpcNode) {
|
||||||
|
defer wg.Done()
|
||||||
|
status, latency := ProbeNode(n.Url)
|
||||||
|
if err := data.UpdateRpcNodeHealth(n.ID, status, latency); err != nil {
|
||||||
|
log.Sugar.Warnf("[rpc-health] update node %d err=%v", n.ID, err)
|
||||||
|
}
|
||||||
|
}(nodes[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeNode does a TCP dial to the RPC URL and returns (status, latencyMs).
|
||||||
|
// Exported so the admin controller can reuse it without duplicating logic.
|
||||||
|
func ProbeNode(rawURL string) (string, int) {
|
||||||
|
addr, err := ParseAddress(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return mdb.RpcNodeStatusDown, -1
|
||||||
|
}
|
||||||
|
dur, err := MeasureTCPDial(addr, rpcProbeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return mdb.RpcNodeStatusDown, -1
|
||||||
|
}
|
||||||
|
return mdb.RpcNodeStatusOk, int(dur.Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAddress(raw string) (string, error) {
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
raw = "tcp://" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := u.Hostname()
|
||||||
|
port := u.Port()
|
||||||
|
if port == "" {
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https", "wss":
|
||||||
|
port = "443"
|
||||||
|
default:
|
||||||
|
port = "80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host + ":" + port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MeasureTCPDial(addr string, timeout time.Duration) (time.Duration, error) {
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --------------- ParseAddress ---------------
|
||||||
|
|
||||||
|
func TestParseAddress_HttpWithPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("http://api.trongrid.io:8090")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "api.trongrid.io:8090" {
|
||||||
|
t.Fatalf("want api.trongrid.io:8090, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_HttpDefaultPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("http://api.trongrid.io")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "api.trongrid.io:80" {
|
||||||
|
t.Fatalf("want api.trongrid.io:80, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_HttpsDefaultPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("https://api.trongrid.io")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "api.trongrid.io:443" {
|
||||||
|
t.Fatalf("want api.trongrid.io:443, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_WssDefaultPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("wss://bsc-ws-node.nariox.org")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "bsc-ws-node.nariox.org:443" {
|
||||||
|
t.Fatalf("want bsc-ws-node.nariox.org:443, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_WsDefaultPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("ws://localhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "localhost:80" {
|
||||||
|
t.Fatalf("want localhost:80, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_WsWithPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("ws://localhost:9650")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "localhost:9650" {
|
||||||
|
t.Fatalf("want localhost:9650, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_BareHostPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("10.0.0.1:8545")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "10.0.0.1:8545" {
|
||||||
|
t.Fatalf("want 10.0.0.1:8545, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_BareHostNoPort(t *testing.T) {
|
||||||
|
got, err := ParseAddress("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "example.com:80" {
|
||||||
|
t.Fatalf("want example.com:80, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddress_WithPath(t *testing.T) {
|
||||||
|
got, err := ParseAddress("https://mainnet.infura.io/v3/KEY123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "mainnet.infura.io:443" {
|
||||||
|
t.Fatalf("want mainnet.infura.io:443, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- MeasureTCPDial ---------------
|
||||||
|
|
||||||
|
func TestMeasureTCPDial_Success(t *testing.T) {
|
||||||
|
// start a local TCP listener
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
dur, err := MeasureTCPDial(ln.Addr().String(), 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial should succeed: %v", err)
|
||||||
|
}
|
||||||
|
if dur <= 0 {
|
||||||
|
t.Fatalf("duration should be positive, got %v", dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeasureTCPDial_Refused(t *testing.T) {
|
||||||
|
// pick a port that is almost certainly not listening
|
||||||
|
_, err := MeasureTCPDial("127.0.0.1:1", 500*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("dial to closed port should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeasureTCPDial_RespectsTimeout(t *testing.T) {
|
||||||
|
// Start a listener but never accept — the dial handshake will hang
|
||||||
|
// until the timeout fires.
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
// Set backlog to 0 by not calling Accept and filling the queue.
|
||||||
|
// Connect once to fill the backlog, then the next dial should stall.
|
||||||
|
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("could not saturate backlog, skipping")
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Use a very short timeout to verify it doesn't hang forever.
|
||||||
|
_, dialErr := MeasureTCPDial(ln.Addr().String(), 100*time.Millisecond)
|
||||||
|
// This may succeed (kernel allows queued connections) or timeout — both
|
||||||
|
// are acceptable. We just verify it returns within a sane window.
|
||||||
|
_ = dialErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- ProbeNode ---------------
|
||||||
|
|
||||||
|
func TestProbeNode_Reachable(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
url := "http://127.0.0.1:" + strconv.Itoa(port)
|
||||||
|
|
||||||
|
status, latency := ProbeNode(url)
|
||||||
|
if status != mdb.RpcNodeStatusOk {
|
||||||
|
t.Fatalf("want ok, got %s", status)
|
||||||
|
}
|
||||||
|
if latency < 0 {
|
||||||
|
t.Fatalf("latency should be >= 0, got %d", latency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeNode_Unreachable(t *testing.T) {
|
||||||
|
status, latency := ProbeNode("http://127.0.0.1:1")
|
||||||
|
if status != mdb.RpcNodeStatusDown {
|
||||||
|
t.Fatalf("want down, got %s", status)
|
||||||
|
}
|
||||||
|
if latency != -1 {
|
||||||
|
t.Fatalf("want -1, got %d", latency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeNode_InvalidURL(t *testing.T) {
|
||||||
|
status, latency := ProbeNode("://bad")
|
||||||
|
if status != mdb.RpcNodeStatusDown {
|
||||||
|
t.Fatalf("want down, got %s", status)
|
||||||
|
}
|
||||||
|
if latency != -1 {
|
||||||
|
t.Fatalf("want -1, got %d", latency)
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-14
@@ -2,6 +2,8 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,12 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReplayAddWallet = "请发给我一个合法的钱包地址"
|
ReplaySelectNetwork = "请选择要添加的钱包网络"
|
||||||
|
ReplayAddWallet = "请发送 %s 网络收款地址"
|
||||||
pendingWalletAddressTTL = 5 * time.Minute
|
pendingWalletAddressTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type pendingWalletAddressState struct {
|
type pendingWalletAddressState struct {
|
||||||
RequestedAt time.Time
|
RequestedAt time.Time
|
||||||
|
Network string
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingWalletAddressUsers sync.Map
|
var pendingWalletAddressUsers sync.Map
|
||||||
@@ -35,7 +39,7 @@ func OnTextMessageHandle(c tb.Context) error {
|
|||||||
senderID = sender.ID
|
senderID = sender.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet
|
isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ")
|
||||||
isPendingFlow := isWalletAddressPending(senderID)
|
isPendingFlow := isWalletAddressPending(senderID)
|
||||||
if !isReplyFlow && !isPendingFlow {
|
if !isReplyFlow && !isPendingFlow {
|
||||||
return nil
|
return nil
|
||||||
@@ -45,19 +49,26 @@ func OnTextMessageHandle(c tb.Context) error {
|
|||||||
defer bots.Delete(msg.ReplyTo)
|
defer bots.Delete(msg.ReplyTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgText := msg.Text
|
msgText := strings.TrimSpace(msg.Text)
|
||||||
if !isValidTronAddress(msgText) {
|
state, _ := getPendingWalletAddressState(senderID)
|
||||||
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 TRON 地址", msgText))
|
if state.Network == "" {
|
||||||
|
_ = c.Send("请先选择网络,再发送地址。")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := data.AddWalletAddress(msgText)
|
var err error
|
||||||
|
if !isValidAddressByNetwork(state.Network, msgText) {
|
||||||
|
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 %s 地址", msgText, strings.ToUpper(state.Network)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
storeAddress := normalizeWalletAddressByNetwork(state.Network, msgText)
|
||||||
|
_, err = data.AddWalletAddressWithNetwork(state.Network, storeAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send(err.Error())
|
return c.Send(err.Error())
|
||||||
}
|
}
|
||||||
pendingWalletAddressUsers.Delete(senderID)
|
pendingWalletAddressUsers.Delete(senderID)
|
||||||
|
|
||||||
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText))
|
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功(%s)", storeAddress, strings.ToUpper(state.Network)))
|
||||||
return WalletList(c)
|
return WalletList(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +84,14 @@ func WalletList(c tb.Context) error {
|
|||||||
if wallet.Status == mdb.TokenStatusDisable {
|
if wallet.Status == mdb.TokenStatusDisable {
|
||||||
status = "已禁用🚫"
|
status = "已禁用🚫"
|
||||||
}
|
}
|
||||||
|
net := wallet.Network
|
||||||
|
if net == "" {
|
||||||
|
net = mdb.NetworkTron
|
||||||
|
}
|
||||||
|
|
||||||
btnInfo := tb.InlineButton{
|
btnInfo := tb.InlineButton{
|
||||||
Unique: wallet.Address,
|
Unique: wallet.Address,
|
||||||
Text: fmt.Sprintf("%s [%s]", wallet.Address, status),
|
Text: fmt.Sprintf("[%s] %s [%s]", net, wallet.Address, status),
|
||||||
Data: strutil.MustString(wallet.ID),
|
Data: strutil.MustString(wallet.ID),
|
||||||
}
|
}
|
||||||
bots.Handle(&btnInfo, WalletInfo)
|
bots.Handle(&btnInfo, WalletInfo)
|
||||||
@@ -85,20 +100,53 @@ func WalletList(c tb.Context) error {
|
|||||||
|
|
||||||
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
|
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
|
||||||
bots.Handle(&addBtn, func(c tb.Context) error {
|
bots.Handle(&addBtn, func(c tb.Context) error {
|
||||||
if sender := c.Sender(); sender != nil {
|
chains, err := getEnabledSupportedNetworks()
|
||||||
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
|
if err != nil {
|
||||||
|
return c.Send("读取支持链失败:" + err.Error())
|
||||||
}
|
}
|
||||||
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
|
if len(chains) == 0 {
|
||||||
ForceReply: true,
|
return c.Send("当前没有可用链,请先在后台配置 supported-assets。")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([][]tb.InlineButton, 0, len(chains))
|
||||||
|
for _, network := range chains {
|
||||||
|
btn := tb.InlineButton{
|
||||||
|
Text: strings.ToUpper(network),
|
||||||
|
Unique: "SelectWalletNetwork",
|
||||||
|
Data: network,
|
||||||
|
}
|
||||||
|
bots.Handle(&btn, SelectWalletNetwork)
|
||||||
|
rows = append(rows, []tb.InlineButton{btn})
|
||||||
|
}
|
||||||
|
return c.EditOrSend(ReplaySelectNetwork, &tb.ReplyMarkup{
|
||||||
|
InlineKeyboard: rows,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
btnList = append(btnList, []tb.InlineButton{addBtn})
|
refreshBtn := tb.InlineButton{Text: "刷新列表", Unique: "WalletRefresh"}
|
||||||
|
bots.Handle(&refreshBtn, WalletList)
|
||||||
|
btnList = append(btnList, []tb.InlineButton{addBtn, refreshBtn})
|
||||||
|
|
||||||
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
|
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
|
||||||
InlineKeyboard: btnList,
|
InlineKeyboard: btnList,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SelectWalletNetwork(c tb.Context) error {
|
||||||
|
network := strings.ToLower(strings.TrimSpace(c.Data()))
|
||||||
|
if network == "" {
|
||||||
|
return c.Send("请选择有效网络")
|
||||||
|
}
|
||||||
|
if sender := c.Sender(); sender != nil {
|
||||||
|
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{
|
||||||
|
RequestedAt: time.Now(),
|
||||||
|
Network: network,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Send(fmt.Sprintf(ReplayAddWallet, strings.ToUpper(network)), &tb.ReplyMarkup{
|
||||||
|
ForceReply: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func WalletInfo(c tb.Context) error {
|
func WalletInfo(c tb.Context) error {
|
||||||
id := mathutil.MustUint(c.Data())
|
id := mathutil.MustUint(c.Data())
|
||||||
tokenInfo, err := data.GetWalletAddressById(id)
|
tokenInfo, err := data.GetWalletAddressById(id)
|
||||||
@@ -131,7 +179,12 @@ func WalletInfo(c tb.Context) error {
|
|||||||
bots.Handle(&delBtn, DelWallet)
|
bots.Handle(&delBtn, DelWallet)
|
||||||
bots.Handle(&backBtn, WalletList)
|
bots.Handle(&backBtn, WalletList)
|
||||||
|
|
||||||
return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
|
net := tokenInfo.Network
|
||||||
|
if net == "" {
|
||||||
|
net = mdb.NetworkTron
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("网络:%s\n地址:%s", net, tokenInfo.Address)
|
||||||
|
return c.EditOrReply(detail, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
|
||||||
{
|
{
|
||||||
enableBtn,
|
enableBtn,
|
||||||
disableBtn,
|
disableBtn,
|
||||||
@@ -195,3 +248,27 @@ func isWalletAddressPending(userID int64) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPendingWalletAddressState(userID int64) (pendingWalletAddressState, bool) {
|
||||||
|
v, ok := pendingWalletAddressUsers.Load(userID)
|
||||||
|
if !ok {
|
||||||
|
return pendingWalletAddressState{}, false
|
||||||
|
}
|
||||||
|
state, ok := v.(pendingWalletAddressState)
|
||||||
|
return state, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnabledSupportedNetworks() ([]string, error) {
|
||||||
|
chains, err := data.ListEnabledChains()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
networks := make([]string, 0, len(chains))
|
||||||
|
for _, ch := range chains {
|
||||||
|
if n := strings.ToLower(strings.TrimSpace(ch.Network)); n != "" {
|
||||||
|
networks = append(networks, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(networks)
|
||||||
|
return networks, nil
|
||||||
|
}
|
||||||
|
|||||||
+103
-49
@@ -1,66 +1,120 @@
|
|||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/config"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
tb "gopkg.in/telebot.v3"
|
tb "gopkg.in/telebot.v3"
|
||||||
"gopkg.in/telebot.v3/middleware"
|
"gopkg.in/telebot.v3/middleware"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var bots *tb.Bot
|
// bots is the single bot instance backing slash-command handlers
|
||||||
|
// (/start, WalletList, add-wallet dialog). Config is read from the
|
||||||
|
// settings table (system.telegram_bot_token + system.telegram_chat_id).
|
||||||
|
// Decoupled from notification_channels — that table drives push
|
||||||
|
// notifications only (via notify/dispatcher.go).
|
||||||
|
var (
|
||||||
|
bots *tb.Bot
|
||||||
|
adminChatID int64
|
||||||
|
reloadMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
// BotStart 机器人启动
|
// BotStart connects the command bot. If no telegram channel is
|
||||||
|
// configured yet it logs and returns; the service otherwise runs fine
|
||||||
|
// and notifications still fan out as soon as a channel is added (those
|
||||||
|
// use their own per-request bot; see notify/telegram_sender.go).
|
||||||
func BotStart() {
|
func BotStart() {
|
||||||
var err error
|
if err := reloadBot("bootstrap"); err != nil {
|
||||||
botSetting := tb.Settings{
|
log.Sugar.Errorf("[telegram] bot start failed: %v", err)
|
||||||
Token: config.TgBotToken,
|
|
||||||
Poller: &tb.LongPoller{Timeout: 50 * time.Second},
|
|
||||||
Synchronous: true,
|
|
||||||
}
|
}
|
||||||
if config.TgProxy != "" {
|
|
||||||
botSetting.URL = config.TgProxy
|
|
||||||
}
|
|
||||||
bots, err = tb.NewBot(botSetting)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Error(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = bots.SetCommands(Cmds)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Error(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RegisterHandle()
|
|
||||||
bots.Start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterHandle 注册处理器
|
// ReloadBotAsync refreshes the command bot from notification_channels.
|
||||||
func RegisterHandle() {
|
// It is used by admin API handlers after telegram channel create/update/
|
||||||
adminOnly := bots.Group()
|
// status/delete so operators don't need to restart the service.
|
||||||
adminOnly.Use(middleware.Whitelist(config.TgManage))
|
func ReloadBotAsync(reason string) {
|
||||||
adminOnly.Handle(START_CMD, WalletList)
|
|
||||||
adminOnly.Handle(tb.OnText, OnTextMessageHandle)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendToBot 主动发送消息机器人消息
|
|
||||||
func SendToBot(msg string) {
|
|
||||||
go func() {
|
go func() {
|
||||||
if bots == nil {
|
if err := reloadBot(reason); err != nil {
|
||||||
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
|
log.Sugar.Errorf("[telegram] reload failed, reason=%s err=%v", reason, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
user := tb.User{
|
|
||||||
ID: config.TgManage,
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
|
|
||||||
_, err := bots.Send(&user, msg, &tb.SendOptions{
|
|
||||||
ParseMode: tb.ModeHTML,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
|
|
||||||
} else {
|
|
||||||
log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadCommandBotConfig reads the command-bot config from the settings
|
||||||
|
// table (system.telegram_bot_token + system.telegram_chat_id).
|
||||||
|
// Returns (nil, "", nil) when the keys are absent so the caller can
|
||||||
|
// log and return gracefully — bot stays disabled until settings are set.
|
||||||
|
func loadCommandBotConfig() (*botConfig, string, error) {
|
||||||
|
botToken := strings.TrimSpace(data.GetSettingString("system.telegram_bot_token", ""))
|
||||||
|
chatIDStr := strings.TrimSpace(data.GetSettingString("system.telegram_chat_id", ""))
|
||||||
|
if botToken == "" || chatIDStr == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("invalid telegram chat_id in settings: %w", err)
|
||||||
|
}
|
||||||
|
return &botConfig{BotToken: botToken, ChatID: chatID}, "settings", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// botConfig holds the minimal fields needed to start the command bot.
|
||||||
|
type botConfig struct {
|
||||||
|
BotToken string
|
||||||
|
ChatID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadBot(reason string) error {
|
||||||
|
reloadMu.Lock()
|
||||||
|
defer reloadMu.Unlock()
|
||||||
|
|
||||||
|
if bots != nil {
|
||||||
|
bots.Stop()
|
||||||
|
bots = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, source, err := loadCommandBotConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
log.Sugar.Infof("[telegram] no enabled telegram channel configured; command bot disabled (reason=%s)", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
adminChatID = cfg.ChatID
|
||||||
|
|
||||||
|
botSetting := tb.Settings{
|
||||||
|
Token: cfg.BotToken,
|
||||||
|
Poller: &tb.LongPoller{Timeout: 50 * time.Second},
|
||||||
|
Synchronous: true,
|
||||||
|
}
|
||||||
|
newBot, err := tb.NewBot(botSetting)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new bot: %w", err)
|
||||||
|
}
|
||||||
|
if err = newBot.SetCommands(Cmds); err != nil {
|
||||||
|
return fmt.Errorf("set commands: %w", err)
|
||||||
|
}
|
||||||
|
bots = newBot
|
||||||
|
RegisterHandle()
|
||||||
|
go bots.Start()
|
||||||
|
|
||||||
|
log.Sugar.Infof("[telegram] command bot started, reason=%s, source=%s", reason, source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandle wires slash-command and text-message routes. The
|
||||||
|
// admin whitelist uses the chat_id from the same channel row.
|
||||||
|
func RegisterHandle() {
|
||||||
|
if bots == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminOnly := bots.Group()
|
||||||
|
adminOnly.Use(middleware.Whitelist(adminChatID))
|
||||||
|
adminOnly.Handle(START_CMD, WalletList)
|
||||||
|
adminOnly.Handle(tb.OnText, OnTextMessageHandle)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/config"
|
||||||
|
"github.com/assimon/luuu/internal/testutil"
|
||||||
|
"github.com/assimon/luuu/model/dao"
|
||||||
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
||||||
|
origToken := config.TgBotToken
|
||||||
|
origProxy := config.TgProxy
|
||||||
|
origManage := config.TgManage
|
||||||
|
defer func() {
|
||||||
|
config.TgBotToken = origToken
|
||||||
|
config.TgProxy = origProxy
|
||||||
|
config.TgManage = origManage
|
||||||
|
}()
|
||||||
|
|
||||||
|
config.TgBotToken = token
|
||||||
|
config.TgProxy = proxy
|
||||||
|
config.TgManage = manage
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCommandBotConfig_DoesNotUseEnvFallbackWhenNoChannel(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Env config is present but settings table is empty —
|
||||||
|
// the bot must NOT fall back to config.TgBotToken.
|
||||||
|
withTelegramEnv("env-token", "http://127.0.0.1:7890", 123456789, func() {
|
||||||
|
cfg, source, err := loadCommandBotConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("expected nil config when settings table has no telegram keys, got %+v", cfg)
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
t.Fatalf("source = %q, want empty", source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCommandBotConfig_PrefersEnabledTelegramChannel(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Seed settings table with telegram credentials.
|
||||||
|
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_bot_token", Value: "db-token", Type: "string"})
|
||||||
|
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "987654321", Type: "string"})
|
||||||
|
_ = data.ReloadSettings()
|
||||||
|
|
||||||
|
withTelegramEnv("env-token", "", 123456789, func() {
|
||||||
|
cfg, source, err := loadCommandBotConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil config")
|
||||||
|
}
|
||||||
|
if source != "settings" {
|
||||||
|
t.Fatalf("source = %q, want %q", source, "settings")
|
||||||
|
}
|
||||||
|
if cfg.BotToken != "db-token" {
|
||||||
|
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "db-token")
|
||||||
|
}
|
||||||
|
if cfg.ChatID != 987654321 {
|
||||||
|
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(987654321))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCommandBotConfig_ReturnsNilWhenNoChannelAndNoEnv(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Fresh DB — ensure the cache doesn't carry over values from a previous test.
|
||||||
|
_ = data.ReloadSettings()
|
||||||
|
|
||||||
|
withTelegramEnv("", "", 0, func() {
|
||||||
|
cfg, source, err := loadCommandBotConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("expected nil config, got %+v", cfg)
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
t.Fatalf("source = %q, want empty", source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCommandBotConfig_SkipsEmptyConfigChannel(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Only chat_id present, no bot_token → should return nil.
|
||||||
|
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "987654321", Type: "string"})
|
||||||
|
_ = data.ReloadSettings()
|
||||||
|
|
||||||
|
cfg, source, err := loadCommandBotConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("expected nil config when bot_token is absent, got %+v", cfg)
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
t.Fatalf("source = %q, want empty", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCommandBotConfig_SkipsInvalidAndUsesNextValid(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// bot_token present but chat_id is not a valid int → should return error.
|
||||||
|
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_bot_token", Value: "db-token", Type: "string"})
|
||||||
|
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "not-a-number", Type: "string"})
|
||||||
|
_ = data.ReloadSettings()
|
||||||
|
|
||||||
|
cfg, _, err := loadCommandBotConfig()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid chat_id, got nil")
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("expected nil config on parse error, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,24 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isValidEthereumAddress 校验 0x + 20 字节十六进制(主网收款)。
|
||||||
|
func isValidEthereumAddress(addr string) bool {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if len(addr) != 42 || !strings.HasPrefix(addr, "0x") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := hex.DecodeString(addr[2:])
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// isValidTronAddress 校验 Tron Base58Check 地址是否合法
|
// isValidTronAddress 校验 Tron Base58Check 地址是否合法
|
||||||
func isValidTronAddress(addr string) bool {
|
func isValidTronAddress(addr string) bool {
|
||||||
// 基本过滤
|
// 基本过滤
|
||||||
@@ -32,3 +46,35 @@ func isValidTronAddress(addr string) bool {
|
|||||||
|
|
||||||
return string(checksum) == string(hash2[:4])
|
return string(checksum) == string(hash2[:4])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isValidAddressByNetwork(network, addr string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
|
case mdb.NetworkTron:
|
||||||
|
return isValidTronAddress(addr)
|
||||||
|
case mdb.NetworkSolana:
|
||||||
|
return isValidSolanaAddress(addr)
|
||||||
|
default:
|
||||||
|
// 其余 EVM 链统一使用 0x 地址校验
|
||||||
|
return isValidEthereumAddress(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddressByNetwork(network, addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
|
case mdb.NetworkTron, mdb.NetworkSolana:
|
||||||
|
return addr
|
||||||
|
default:
|
||||||
|
return strings.ToLower(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。
|
||||||
|
func isValidSolanaAddress(addr string) bool {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if len(addr) < 32 || len(addr) > 44 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := solana.PublicKeyFromBase58(addr)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ var Errno = map[int]string{
|
|||||||
10008: "order does not exist",
|
10008: "order does not exist",
|
||||||
10009: "failed to parse request params",
|
10009: "failed to parse request params",
|
||||||
10010: "order status already changed",
|
10010: "order status already changed",
|
||||||
|
10011: "exceeded maximum sub-order limit",
|
||||||
|
10012: "cannot switch network on a sub-order",
|
||||||
|
10013: "order is not awaiting payment",
|
||||||
|
10014: "chain is not enabled",
|
||||||
|
10016: "supported asset not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,6 +33,11 @@ var (
|
|||||||
OrderNotExists = Err(10008)
|
OrderNotExists = Err(10008)
|
||||||
ParamsMarshalErr = Err(10009)
|
ParamsMarshalErr = Err(10009)
|
||||||
OrderStatusConflict = Err(10010)
|
OrderStatusConflict = Err(10010)
|
||||||
|
SubOrderLimitExceeded = Err(10011)
|
||||||
|
CannotSwitchSubOrder = Err(10012)
|
||||||
|
OrderNotWaitPay = Err(10013)
|
||||||
|
ChainNotEnabled = Err(10014)
|
||||||
|
SupportedAssetNotFound = Err(10016)
|
||||||
)
|
)
|
||||||
|
|
||||||
type RspError struct {
|
type RspError struct {
|
||||||
@@ -50,3 +60,19 @@ func Err(code int) (err error) {
|
|||||||
func (re *RspError) Render() (code int, msg string) {
|
func (re *RspError) Render() (code int, msg string) {
|
||||||
return re.Code, re.Msg
|
return re.Code, re.Msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HttpStatus maps a RspError code to the HTTP status the handler
|
||||||
|
// should use on the wire. Small codes (< 1000) are treated as real
|
||||||
|
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
|
||||||
|
// clients see the right status. Business codes (>= 1000) are all
|
||||||
|
// client-side problems that map to HTTP 400; the specific code still
|
||||||
|
// lives in the body's `status_code` field for the frontend to branch on.
|
||||||
|
func (re *RspError) HttpStatus() int {
|
||||||
|
if re == nil {
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
if re.Code >= 400 && re.Code < 600 {
|
||||||
|
return re.Code
|
||||||
|
}
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,16 +58,21 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
|
|||||||
return r.SucJson(e, pageDate, message...)
|
return r.SucJson(e, pageDate, message...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FailJson 失败json
|
// FailJson 失败json — Propagates semantic HTTP status codes to the
|
||||||
|
// wire. RspError.Code in the 4xx/5xx range is used directly; business
|
||||||
|
// codes (>=1000) map to HTTP 400 with the specific code still visible
|
||||||
|
// in the body's status_code field.
|
||||||
func (r *Resp) FailJson(e echo.Context, err error) error {
|
func (r *Resp) FailJson(e echo.Context, err error) error {
|
||||||
rr := new(Response)
|
rr := new(Response)
|
||||||
switch err.(type) {
|
httpStatus := http.StatusBadRequest
|
||||||
|
switch t := err.(type) {
|
||||||
case *constant.RspError:
|
case *constant.RspError:
|
||||||
rr.StatusCode, rr.Message = err.(*constant.RspError).Render()
|
rr.StatusCode, rr.Message = t.Render()
|
||||||
|
httpStatus = t.HttpStatus()
|
||||||
default:
|
default:
|
||||||
rr.StatusCode = 400
|
rr.StatusCode = http.StatusBadRequest
|
||||||
rr.Message = err.Error()
|
rr.Message = err.Error()
|
||||||
}
|
}
|
||||||
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
||||||
return r.Json(e, http.StatusOK, &rr)
|
return r.Json(e, httpStatus, &rr)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user