49 Commits

Author SHA1 Message Date
Astra3456 3e88d7a40c Enhance README with links to supported systems
Updated supported systems with links and improved clarity.
2026-04-15 05:16:44 +08:00
Astra3456 18dcb96822 Revise Telegram community joining instructions
Updated the description for joining the Telegram community.
2026-04-15 05:01:54 +08:00
Astra3456 4750d63db3 Update README.md 2026-04-15 04:57:14 +08:00
Astra3456 7af7306729 Update README.md 2026-04-15 04:55:24 +08:00
Astra3456 ff16db907a Update README.md 2026-04-15 04:50:49 +08:00
Astra3456 96c262a7dd Update README.md
Since version 0.0.5, EPUSDT has been upgraded to a multi-chain payment solution, not only for TRC20-USDT but also for other tokens.
2026-04-15 04:50:04 +08:00
Astra3456 8e96cc0058 Update README.md
just Readme update,Introduce the new features and add the official website
2026-04-15 04:41:37 +08:00
Ginta 9c003fbead feat: get support chain api 2026-04-15 03:18:38 +08:00
Ginta aff1055743 feat: get support chain api 2026-04-15 02:53:52 +08:00
Ginta f6a8fb47df Merge pull request #31 from GMwalletApp/dev-payment
Dev payment
2026-04-15 02:18:25 +08:00
Ginta d46bc7793e feat: get support chain api 2026-04-15 01:44:38 +08:00
Ginta 9d01443f3e Merge pull request #27 from jiamovo/fix/epay-submit-form-compat
support epay submit form params
2026-04-15 00:03:23 +08:00
Ginta 70f8ed4ea0 Merge pull request #25 from mar-heaven/dev-payment
test: bsc
2026-04-14 21:01:30 +08:00
jiamovo 8cd816c35e support epay submit form params 2026-04-14 20:58:53 +08:00
Ginta 2f6cd34610 feat: get support chain api 2026-04-14 19:03:39 +08:00
Ginta 1e2282ba2e feat: get support chain api 2026-04-14 18:57:38 +08:00
line-6000 786c5e8175 update router for support epay GET and POST 2026-04-14 18:43:20 +08:00
Ginta 5e68bf8309 test: bsc pol plasma 2026-04-13 07:50:07 +08:00
line-6000 5168beda00 Merge pull request #26 from GMwalletApp/dev-payment 2026-04-13 03:57:45 +08:00
line-6000 5e4d5dfae4 feat: add multi-network payment support
- Redesign payment UI with a two-step flow (select coin/network → pay)
  - Add wallet address validation in Telegram bot
  - Rename NetworkEthereum constant from "eth" to "ethereum"
2026-04-13 03:52:45 +08:00
line-6000 32ca778735 add epay route support 2026-04-12 18:14:40 +08:00
line-6000 730ff983f8 Merge pull request #24 from mar-heaven/dev-payment
feat: add Ethereum ERC-20 payment scanning

  - Add Ethereum WebSocket listener subscribing to USDT/USDC Transfer events on mainnet
  - Add ERC-20 transfer processing with order matching and settlement (task_service)
  - Add NetworkEthereum constant and ethereum_ws_url config
  - Support adding 0x Ethereum wallet addresses via Telegram bot
  - Show network info in Telegram wallet list and detail view
2026-04-12 01:02:05 +08:00
Ginta 93b32d38d9 test: eth 2026-04-11 22:10:48 +08:00
line-6000 3f071e6c01 feat: add Solana payment scanning with USDT/USDC support and wallet management API
- Rewrite Solana scanner: stateless scan with processed-signature cache, query wallet + USDT ATA + USDC ATA addresses to cover all SPL transfers
  - Add network field to transaction_lock (4-tuple unique index) and order flow
  - Add wallet management endpoints (add/list/get/status/delete) with token auth
  - Add network info to Telegram payment notification
  - Add USDC hint to test script GMPay prompt
  - Add API docs and Solana scanning docs
2026-04-11 00:10:54 +08:00
line-6000 a7963b335b Merge pull request #23 from GMwalletApp/dev
test: fix macOS path assertion and wallet address unique index
2026-04-04 01:01:28 +08:00
sojibimran d7fa90112d test: fix macOS path assertion and wallet address unique index 2026-04-04 00:57:52 +08:00
line-6000 5a607aa347 Update README.md 2026-04-03 23:55:46 +08:00
line-6000 b91d8b819c Update LEGACY_API_MIGRATION.md 2026-04-03 23:44:55 +08:00
line-6000 f87a131d7f Create docker-RUN.md 2026-04-03 23:40:25 +08:00
line-6000 258d239831 Update docker-compose.yaml 2026-04-03 23:10:29 +08:00
line-6000 5a960d3eec Merge pull request #22 from GMwalletApp/dev
feat: change payment ui
2026-04-03 22:46:30 +08:00
line-6000 2d673493cf Update index.html 2026-04-03 22:43:24 +08:00
sojibimran 537168008c update payment ui and change api route path 2026-04-03 22:00:24 +08:00
line-6000 99c4e5e072 refactor: migrate legacy API routes to /legacy prefix for backward compatibility 2026-04-02 03:34:12 +08:00
alphago9 4f250098f9 Merge pull request #21 from GMwalletApp/dev
build(release): sync release workflow cleanup from dev to master
2026-03-31 17:17:51 +08:00
alphago9 02d571bffb Merge pull request #20 from GMwalletApp/dev-runtime
build(release): compare final changelog against previous stable tag
2026-03-31 17:10:36 +08:00
alphago9 32ac0106e0 build(release): compare final changelog against previous stable tag 2026-03-31 16:38:49 +08:00
alphago9 49f86b5d8f Merge branch 'dev' into dev-runtime 2026-03-31 16:10:42 +08:00
alphago9 8927c0658f build(release): simplify release assets and prerelease behavior 2026-03-31 16:04:27 +08:00
alphago9 c847eff4e7 Merge pull request #19 from GMwalletApp/hotfix/release-attestation
fix(ci): sync release attestation hotfix back to dev
2026-03-31 15:32:33 +08:00
alphago9 ef6cfc88ff fix(ci): attest release artifacts directly 2026-03-31 14:56:34 +08:00
alphago9 04a2629df8 Merge pull request #18 from GMwalletApp/dev
merge dev into master
2026-03-31 14:45:04 +08:00
alphago9 0ba444df73 Merge pull request #17 from GMwalletApp/dev-runtime
refactor: harden sqlite runtime and packaged startup flow
2026-03-31 14:22:26 +08:00
alphago9 682aa3907a refactor: harden sqlite runtime and packaged startup flow
- refine config loading with --config and current-directory .env

- improve sqlite busy handling and runtime DB tuning

- restore telegram payment notification and wallet input flow

- add behavior-based config and callback recovery tests
2026-03-31 13:54:10 +08:00
alphago9 08f0c93e11 Merge branch 'dev' into dev-runtime 2026-03-30 20:14:29 +08:00
line-6000 938396d82b feat: refactor payment system with multi-currency support and TRX native transfer detection
- Add multi-currency exchange rate API integration (GetRateForCoin)
 - Implement native TRX transfer detection alongside TRC20 USDT transfers
 - Replace deprecated Tronscan API with Trongrid API for better reliability
 - Add TRON Grid API key configuration support
 - Refactor order service to support multiple tokens and currencies
 - Update wallet address locking/unlocking to track token type
 - Add base58 and gjson dependencies for crypto operations
 - Optimize SQLite connection with WAL mode and busy timeout
 - Remove deprecated USDT rate job (replaced by dynamic rate API)
 - Update order data model to include currency and receive address fields
 - Enhance TRC20 callback with improved error handling and logging
2026-03-30 16:19:10 +08:00
alphago9 dea81caa61 build(release): add tag-based release automation and version metadata 2026-03-30 01:37:08 +08:00
alphago9 fc8d58024e refactor: replace redis runtime and queues with sqlite-backed scheduler 2026-03-29 17:26:36 +08:00
line-6000 d6e7927605 feat: use no cgo sqlite as default 2026-03-27 17:04:43 +08:00
96 changed files with 8708 additions and 1811 deletions
+25 -7
View File
@@ -1,28 +1,46 @@
name: docker-alpine-epusdt
on:
workflow_dispatch: #github页面手动触发
workflow_dispatch:
push:
branches: [ "main" ]
branches:
- "main"
env:
IMAGE_NAME: epusdt #这是您的镜像名
IMAGE_NAME: epusdt
jobs:
push-docker-hub:
runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: Compute Build Metadata
id: meta
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build && Push
uses: docker/build-push-action@v4
- name: Build And Push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ steps.meta.outputs.commit }}
DATE=${{ steps.meta.outputs.date }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
+79
View File
@@ -0,0 +1,79 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
goreleaser:
name: Build And Publish Binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Ensure Tag Commit Is On Default Branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "$DEFAULT_BRANCH" --depth=1
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then
echo "tag ${GITHUB_REF_NAME} must reference a commit reachable from ${DEFAULT_BRANCH}"
exit 1
fi
- name: Set Up Go
uses: actions/setup-go@v6
with:
go-version-file: src/go.mod
- name: Select Previous Stable Tag For Final Releases
run: |
current_tag="${GITHUB_REF_NAME}"
if [[ "$current_tag" == *-* ]]; then
echo "prerelease tag detected: $current_tag"
exit 0
fi
previous_stable_tag="$(
git tag --list 'v*' --sort=-version:refname |
grep -Fxv "$current_tag" |
grep -Ev -- '-' |
head -n 1
)"
if [[ -n "$previous_stable_tag" ]]; then
echo "using previous stable tag: $previous_stable_tag"
echo "GORELEASER_PREVIOUS_TAG=$previous_stable_tag" >> "$GITHUB_ENV"
else
echo "no previous stable tag found"
fi
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
workdir: src
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Attest Release Artifacts
uses: actions/attest@v4
with:
subject-checksums: src/dist/SHA256SUMS
+3
View File
@@ -1,5 +1,8 @@
.idea/
src/runtime/
src/.env
src/*.db
src/*.db-shm
src/*.db-wal
.DS_Store
.env
+157
View File
@@ -0,0 +1,157 @@
# Development Standard
## Purpose
This document defines the default development goals, review criteria, and testing constraints for changes in this repository.
Before starting any implementation, use this document as a checklist.
## Development Goals
Every change should be judged against all of the following goals:
1. Functional completeness
The target feature must actually work end to end, not just compile.
2. No accidental teammate regression
Do not accidentally remove or override teammate capabilities that already exist on the base branch.
3. Robustness
The code should handle normal failure cases, retries, state conflicts, and restart scenarios as reasonably as possible.
4. Elegance
Prefer clear responsibilities, minimal coupling, and a small number of well-named moving parts.
5. Maintainability
Avoid hidden behavior, confusing configuration, or implementation details that make future debugging harder.
6. No dirty output
Never commit local runtime artifacts, local databases, caches, logs, or other development byproducts.
7. No mojibake
Source files, messages, and docs should not contain broken encoding text.
## Multi-Dimensional Self Review
Each meaningful change should be evaluated across these dimensions:
- functional completeness
- engineering implementation quality
- robustness
- elegance
- maintainability
- regression risk
- commit readiness
- test coverage quality
Scoring should be honest. High scores require both working behavior and clean delivery quality.
## Code Quality Rules
### Redundancy
Avoid redundant code unless duplication is clearly cheaper and safer than abstraction.
The following are considered bad redundancy:
- duplicate business logic with only tiny naming differences
- duplicate retry or state logic in multiple places
- helper functions that exist only to wrap one line without improving clarity
The following are acceptable:
- small data shapes for read optimization
- isolated helpers that remove repeated infrastructure setup
- compatibility shims with clear removal intent
### Elegance
Code is considered elegant when:
- responsibilities are split cleanly
- names explain intent
- state transitions are explicit
- configuration is understandable
- behavior is observable through useful logs
- failure handling does not hide important problems
### Robustness
Code is considered robust when:
- state changes are guarded by conditions where needed
- retries are controlled, not infinite
- restart recovery is possible where it matters
- local concurrency does not easily corrupt behavior
- failure in one side task does not silently break the main flow
## Bug Standard
Ask these questions before treating a change as finished:
- Is there any blocking functional bug?
- Is there any likely edge-case bug?
- Is there any operational bug under realistic local concurrency?
- Is there any user-visible regression?
If a bug is known, it must be called out explicitly in review notes.
## Testing Rules
### Required Principle
Tests must validate behavior, not re-implement the code under test.
### Forbidden Test Types
Do not write:
- invalid tests
- mirror tests
- tests that only repeat implementation branches line by line
- tests that only verify mocks interacted in the same order as the code was written
- tests that lock onto incidental internals instead of real behavior
### Preferred Test Types
Prefer:
- end-to-end behavior tests
- state transition tests
- concurrency conflict tests
- retry and recovery tests
- expiration and timing behavior tests
- persistence and restart-oriented tests where relevant
### Test Coverage Expectations
Coverage quality matters more than raw coverage count.
For core flows, tests should cover:
- normal success path
- important failure path
- state conflict or idempotency path
- retry or delayed processing path where relevant
## Review Checklist
Before considering a change ready, confirm:
- functionality is complete
- teammate capability was not accidentally lost
- no local artifact will be committed
- no mojibake exists
- no obvious redundant code exists
- no known blocking bug remains
- robustness is acceptable for the target deployment model
- tests are behavior-based and not mirror tests
- review notes clearly explain intentional semantic changes
## Current Project Direction
For this repository specifically, current review should assume:
- single-instance deployment is the primary target unless stated otherwise
- runtime semantics may intentionally differ from older Redis and asynq behavior when the new SQLite design is deliberate
- any such semantic difference must be explained in merge or review notes
+12 -12
View File
@@ -1,24 +1,24 @@
FROM golang:alpine AS builder
RUN apk add --no-cache --update git build-base
ENV CGO_ENABLED=1
WORKDIR /app
COPY ./src/go.mod ./src/go.sum ./
RUN go mod download
ENV CGO_ENABLED=0
COPY ./src .
RUN go mod tidy \
&& go build -ldflags "-s -w" \
-o epusdt .
WORKDIR /app
RUN git clone https://github.com/GMwalletApp/epusdt.git .
WORKDIR /app/src
RUN go mod download
RUN go build -o /app/epusdt .
FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/static /app/static
COPY --from=builder /app/static /static
COPY --from=builder /app/src/static /app/static
COPY --from=builder /app/src/static /static
COPY --from=builder /app/epusdt .
VOLUME /app/conf
ENTRYPOINT ["./epusdt" ,"http","start"]
VOLUME /app/conf
ENTRYPOINT ["./epusdt", "http", "start"]
+151 -53
View File
@@ -1,71 +1,169 @@
## Epusdt (Easy Payment Usdt)
# Epusdt Easy Payment USDT
<p align="center">
<img src="wiki/img/usdtlogo.png">
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p>
<p align="center">
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
</p>
## 项目简介
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql``redis`
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
<p align="center">
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
</p>
## 项目特点
- 支持私有化部署,无需担心钱包被篡改和吞单😁
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
- 多钱包地址轮询,提高订单并发率
- 异步队列响应,优雅及高性能
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
- 支持`http api`,其他系统亦可接入
- `Telegram`机器人接入,便捷使用和支付消息快速通知
<p align="center">
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
<a href="https://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>
---
## 🌍 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 |
| **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
├── plugins ---> (已集成的插件库,例如dujiaoka)
├── src ---> (项目核心目录)
├── sdk ---> (接入SDK)
├── sql ---> (安装sql文件或更新sql文件)
└── wiki ---> (知识库)
├── plugins/ → 已集成的系统插件(独角数卡等)
├── src/ → 项目核心代码
├── sdk/ → 接入 SDK
├── sql/ → 数据库安装 / 升级脚本
└── wiki/ → 文档与知识库
```
## 教程:
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
---
## 已适配系统插件
- 独角数卡[插件地址](plugins/dujiaoka)
## 🔧 实现原理
## 加入交流/意见反馈
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
## 设计实现
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
监听钱包地址`usdt`代币入账事件,通过`金额差异``时效性`来判定交易归属信息,
可参考下方`流程图`
```
简单的原理
1.客户需支付20.05usdt
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
3.发起支付的时候,我们可以判定钱包address_120.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功
工作流程
1. 客户发起支付,需支付 20.05 USDT
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
3.address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
```
### 流程图:
![Implementation principle](wiki/img/implementation_principle.jpg)
![Epusdt 支付流程图](wiki/img/implementation_principle.jpg)
## 声明
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,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>
+70
View File
@@ -0,0 +1,70 @@
# Releasing
This repository publishes versioned binaries to GitHub Releases when a tag is pushed.
## What gets published
For each tag like `v1.2.3`, GitHub Actions builds release archives for:
- `linux/amd64`
- `linux/arm64`
- `darwin/amd64`
- `darwin/arm64`
- `windows/amd64`
- `windows/arm64`
Each archive includes:
- the `epusdt` binary
- `src/.env.example`
- `src/static/`
Windows artifacts are published as `.zip`. Other platforms are published as `.tar.gz`.
Each release also publishes:
- a `SHA256SUMS` checksum file
- a GitHub build provenance attestation for the checksum file
## How to cut a release
1. Make sure the release commit has already landed on the default branch.
2. Create an annotated tag:
```bash
git tag -a v1.0.0 -m "release v1.0.0"
```
3. Push the tag:
```bash
git push origin v1.0.0
```
4. Wait for the `release` workflow to finish on GitHub.
5. Download the generated binaries from the GitHub Release page.
## Verify a release
Verify the GitHub build provenance attestation:
```bash
gh attestation verify SHA256SUMS -R GMwalletApp/epusdt
```
## Local validation
Run a snapshot build locally before pushing a tag:
```bash
cd src
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean
```
The resulting artifacts will be generated under `src/dist/`.
## Release candidates
If you use tags like `v1.2.3-rc1`, GoReleaser will mark the GitHub release as a prerelease.
For a final tag like `v1.2.3`, the release workflow forces GoReleaser to compare against the previous stable tag instead of the latest `rc` tag. This keeps the final changelog focused on stable-to-stable changes.
+208
View File
@@ -0,0 +1,208 @@
# Review
## Scope
This review checks `dev-runtime` against `dev` with the following standards:
- Do not accidentally remove or override teammate payment capabilities already merged into `dev`.
- Do not submit local test artifacts or dirty files.
- Confirm Redis -> SQLite replacement is functionally complete for the intended single-instance target.
- Judge code quality, elegance, robustness, and maintainability.
- Check for real bugs, mojibake, and redundant code.
- Compare current quality with the previous `dev` implementation.
These standards are also the default development constraints for future work:
- no invalid tests
- no mirror tests
- prefer behavior-based tests over implementation-shaped tests
- explain intentional semantic changes in review notes
- verify dirty local artifacts are excluded before commit
- think about teammate-regression risk before coding, not only after coding
## Files That Must Not Be Committed
These local SQLite artifacts must never be committed:
- `src/epusdt.db`
- `src/epusdt.db-shm`
- `src/epusdt.db-wal`
They are now excluded by `.gitignore`.
## Teammate Capabilities Still Preserved
The following teammate payment capabilities are still present:
- multi-currency order model: `src/model/mdb/orders_mdb.go`
- wallet address model: `src/model/mdb/wallet_address_mdb.go`
- native `TRX` transfer detection: `src/model/service/task_service.go`
- `TRC20 USDT` detection: `src/model/service/task_service.go`
- dynamic exchange-rate path: `src/model/service/order_service.go`, `src/config/config.go`
- `TRON_GRID_API_KEY` support: `src/config/config.go`
Conclusion: teammate core payment capabilities were not accidentally removed.
## Intentional Architecture Changes
These are intentional refactors, not accidental overrides:
- Redis runtime removed: `src/model/dao/rdb.go`
- asynq handlers removed: `src/mq/handle/callback_queue.go`, `src/mq/handle/order_expiration_queue.go`
- SQLite runtime lock introduced: `src/model/mdb/transaction_lock_mdb.go`
- SQLite scheduler introduced: `src/mq/queue.go`, `src/mq/worker.go`
- reservation moved from Redis key to SQLite unique constraint: `src/model/data/order_data.go`
Conclusion: this branch keeps payment capability but changes the runtime implementation.
## Previously Regressed Parts That Are Now Restored
The following regressions have been restored in the current working tree:
- rich Telegram payment notification template: `src/model/service/task_service.go`
- key chain-scan observability logs: `src/model/service/task_service.go`
- Chinese checkout error message: `src/model/service/pay_service.go`
- more reliable Telegram add-wallet flow: `src/telegram/handle.go`
## Functional Completion
For the single-instance target, the Redis -> SQLite replacement is complete across:
- order creation
- amount reservation
- chain scan matching
- payment success processing
- order expiration
- callback scheduling
- Telegram notification
Conclusion: feature work is complete for the intended single-instance model.
## Mojibake Check
No real mojibake was found in the source files checked during review.
Files spot-checked:
- `src/telegram/handle.go`
- `src/model/service/task_service.go`
- `src/model/service/pay_service.go`
Note: some terminals may render UTF-8 Chinese poorly, but the source content itself is fine.
## Redundant Code Check
No obvious redundant business code was found.
Recent helper additions are justified:
- `PendingCallbackOrder`
- `expirableOrder`
- `configureSQLite`
Conclusion: no clear "fix by duplication" smell was found.
## Bug Assessment
No confirmed blocking business bug is currently visible in the reviewed code path.
Important risk that still needs attention:
- SQLite main DB can still hit `SQLITE_BUSY` under concurrent polling and writes.
This risk is now being mitigated by:
- `src/model/dao/sqlite_config.go`
- `src/model/dao/mdb_sqlite.go`
- `src/model/dao/runtime_sqlite.go`
- lightweight busy retry in `src/mq/worker.go`
Conclusion: no confirmed blocking functional bug, but SQLite concurrency remains a real operational boundary that should still be watched in runtime.
## Robustness Assessment
Strengths:
- conditional state transitions prevent reviving expired orders
- reservation is now a persistent unique constraint instead of a temporary Redis key
- callback state is durable across process restarts
- behavior tests exist for key paths:
- `src/model/service/order_service_test.go`
- `src/mq/worker_test.go`
- callback polling now has a small targeted retry for transient SQLite busy errors
Remaining limits:
- robust for single-instance deployment
- not a final design for multi-instance deployment
- SQLite contention should still be watched in real runtime
Conclusion: robust enough for single-instance use, not a direct multi-instance design.
## Elegance Assessment
Strengths:
- runtime responsibility is cleaner with SQLite reservation + scheduler
- logging responsibility is split more clearly:
- `log_level`
- `http_access_log`
- `sql_debug`
- Telegram input flow is more practical for real clients
- SQLite tuning is extracted into one helper
Tradeoffs:
- callback semantics are no longer the same as the old immediate-asynq enqueue path
- current branch still has local uncommitted fixes that must be organized into a clean commit set
Conclusion: elegant for the chosen single-instance direction, but must be explained clearly when merging.
## Quality Compared With Previous Code
Overall judgment: quality did not go down; it improved in the single-instance direction.
Improvements:
- stronger state-machine constraints
- better log control
- more durable runtime reservation
- better Telegram interaction resilience
- added behavior tests
- added restart-oriented callback recovery coverage
- reduced scheduler fragility under transient SQLite busy errors
Changed semantics rather than degradation:
- merchant callback is now polled by SQLite scheduler instead of immediate asynq enqueue
Conclusion: quality is not lower, but runtime semantics changed and must be stated explicitly.
## Self Score
- functional completeness: 9.1/10
- engineering implementation: 8.9/10
- robustness: 8.7/10
- elegance: 9.0/10
- commit readiness: 8.8/10
Overall self score: 8.9/10
Main deductions:
- current uncommitted fixes still need to be organized into a clean commit
- SQLite single-file concurrency boundary still exists
- callback semantics differ from `dev-payment` and require clear communication
## Final Conclusion
This code can be submitted, but only if all of the following are done:
1. Do not commit local SQLite artifacts.
2. Include the current uncommitted fixes in a clean, intentional commit set.
3. Explain clearly in merge notes:
- what teammate capabilities were preserved
- what runtime behavior was intentionally refactored
- what semantics changed and why
If those three conditions are met, this is not a hidden override of teammate code. It is a reviewed and documented refactor.
+2 -3
View File
@@ -1,7 +1,6 @@
version: "3"
services:
epusdt:
image: annona/epusdt:alpine
image: gmwallet/epusdt:alpine
restart: always
build:
context: .
@@ -9,4 +8,4 @@ services:
volumes:
- ./env:/app/.env
ports:
- "8088:8000"
- "8000:8000"
+28 -37
View File
@@ -1,80 +1,71 @@
app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com
#是否开启debug,默认false
app_debug=false
#http服务监听端口
log_level=info
http_access_log=false
sql_debug=false
http_listen=:8000
#静态资源文件目录
static_path=/static
#缓存路径
runtime_root_path=/runtime
#日志配置
log_save_path=/logs
log_max_size=32
log_max_age=7
max_backups=3
#数据库类型,默认为mysql类型,可配置项: postgres,mysql,sqlite
# supported values: postgres,mysql,sqlite
db_type=sqlite
# sqlite配置
# sqlite primary database config
sqlite_database_filename=
sqlite_table_prefix=
# postgres配置
# postgres config
postgres_host=127.0.0.1
postgres_port=3306
postgres_user=mysql账号
postgres_passwd=mysql密码
postgres_database=数据库
postgres_user=mysql_user
postgres_passwd=mysql_password
postgres_database=database_name
postgres_table_prefix=
postgres_max_idle_conns=10
postgres_max_open_conns=100
postgres_max_life_time=6
# mysql配置
# mysql config
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql账号
mysql_passwd=mysql密码
mysql_database=数据库
mysql_user=mysql_user
mysql_passwd=mysql_password
mysql_database=database_name
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# redis配置
redis_host=127.0.0.1
redis_port=6379
redis_passwd=
redis_db=5
redis_pool_size=5
redis_max_retries=3
redis_idle_timeout=1000
# 消息队列配置
# background scheduler config
queue_concurrency=10
queue_level_critical=6
queue_level_default=3
queue_level_low=1
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
#机器人Apitoken
tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy=
#管理员userid
tg_manage=
#api接口认证token
api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10
#订单回调失败最大重试次数
order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate=
api_rate_url=
tron_grid_api_key=
solana_rpc_url=
ethereum_ws_url=wss://ethereum.publicnode.com
# epay
epay_pid=
epay_key=
+1
View File
@@ -1,2 +1,3 @@
.DS_Store
dist/
*.log
+42 -24
View File
@@ -1,44 +1,62 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
version: 2
project_name: epusdt
before:
hooks:
# You may remove this if you don't use go modules.
#- go mod tidy
# you may remove this if you don't need go generate
#- go generate ./...
- go test ./...
builds:
- env:
- id: epusdt
main: .
binary: epusdt
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
goos:
- darwin
- linux
- darwin
- windows
goarch:
- amd64
- arm
- 386
- arm64
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
id: epusdt
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format: tar.gz
- id: epusdt
ids:
- epusdt
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
files:
- .env.example
- static/*
- runtime/*
- static/**
format_overrides:
- goos: windows
formats:
- zip
checksum:
name_template: 'checksums.txt'
name_template: "SHA256SUMS"
snapshot:
name_template: "{{ incpatch .Version }}-next"
release:
github:
owner: GMwalletApp
name: epusdt
prerelease: auto
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
- "^fix\\(ci\\):"
- "^build\\(release\\):"
- "^Merge pull request"
+7 -17
View File
@@ -1,7 +1,8 @@
package bootstrap
import (
"github.com/assimon/luuu/command"
"sync"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/mq"
@@ -10,26 +11,15 @@ import (
"github.com/assimon/luuu/util/log"
)
// Start 服务启动
func Start() {
// 配置加载
var initOnce sync.Once
func InitApp() {
initOnce.Do(func() {
config.Init()
// 日志加载
log.Init()
// // Mysql启动
// dao.MysqlInit()
// dao.MdbTableInit()
// // redis启动
// dao.RedisInit()
dao.Init()
// 队列启动
mq.Start()
// telegram机器人启动
go telegram.BotStart()
// 定时任务
go task.Start()
err := command.Execute()
if err != nil {
panic(err)
}
})
}
+18
View File
@@ -0,0 +1,18 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/gookit/color"
)
func printBanner() {
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
color.Infof(
"Epusdt version(%s) commit(%s) built(%s) Powered by %s %s \n",
config.GetAppVersion(),
config.GetBuildCommit(),
config.GetBuildDate(),
"GMwalletApp",
"https://github.com/GMwalletApp/epusdt",
)
}
+18 -19
View File
@@ -2,6 +2,12 @@ package command
import (
"context"
"net/http"
"os"
"os/signal"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route"
@@ -12,16 +18,12 @@ import (
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"net/http"
"os"
"os/signal"
"time"
)
var httpCmd = &cobra.Command{
Use: "http",
Short: "http服务",
Long: "http服务相关命令",
Short: "http service",
Long: "http service commands",
Run: func(cmd *cobra.Command, args []string) {
},
}
@@ -32,9 +34,11 @@ func init() {
var startCmd = &cobra.Command{
Use: "start",
Short: "启动",
Long: "启动http服务",
Short: "start",
Long: "start http service",
Run: func(cmd *cobra.Command, args []string) {
bootstrap.InitApp()
printBanner()
HttpServerStart()
},
}
@@ -44,23 +48,22 @@ func HttpServerStart() {
e := echo.New()
e.HideBanner = true
e.HTTPErrorHandler = customHTTPErrorHandler
// 中间件注册
MiddlewareRegister(e)
// 路由注册
route.RegisterRoute(e)
// 静态目录注册
e.Static(config.StaticPath, "static")
e.Static(config.StaticPath, config.StaticFilePath)
httpListen := viper.GetString("http_listen")
go func() {
if err = e.Start(httpListen); err != http.ErrServerClosed {
log.Sugar.Error(err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, os.Kill)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err = e.Shutdown(ctx); err != nil {
@@ -68,16 +71,13 @@ func HttpServerStart() {
}
}
// MiddlewareRegister 中间件注册
func MiddlewareRegister(e *echo.Echo) {
if config.AppDebug {
e.Debug = true
if config.HTTPAccessLog {
e.Use(echoMiddleware.Logger())
}
e.Use(middleware.RequestUUID())
}
// customHTTPErrorHandler 默认消息提示
func customHTTPErrorHandler(err error, e echo.Context) {
code := http.StatusInternalServerError
msg := "server error"
@@ -95,5 +95,4 @@ func customHTTPErrorHandler(err error, e echo.Context) {
resp.Message = he.Msg
}
_ = e.JSON(http.StatusOK, resp)
return
}
+10 -1
View File
@@ -1,6 +1,11 @@
package command
import "github.com/spf13/cobra"
import (
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var configPath string
var rootCmd = &cobra.Command{}
@@ -9,5 +14,9 @@ func Execute() error {
}
func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env")
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
config.SetConfigPath(configPath)
}
rootCmd.AddCommand(httpCmd)
}
+26
View File
@@ -0,0 +1,26 @@
package command
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print build version information",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(),
"version: %s\ncommit: %s\nbuilt: %s\n",
config.GetAppVersion(),
config.GetBuildCommit(),
config.GetBuildDate(),
)
return err
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
+272 -21
View File
@@ -1,61 +1,169 @@
package config
import (
"errors"
"fmt"
"github.com/spf13/viper"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/assimon/luuu/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
)
var (
AppDebug bool
HTTPAccessLog bool
SQLDebug bool
LogLevel string
MysqlDns string
RuntimePath string
LogSavePath string
StaticPath string
StaticFilePath string
TgBotToken string
TgProxy string
TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
configRootPath string
explicitConfigPath string
)
func SetConfigPath(path string) {
explicitConfigPath = strings.TrimSpace(path)
}
func Init() {
viper.AddConfigPath("./")
viper.SetConfigFile(".env")
err := viper.ReadInConfig()
configPath, err := resolveConfigFilePath()
if err != nil {
panic(err)
}
gwd, err := os.Getwd()
configRootPath = filepath.Dir(configPath)
viper.SetConfigFile(configPath)
err = viper.ReadInConfig()
if err != nil {
panic(err)
}
AppDebug = viper.GetBool("app_debug")
StaticPath = viper.GetString("static_path")
RuntimePath = fmt.Sprintf(
"%s%s",
gwd,
viper.GetString("runtime_root_path"))
LogSavePath = fmt.Sprintf(
"%s%s",
RuntimePath,
viper.GetString("log_save_path"))
HTTPAccessLog = viper.GetBool("http_access_log")
SQLDebug = viper.GetBool("sql_debug")
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath)
mustMkdir(LogSavePath)
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
url.QueryEscape(viper.GetString("mysql_user")),
url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf(
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
viper.GetString("mysql_database"))
TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
}
func mustMkdir(path string) {
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
}
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error":
return strings.ToLower(strings.TrimSpace(level))
default:
return "info"
}
}
func normalizeStaticURLPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/static"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func resolvePathFromBase(basePath string, path string, fallback string) string {
path = strings.TrimSpace(path)
if path == "" {
return fallback
}
if filepath.IsAbs(path) {
return path
}
path = strings.TrimPrefix(path, "/")
path = strings.TrimPrefix(path, "\\")
return filepath.Join(basePath, filepath.FromSlash(path))
}
func resolveConfigFilePath() (string, error) {
if explicitConfigPath != "" {
return normalizeConfiguredPath(explicitConfigPath)
}
if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" {
return normalizeConfiguredPath(envPath)
}
return normalizeConfiguredPath(".env")
}
func normalizeConfiguredPath(input string) (string, error) {
path := strings.TrimSpace(input)
if path == "" {
path = ".env"
}
if !filepath.IsAbs(path) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
path = filepath.Join(cwd, path)
}
info, err := os.Stat(path)
if err == nil && info.IsDir() {
path = filepath.Join(path, ".env")
info, err = os.Stat(path)
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("config file not found: %s", path)
}
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("config path must point to a file, got directory: %s", path)
}
return path, nil
}
func GetAppVersion() string {
return "0.0.2"
return BuildVersion
}
func GetBuildCommit() string {
return BuildCommit
}
func GetBuildDate() string {
return BuildDate
}
func GetAppName() string {
@@ -74,6 +182,73 @@ func GetApiAuthToken() string {
return viper.GetString("api_auth_token")
}
func GetRateApiUrl() string {
rateURL := viper.GetString("api_rate_url")
if rateURL == "" {
rateURL = os.Getenv("API_RATE_URL")
}
if rateURL == "" {
log.Println("api_rate_url is empty")
}
RateApiUrl = rateURL
return rateURL
}
func GetRateForCoin(coin string, base string) float64 {
coin = strings.ToLower(strings.TrimSpace(coin))
base = strings.ToLower(strings.TrimSpace(base))
if coin == "" || base == "" {
return 0
}
if coin == base {
return 1
}
if coin == "usdt" {
switch base {
case "usd":
return 1
case "cny":
usdtRate := GetUsdtRate()
if usdtRate > 0 {
return 1 / usdtRate
}
}
}
baseURL := RateApiUrl
if baseURL == "" {
baseURL = GetRateApiUrl()
}
if baseURL == "" {
log.Printf("rate api url is empty")
return 0.0
}
if baseURL[len(baseURL)-1] != '/' {
baseURL += "/"
}
client := http_client.GetHttpClient()
resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base))
if err != nil {
log.Printf("call rate api error: %s", err.Error())
return 0.0
}
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
log.Printf("call rate api unexpected status: %s", resp.Status())
return 0.0
}
targetRate := 0.0
gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool {
if key.String() == coin {
targetRate = value.Float()
return false
}
return true
})
return targetRate
}
func GetUsdtRate() float64 {
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 {
@@ -97,3 +272,79 @@ func GetOrderExpirationTimeDuration() time.Duration {
timer := GetOrderExpirationTime()
return time.Minute * time.Duration(timer)
}
func GetRuntimeSqlitePath() string {
filename := viper.GetString("runtime_sqlite_filename")
if filename == "" {
filename = "epusdt-runtime.db"
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(RuntimePath, filepath.FromSlash(filename))
}
func GetPrimarySqlitePath() string {
filename := strings.TrimSpace(viper.GetString("sqlite_database_filename"))
if filename == "" {
return filepath.Join(configRootPath, "epusdt.db")
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(configRootPath, filepath.FromSlash(filename))
}
func GetQueueConcurrency() int {
concurrency := viper.GetInt("queue_concurrency")
if concurrency <= 0 {
return 10
}
return concurrency
}
func GetQueuePollInterval() time.Duration {
interval := viper.GetInt("queue_poll_interval_ms")
if interval <= 0 {
interval = 1000
}
return time.Duration(interval) * time.Millisecond
}
func GetOrderNoticeMaxRetry() int {
retry := viper.GetInt("order_notice_max_retry")
if retry < 0 {
return 0
}
return retry
}
func GetCallbackRetryBaseDuration() time.Duration {
seconds := viper.GetInt("callback_retry_base_seconds")
if seconds <= 0 {
seconds = 5
}
return time.Duration(seconds) * time.Second
}
func GetSolanaRpcUrl() string {
rpcUrl := viper.GetString("solana_rpc_url")
if rpcUrl == "" {
return "https://api.mainnet-beta.solana.com"
}
return rpcUrl
}
func GetEthereumWsUrl() string {
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
}
func GetEpayPid() int {
return viper.GetInt("epay_pid")
}
func GetEpayKey() string {
return viper.GetString("epay_key")
}
+137
View File
@@ -0,0 +1,137 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
t.Helper()
root := t.TempDir()
configPath := filepath.Join(root, "custom.env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := normalizeConfiguredPath(configPath)
if err != nil {
t.Fatalf("normalize explicit file: %v", err)
}
if got != configPath {
t.Fatalf("config path = %s, want %s", got, configPath)
}
}
func TestNormalizeConfiguredPathUsesExplicitDirectory(t *testing.T) {
t.Helper()
root := t.TempDir()
configPath := filepath.Join(root, ".env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := normalizeConfiguredPath(root)
if err != nil {
t.Fatalf("normalize explicit directory: %v", err)
}
if got != configPath {
t.Fatalf("config path = %s, want %s", got, configPath)
}
}
func TestResolveConfigFilePathUsesCurrentDirectoryByDefault(t *testing.T) {
t.Helper()
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("get cwd: %v", err)
}
defer func() { _ = os.Chdir(oldCwd) }()
root := t.TempDir()
configPath := filepath.Join(root, ".env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Setenv("EPUSDT_CONFIG", "")
SetConfigPath("")
got, err := resolveConfigFilePath()
if err != nil {
t.Fatalf("resolve config path: %v", err)
}
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)
}
}
func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Helper()
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("get cwd: %v", err)
}
defer func() { _ = os.Chdir(oldCwd) }()
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
envDir := filepath.Join(root, "from-env")
if err := os.MkdirAll(envDir, 0o755); err != nil {
t.Fatalf("mkdir env dir: %v", err)
}
envPath := filepath.Join(envDir, ".env")
if err := os.WriteFile(envPath, []byte("app_name=env\n"), 0o644); err != nil {
t.Fatalf("write env config: %v", err)
}
flagDir := filepath.Join(root, "from-flag")
if err := os.MkdirAll(flagDir, 0o755); err != nil {
t.Fatalf("mkdir flag dir: %v", err)
}
flagPath := filepath.Join(flagDir, ".env")
if err := os.WriteFile(flagPath, []byte("app_name=flag\n"), 0o644); err != nil {
t.Fatalf("write flag config: %v", err)
}
t.Setenv("EPUSDT_CONFIG", envDir)
SetConfigPath(flagDir)
defer SetConfigPath("")
got, err := resolveConfigFilePath()
if err != nil {
t.Fatalf("resolve config path: %v", err)
}
if got != flagPath {
t.Fatalf("config path = %s, want %s", got, flagPath)
}
}
+54
View File
@@ -1,6 +1,10 @@
package comm
import (
"encoding/json"
"fmt"
"log"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/constant"
@@ -22,3 +26,53 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
}
return c.SucJson(ctx, resp)
}
// SwitchNetwork 切换支付网络,创建或返回子订单
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)
}
fmt.Printf("switch network response: \n%s", string(jsonBytes))
return c.SucJson(ctx, resp)
}
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest)
if err = ctx.Bind(req); err != nil {
log.Println("bind request error:", err)
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err = c.ValidateStruct(ctx, req); err != nil {
log.Println("validate request error:", err)
return c.FailJson(ctx, err)
}
resp, err := service.CreateTransaction(req)
if err != nil {
log.Println("create transaction error:", err)
return c.FailJson(ctx, err)
}
fmt.Printf("create transaction response: %+v\n", resp)
tradeID := resp.TradeId
ctx.Redirect(302, "/pay/checkout-counter/"+tradeID)
return nil
}
+23 -5
View File
@@ -1,13 +1,16 @@
package comm
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4"
"html/template"
"net/http"
)
// CheckoutCounter 收银台
@@ -15,12 +18,27 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html"))
if err == service.ErrOrder {
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
emptyResp := response.CheckoutCounterResponse{}
return tmpl.Execute(ctx.Response(), emptyResp)
}
return ctx.String(http.StatusOK, err.Error())
}
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
jsonByte, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
fmt.Printf("%v\n", string(jsonByte))
return tmpl.Execute(ctx.Response(), resp)
}
@@ -0,0 +1,147 @@
package comm
import (
"sort"
"strconv"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
type addSupportedAssetRequest struct {
Network string `json:"network" validate:"required"`
Token string `json:"token" validate:"required"`
Status int64 `json:"status" validate:"required|in:1,2"`
}
type updateSupportedAssetRequest struct {
Network string `json:"network" validate:"required"`
Token string `json:"token" validate:"required"`
Status int64 `json:"status" validate:"required|in:1,2"`
}
// GetSupportedAssets 对外公开可用链与 token 列表(无需鉴权,仅返回已启用项)。
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
list, err := data.ListEnabledSupportedAssets()
if err != nil {
return c.FailJson(ctx, err)
}
wallets, err := data.GetAvailableWalletAddress()
if err != nil {
return c.FailJson(ctx, err)
}
networkSet := make(map[string]struct{})
for _, w := range wallets {
networkSet[w.Network] = struct{}{}
}
grouped := make(map[string][]string)
for _, item := range list {
if _, ok := networkSet[item.Network]; !ok {
continue
}
grouped[item.Network] = append(grouped[item.Network], item.Token)
}
networks := make([]string, 0, len(grouped))
for network := range grouped {
networks = append(networks, network)
}
sort.Strings(networks)
supports := make([]response.NetworkTokenSupport, 0, len(networks))
for _, network := range networks {
tokens := grouped[network]
sort.Strings(tokens)
supports = append(supports, response.NetworkTokenSupport{
Network: network,
Tokens: tokens,
})
}
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
}
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
network := ctx.QueryParam("network")
list, err := data.ListSupportedAssets(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, list)
}
// GetSupportedAsset 查询单条支持项(无需鉴权)。
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
asset, err := data.GetSupportedAssetByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if asset.ID <= 0 {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
return c.SucJson(ctx, asset)
}
// AddSupportedAsset 新增支持项(鉴权)。
func (c *BaseCommController) AddSupportedAsset(ctx echo.Context) error {
req := new(addSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.AddSupportedAsset(req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// UpdateSupportedAsset 修改支持项(鉴权)。
func (c *BaseCommController) UpdateSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req := new(updateSupportedAssetRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
asset, err := data.UpdateSupportedAsset(id, req.Network, req.Token, req.Status)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, asset)
}
// DeleteSupportedAsset 删除支持项(鉴权)。
func (c *BaseCommController) DeleteSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
asset, err := data.GetSupportedAssetByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if asset.ID <= 0 {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
if err := data.DeleteSupportedAssetByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+98
View File
@@ -0,0 +1,98 @@
package comm
import (
"strconv"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
type addWalletRequest struct {
Network string `json:"network" validate:"required"`
Address string `json:"address" validate:"required"`
}
type changeStatusRequest struct {
Status int `json:"status" validate:"required|in:1,2"`
}
// AddWallet 添加钱包地址
func (c *BaseCommController) AddWallet(ctx echo.Context) error {
req := new(addWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
wallet, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, wallet)
}
// ListWallets 获取钱包列表
func (c *BaseCommController) ListWallets(ctx echo.Context) error {
network := ctx.QueryParam("network")
var wallets []mdb.WalletAddress
var err error
if network != "" {
wallets, err = data.GetAllWalletAddressByNetwork(network)
} else {
wallets, err = data.GetAllWalletAddress()
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, wallets)
}
// GetWallet 获取单个钱包
func (c *BaseCommController) GetWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
wallet, err := data.GetWalletAddressById(id)
if err != nil {
return c.FailJson(ctx, err)
}
if wallet.ID <= 0 {
return c.FailJson(ctx, constant.NotAvailableWalletAddress)
}
return c.SucJson(ctx, wallet)
}
// ChangeWalletStatus 启用/禁用钱包
func (c *BaseCommController) ChangeWalletStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
req := new(changeStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteWallet 删除钱包
func (c *BaseCommController) DeleteWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err := data.DeleteWalletAddressById(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+30
View File
@@ -0,0 +1,30 @@
package crypto
import (
"crypto/sha256"
"github.com/shengdoushi/base58"
)
const AddressLength = 20
const PrefixMainnet = 0x41
// Encode returns the Base58 encoding of input using the Bitcoin alphabet.
func Encode(input []byte) string {
return base58.Encode(input, base58.BitcoinAlphabet)
}
// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum).
func EncodeCheck(input []byte) string {
h256h0 := sha256.New()
h256h0.Write(input)
h0 := h256h0.Sum(nil)
h256h1 := sha256.New()
h256h1.Write(h0)
h1 := h256h1.Sum(nil)
inputCheck := append(append([]byte(nil), input...), h1[:4]...)
return Encode(inputCheck)
}
+73 -34
View File
@@ -1,76 +1,115 @@
module github.com/assimon/luuu
go 1.24.0
go 1.25.0
require (
github.com/btcsuite/btcutil v1.0.2
github.com/dromara/carbon/v2 v2.6.15
github.com/go-redis/redis/v8 v8.11.5
github.com/ethereum/go-ethereum v1.15.11
github.com/gagliardetto/solana-go v1.16.0
github.com/go-resty/resty/v2 v2.11.0
github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1
github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0
github.com/libtnb/sqlite v1.0.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0
github.com/shengdoushi/base58 v1.0.0
github.com/shopspring/decimal v1.3.1
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.9.0
go.uber.org/zap v1.17.0
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.27.0
gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.20.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/consensys/bavard v0.1.27 // indirect
github.com/consensys/gnark-crypto v0.16.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hibiken/asynq v0.25.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/labstack/gommon v0.3.0 // indirect
github.com/magiconair/properties v1.8.5 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.4.2 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/redis/go-redis/v9 v9.17.3 // indirect
github.com/spf13/afero v1.6.0 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.13.0 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.63.2 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)
+296 -683
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
package testutil
import (
"path/filepath"
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
appLog "github.com/assimon/luuu/util/log"
"github.com/libtnb/sqlite"
"github.com/spf13/viper"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func SetupTestDatabases(t testing.TB) func() {
t.Helper()
viper.Reset()
viper.Set("forced_usdt_rate", 1.0)
viper.Set("app_uri", "https://example.com")
viper.Set("order_expiration_time", 10)
viper.Set("order_notice_max_retry", 2)
viper.Set("callback_retry_base_seconds", 1)
viper.Set("queue_concurrency", 4)
viper.Set("queue_poll_interval_ms", 50)
viper.Set("api_auth_token", "test-token")
config.HTTPAccessLog = false
config.SQLDebug = false
config.LogLevel = "error"
config.UsdtRate = 0
appLog.Sugar = zap.NewNop().Sugar()
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{})
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB
return func() {
closeDB(t, runtimeDB)
closeDB(t, mainDB)
dao.Mdb = nil
dao.RuntimeDB = nil
viper.Reset()
}
}
func mustOpenSQLite(t testing.TB, path string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
NamingStrategy: schema.NamingStrategy{SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite %s: %v", path, err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle for %s: %v", path, err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
return db
}
func mustMigrate(t testing.TB, db *gorm.DB, models ...interface{}) {
t.Helper()
if err := db.AutoMigrate(models...); err != nil {
t.Fatalf("automigrate: %v", err)
}
}
func closeDB(t testing.TB, db *gorm.DB) {
t.Helper()
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle close: %v", err)
}
if err = sqlDB.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
}
+4 -5
View File
@@ -1,8 +1,7 @@
package main
import (
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/command"
"github.com/gookit/color"
)
@@ -12,7 +11,7 @@ func main() {
color.Error.Println("[Start Server Err!!!] ", err)
}
}()
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "assimon", "https://github.com/assimon/epusdt")
bootstrap.Start()
if err := command.Execute(); err != nil {
panic(err)
}
}
+2 -1
View File
@@ -2,12 +2,13 @@ package middleware
import (
"bytes"
"io/ioutil"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4"
"io/ioutil"
)
func CheckApiSign() echo.MiddlewareFunc {
+24
View File
@@ -0,0 +1,24 @@
package middleware
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant"
"github.com/labstack/echo/v4"
)
// CheckApiToken validates the Authorization header against the configured api_auth_token.
// Use this for management APIs (GET/POST) where body-based signature is not practical.
func CheckApiToken() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
token := ctx.Request().Header.Get("Authorization")
if token == "" {
token = ctx.QueryParam("api_token")
}
if token == "" || token != config.GetApiAuthToken() {
return constant.SignatureErr
}
return next(ctx)
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ func Init() {
log.Fatalf("DBInit err: %v", err)
}
if err := RedisInit(); err != nil {
log.Fatalf("RedisInit err: %v", err)
if err := RuntimeInit(); err != nil {
log.Fatalf("RuntimeInit err: %v", err)
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func MysqlInit() error {
// panic(err)
return err
}
if config.AppDebug {
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
+1 -1
View File
@@ -40,7 +40,7 @@ func PostgreSQLInit() error {
return err
}
if config.AppDebug {
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
+8 -19
View File
@@ -1,6 +1,7 @@
package dao
import (
"os"
"path/filepath"
"github.com/assimon/luuu/config"
@@ -8,8 +9,6 @@ import (
"github.com/gookit/color"
"github.com/spf13/viper"
// "github.com/glebarez/sqlite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
@@ -18,12 +17,13 @@ import (
// SqliteInit 数据库初始化
func SqliteInit() error {
var err error
dbFilename := "./conf/.db"
if dbfile := viper.GetString("sqlite_database_filename"); len(dbfile) > 0 {
dbFilename = filepath.Base(dbfile)
dbFilename := config.GetPrimarySqlitePath()
if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil {
color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err)
return err
}
color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename)
Mdb, err = gorm.Open(sqlite.Open(dbFilename), &gorm.Config{
Mdb, err = openDB(dbFilename, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("sqlite_table_prefix"),
SingularTable: true,
@@ -35,22 +35,11 @@ func SqliteInit() error {
// panic(err)
return err
}
if config.AppDebug {
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] sqlite get DB,err=%s\n", err)
// panic(err)
return err
}
// sqlDB.SetMaxIdleConns(viper.GetInt("sqlite_max_idle_conns"))
// sqlDB.SetMaxOpenConns(viper.GetInt("sqlite_max_open_conns"))
// sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("sqlite_max_life_time")))
err = sqlDB.Ping()
if err != nil {
if _, err = configureSQLite(Mdb, 1); err != nil {
color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error())
// panic(err)
return err
}
log.Sugar.Debug("[store_db] sqlite connDB success")
+16
View File
@@ -0,0 +1,16 @@
//go:build sqlite_cgo
package dao
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !sqlite_cgo
package dao
import (
"github.com/libtnb/sqlite"
"gorm.io/gorm"
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+34
View File
@@ -22,5 +22,39 @@ func MdbTableInit() {
// panic(err)
return
}
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
return
}
seedSupportedAssets()
})
}
func seedSupportedAssets() {
var cnt int64
if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil {
color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err)
return
}
if cnt > 0 {
return
}
defaults := []mdb.SupportedAsset{
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
}
if err := Mdb.Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err)
}
}
-46
View File
@@ -1,46 +0,0 @@
package dao
import (
"context"
"fmt"
"time"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"github.com/gookit/color"
"github.com/spf13/viper"
)
var Rdb *redis.Client
func RedisInit() error {
options := redis.Options{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")), // Redis地址
DB: viper.GetInt("redis_db"), // Redis库
PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小
MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数
IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间
}
if viper.GetString("redis_passwd") != "" {
options.Password = viper.GetString("redis_passwd")
}
Rdb = redis.NewClient(&options)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pong, err := Rdb.Ping(ctx).Result()
if err == redis.Nil {
log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.")
} else if err != nil {
color.Red.Printf("[store_redis] redis connRdb err,err=%s", err)
// panic(err)
// time.Sleep(10 * time.Second)
// RedisInit()
return err
} else {
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package dao
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var RuntimeDB *gorm.DB
func RuntimeInit() error {
var err error
runtimePath := config.GetRuntimeSqlitePath()
color.Green.Printf("[runtime_db] sqlite filename: %s\n", runtimePath)
RuntimeDB, err = openDB(runtimePath, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[runtime_db] sqlite open DB,err=%s\n", err)
return err
}
concurrency := config.GetQueueConcurrency()
if concurrency < 2 {
concurrency = 2
}
if concurrency > 16 {
concurrency = 16
}
if _, err = configureSQLite(RuntimeDB, concurrency); err != nil {
color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error())
return err
}
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err
}
log.Sugar.Debug("[runtime_db] sqlite connDB success")
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package dao
import (
"database/sql"
"gorm.io/gorm"
)
func configureSQLite(db *gorm.DB, maxOpenConns int) (*sql.DB, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
if maxOpenConns <= 0 {
maxOpenConns = 1
}
sqlDB.SetMaxOpenConns(maxOpenConns)
sqlDB.SetMaxIdleConns(1)
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
return nil, err
}
if err := sqlDB.Ping(); err != nil {
return nil, err
}
return sqlDB, nil
}
+226 -56
View File
@@ -1,109 +1,279 @@
package data
import (
"context"
"fmt"
"errors"
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/go-redis/redis/v8"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"time"
"gorm.io/gorm/clause"
)
var (
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
)
var ErrTransactionLocked = errors.New("transaction amount is already locked")
// GetOrderInfoByOrderId 通过客户订单号查询订单
type PendingCallbackOrder struct {
TradeId string `gorm:"column:trade_id"`
CallbackNum int `gorm:"column:callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm"`
UpdatedAt carbon.Time `gorm:"column:updated_at"`
}
func normalizeLockAmount(amount float64) (int64, string) {
value := decimal.NewFromFloat(amount).Round(2)
return value.Shift(2).IntPart(), value.StringFixed(2)
}
func normalizeLockToken(token string) string {
return strings.ToUpper(strings.TrimSpace(token))
}
// GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err
}
// GetOrderInfoByTradeId 通过交易号查询订单
// GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err
}
// CreateOrderWithTransaction 事务创建订单
// CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error
return err
return tx.Model(order).Create(order).Error
}
// GetOrderByBlockIdWithTransaction 通过区块获取订单
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
return order, err
}
// OrderSuccessWithTransaction 事务支付成功
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", req.TradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
}).Error
return err
})
return result.RowsAffected > 0, result.Error
}
// GetPendingCallbackOrders 查询出等待回调的订单
func GetPendingCallbackOrders() ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(orders).
Where("callback_num < ?", 5).
// GetPendingCallbackOrders returns the minimal callback scheduling state.
func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
var orders []PendingCallbackOrder
query := dao.Mdb.Model(&mdb.Orders{}).
Select("trade_id", "callback_num", "callback_confirm", "updated_at").
Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess).
Order("updated_at asc")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp persists a callback attempt result.
func SaveCallBackOrdersResp(order *mdb.Orders) error {
return dao.Mdb.Model(order).
Where("id = ?", order.ID).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
}
// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
result := dao.Mdb.Model(mdb.Orders{}).
Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent.
func CountActiveSubOrders(parentTradeId string) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay).
Count(&count).Error
return count, err
}
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).
Where("parent_trade_id = ?", parentTradeId).
Where("token = ?", token).
Where("network = ?", network).
Where("status = ?", mdb.StatusWaitPay).
Limit(1).
Find(order).Error
return order, err
}
// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id.
func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("trade_id != ?", excludeTradeId).
Where("status = ?", mdb.StatusWaitPay).
Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp 保存订单回调结果
func SaveCallBackOrdersResp(order *mdb.Orders) error {
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
return err
// MarkParentOrderSuccess updates the parent order with the sub-order's payment details.
// Token and network are NOT overwritten — the parent keeps its original values.
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"status": mdb.StatusPaySuccess,
"block_transaction_id": sub.BlockTransactionId,
"callback_confirm": mdb.CallBackConfirmNo,
"actual_amount": sub.ActualAmount,
"receive_address": sub.ReceiveAddress,
})
return result.RowsAffected > 0, result.Error
}
// UpdateOrderIsExpirationById 通过id设置订单过期
func UpdateOrderIsExpirationById(id uint64) error {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error
return err
// 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
}
// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
result, err := dao.Rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", nil
}
// 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
}
// 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) {
scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()).
Limit(1).
Find(&lock).Error
if err != nil {
return "", err
}
return result, nil
if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
}
// LockTransaction 锁定交易
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token)
now := time.Now()
lock := &mdb.TransactionLock{
Network: network,
Address: address,
Token: normalizedToken,
AmountScaled: scaledAmount,
AmountText: amountText,
TradeId: tradeID,
ExpiresAt: now.Add(expirationTime),
}
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now).
Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrTransactionLocked
}
return nil
})
}
// UnLockTransaction 解锁交易
func UnLockTransaction(token string, amount float64) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Del(ctx, cacheKey).Err()
return err
// UnLockTransaction releases the reservation for network+address+token+amount.
func UnLockTransaction(network string, address string, token string, amount float64) error {
scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB.
Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
Delete(&mdb.TransactionLock{}).Error
}
func UnLockTransactionByTradeId(tradeID string) error {
return dao.RuntimeDB.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error
}
func CleanupExpiredTransactionLocks() error {
return dao.RuntimeDB.Where("expires_at <= ?", time.Now()).Delete(&mdb.TransactionLock{}).Error
}
+123
View File
@@ -0,0 +1,123 @@
package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
)
func normalizeNetworkToken(network, token string) (string, string) {
return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token))
}
func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 {
return nil, constant.SupportedAssetAlreadyExists
}
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
if err != nil {
return nil, err
}
if deleted.ID > 0 && deleted.DeletedAt.Valid {
deleted.Status = status
deleted.DeletedAt.Valid = false
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
return nil, err
}
return deleted, nil
}
asset := &mdb.SupportedAsset{
Network: network,
Token: token,
Status: status,
}
err = dao.Mdb.Create(asset).Error
return asset, err
}
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Unscoped().Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(asset).Error
return asset, err
}
func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) {
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error
return asset, err
}
func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset, err := GetSupportedAssetByID(id)
if err != nil {
return nil, err
}
if asset.ID <= 0 {
return nil, constant.SupportedAssetNotFound
}
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
if err != nil {
return nil, err
}
if exist.ID > 0 && exist.ID != id {
return nil, constant.SupportedAssetAlreadyExists
}
asset.Network = network
asset.Token = token
asset.Status = status
if err := dao.Mdb.Save(asset).Error; err != nil {
return nil, err
}
return asset, nil
}
func DeleteSupportedAssetByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error
}
func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) {
network = strings.ToLower(strings.TrimSpace(network))
var list []mdb.SupportedAsset
tx := dao.Mdb.Model(&mdb.SupportedAsset{})
if network != "" {
tx = tx.Where("network = ?", network)
}
err := tx.Order("network asc, token asc").Find(&list).Error
return list, err
}
func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) {
var list []mdb.SupportedAsset
err := dao.Mdb.Model(&mdb.SupportedAsset{}).
Where("status = ?", mdb.TokenStatusEnable).
Order("network asc, token asc").
Find(&list).Error
return list, err
}
+44 -7
View File
@@ -1,14 +1,23 @@
package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
)
// AddWalletAddress 创建钱包
func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token)
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
}
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil {
return nil, err
}
@@ -16,17 +25,28 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists
}
walletAddress := &mdb.WalletAddress{
Token: token,
Network: network,
Address: address,
Status: mdb.TokenStatusEnable,
}
err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取token
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) {
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error
err := dao.Mdb.Model(walletAddress).
Where("network = ?", network).
Where("address = ?", address).
Limit(1).Find(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口)
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
return walletAddress, err
}
@@ -50,6 +70,16 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
var list []mdb.WalletAddress
err := dao.Mdb.Model(list).
Where("status = ?", mdb.TokenStatusEnable).
Where("network = ?", network).
Find(&list).Error
return list, err
}
// GetAllWalletAddress 获得所有钱包地址
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
var WalletAddressList []mdb.WalletAddress
@@ -57,6 +87,13 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
var list []mdb.WalletAddress
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
return list, err
}
// ChangeWalletAddressStatus 启用禁用钱包
func ChangeWalletAddressStatus(id uint64, status int) error {
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
+22 -12
View File
@@ -8,22 +8,32 @@ const (
CallBackConfirmNo = 2
)
const (
PaymentTypeEpay = "Epay"
)
type Orders struct {
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` // 区块id
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
Amount float64 `gorm:"column:amount" json:"amount"`
Currency string `gorm:"column:currency" json:"currency"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
Token string `gorm:"column:token" json:"token"`
Network string `gorm:"column:network" json:"network"`
Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
Name string `gorm:"column:name" json:"name"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"`
PaymentType string `gorm:"column:payment_type" json:"payment_type"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string {
return "orders"
}
+12
View File
@@ -0,0 +1,12 @@
package mdb
type SupportedAsset struct {
Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"`
Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel
}
func (s *SupportedAsset) TableName() string {
return "supported_asset"
}
+20
View File
@@ -0,0 +1,20 @@
package mdb
import "time"
type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (t *TransactionLock) TableName() string {
return "transaction_lock"
}
+12 -4
View File
@@ -5,14 +5,22 @@ const (
TokenStatusDisable = 2
)
// WalletAddress 钱包表
const (
NetworkTron = "tron"
NetworkSolana = "solana"
NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
)
type WalletAddress struct {
Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string {
return "wallet_address"
}
+26
View File
@@ -5,15 +5,23 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"`
Currency string `json:"currency" validate:"required"` // 法币 如:cny
Token string `json:"token" validate:"required"` // 币种 如:usdt
Network string `json:"network" validate:"required"` // 网络 如:TRON
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"`
RedirectUrl string `json:"redirect_url"`
Name string `json:"name"`
PaymentType string `json:"payment_type"`
}
func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{
"OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Network": "网络",
"Amount": "支付金额",
"NotifyUrl": "异步回调网址",
"Signature": "签名",
@@ -22,8 +30,26 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理
type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string
Network string
Amount float64
TradeId string
BlockTransactionId string
}
// SwitchNetworkRequest 切换支付网络
type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required"`
Token string `json:"token" validate:"required"`
Network string `json:"network" validate:"required"`
}
func (r SwitchNetworkRequest) Translates() map[string]string {
return validate.MS{
"TradeId": "订单号",
"Token": "币种",
"Network": "网络",
}
}
+18 -2
View File
@@ -5,8 +5,10 @@ type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址
}
@@ -17,8 +19,22 @@ type OrderNotifyResponse struct {
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
}
// OrderNotifyResponseEpay epay订单异步回调结构体
type OrderNotifyResponseEpay struct {
PID int `json:"pid"` // 商户ID
TradeNo string `json:"trade_no"` // 平台订单号
OutTradeNo string `json:"out_trade_no"` // 商户订单号
Type string `json:"type"` // 订单类型
Name string `json:"name"` // 商品名称
Money string `json:"money"` // 订单金额,保留4位小数
Sign string `json:"sign"` // 签名
SignType string `json:"sign_type"` // 签名类型 // MD5
TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now
}
+8 -2
View File
@@ -2,10 +2,16 @@ package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token"` // 所属币种 TRX USDT......
Currency string `json:"currency"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Network string `json:"network"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"`
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected"`
}
type CheckStatusResponse struct {
+10
View File
@@ -0,0 +1,10 @@
package response
type NetworkTokenSupport struct {
Network string `json:"network"`
Tokens []string `json:"tokens"`
}
type SupportedAssetsResponse struct {
Supports []NetworkTokenSupport `json:"supports"`
}
+286 -66
View File
@@ -1,8 +1,10 @@
package service
import (
"errors"
"fmt"
"math/rand"
"strings"
"sync"
"time"
@@ -12,42 +14,48 @@ import (
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
)
const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额
IncrementalMaximumNumber = 100 // 最大递增次数
CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100
)
var gCreateTransactionLock sync.Mutex
var (
gCreateTransactionLock sync.Mutex
gOrderProcessingLock sync.Mutex
)
// CreateTransaction 创建订单
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network))
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化USDT
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// Usdt是否可以满足最低支付金额
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil {
return nil, err
@@ -55,136 +63,210 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 {
return nil, constant.OrderAlreadyExists
}
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress()
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress)
tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
if availableToken == "" {
if availableAddress == "" {
return nil, constant.NotAvailableAmountErr
}
tx := dao.Mdb.Begin()
order := &mdb.Orders{
TradeId: GenerateCode(),
TradeId: tradeID,
OrderId: req.OrderId,
Amount: req.Amount,
Currency: currency,
ActualAmount: availableAmount,
Token: availableToken,
ReceiveAddress: availableAddress,
Token: token,
Network: network,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
}
err = data.CreateOrderWithTransaction(tx, order)
if err != nil {
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err
}
// 锁定支付池
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
if err = tx.Commit().Error; err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err
}
tx.Commit()
// 超时过期消息队列
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: ExpirationTime,
ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
}
// OrderProcessing 成功处理订单
// OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil {
tx.Rollback()
return err
}
if exist.ID > 0 {
tx.Rollback()
return constant.OrderBlockAlreadyProcess
}
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req)
updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil {
tx.Rollback()
return err
}
// 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount)
if err != nil {
if !updated {
tx.Rollback()
return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
return err
}
tx.Commit()
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
}
// Load order to check parent-child relationship
order, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return nil
}
// 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 nil
}
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
}
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it here to prevent the callback worker from retrying an empty URL.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
}
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
if err != nil {
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
return nil
}
// Mark parent as paid with sub-order's payment details
if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil {
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.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)
}
// Expire sibling sub-orders and release their locks
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return nil
}
for _, sib := range siblings {
if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err)
}
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
}
}
return nil
}
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := ""
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := ""
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
token := address.Token
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
if err != nil {
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
}
if errors.Is(err, data.ErrTransactionLocked) {
continue
}
return "", err
}
if result == "" {
availableWallet = token
break
}
}
return availableWallet, nil
return "", nil
}
for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount)
address, err := tryLockWalletFunc(availableAmount)
if err != nil {
return "", 0, err
}
// 拿不到可用钱包就累加金额
if token == "" {
if address == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue
}
availableToken = token
availableAddress = address
break
}
return availableToken, availableAmount, nil
return availableAddress, availableAmount, nil
}
// GenerateCode 订单号生成
// GenerateCode creates a unique trade id.
func GenerateCode() string {
date := time.Now().Format("20060102")
r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
}
// GetOrderInfoByTradeId 通过交易号获取订单
// GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
@@ -195,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
}
return order, nil
}
const MaxSubOrders = 2
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
network := strings.ToLower(strings.TrimSpace(req.Network))
// 1. Load parent order
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return nil, err
}
if parent.ID <= 0 {
return nil, constant.OrderNotExists
}
if parent.ParentTradeId != "" {
return nil, constant.CannotSwitchSubOrder
}
if parent.Status != mdb.StatusWaitPay {
return nil, constant.OrderNotWaitPay
}
// 2. Same token+network as parent → mark selected and return
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
_ = data.MarkOrderSelected(parent.TradeId)
parent.IsSelected = true
return buildCheckoutResponse(parent), nil
}
// 3. Existing active sub-order for this token+network → return it
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
if err != nil {
return nil, err
}
if existing.ID > 0 {
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
return buildCheckoutResponse(existing), nil
}
// 4. Check sub-order limit
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
if count >= MaxSubOrders {
return nil, constant.SubOrderLimitExceeded
}
// 5. Calculate amount for the new network
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 6. Find and lock wallet
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,
}
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,
}
}
+305
View File
@@ -0,0 +1,305 @@
package service
import (
"fmt"
"sync"
"testing"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
)
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
return &request.CreateTransactionRequest{
OrderId: orderID,
Currency: "CNY",
Token: "USDT",
Network: "tron",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp1.ActualAmount); got != "1.00" {
t.Fatalf("first actual amount = %s, want 1.00", got)
}
if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" {
t.Fatalf("second actual amount = %s, want 1.01", got)
}
if resp1.ReceiveAddress != "wallet_1" || resp2.ReceiveAddress != "wallet_1" {
t.Fatalf("unexpected receive addresses: %s, %s", resp1.ReceiveAddress, resp2.ReceiveAddress)
}
if resp1.Token != "USDT" || resp2.Token != "USDT" {
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
}
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
if err != nil {
t.Fatalf("get first runtime lock: %v", err)
}
if tradeID1 != resp1.TradeId {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
}
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
if err != nil {
t.Fatalf("get second runtime lock: %v", err)
}
if tradeID2 != resp2.TradeId {
t.Fatalf("second runtime lock = %s, want %s", tradeID2, resp2.TradeId)
}
}
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
if err != nil {
t.Fatalf("create transaction: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
})
if err != nil {
t.Fatalf("order processing: %v", err)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("get order by trade id: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("callback confirm = %d, want %d", order.CallBackConfirm, mdb.CallBackConfirmNo)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("get runtime lock after processing: %v", err)
}
if tradeID != "" {
t.Fatalf("runtime lock still exists: %s", tradeID)
}
}
func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
if err != nil {
t.Fatalf("create transaction: %v", err)
}
req := &request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
}
if err = OrderProcessing(req); err != nil {
t.Fatalf("first order processing: %v", err)
}
err = OrderProcessing(req)
if err != constant.OrderBlockAlreadyProcess {
t.Fatalf("second order processing error = %v, want %v", err, constant.OrderBlockAlreadyProcess)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload order after duplicate block: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status after duplicate block = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("order block transaction id after duplicate block = %s, want block_1", order.BlockTransactionId)
}
}
func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if err = dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", resp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force order expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_expired",
})
if err != constant.OrderStatusConflict {
t.Fatalf("order processing error = %v, want %v", err, constant.OrderStatusConflict)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload expired order: %v", err)
}
if order.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", order.Status, mdb.StatusExpired)
}
if order.BlockTransactionId != "" {
t.Fatalf("expired order block transaction id = %s, want empty", order.BlockTransactionId)
}
}
func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
if _, err := data.AddWalletAddress("wallet_2"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
for _, tc := range []struct {
address string
token string
tradeID string
amount float64
}{
{address: resp1.ReceiveAddress, token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount},
{address: resp2.ReceiveAddress, token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount},
} {
wg.Add(1)
go func(address, token, tradeID string, amount float64) {
defer wg.Done()
<-start
errs <- OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
Network: "tron",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: "shared_block",
})
}(tc.address, tc.token, tc.tradeID, tc.amount)
}
close(start)
wg.Wait()
close(errs)
var successCount int
var duplicateCount int
for err := range errs {
switch err {
case nil:
successCount++
case constant.OrderBlockAlreadyProcess:
duplicateCount++
default:
t.Fatalf("unexpected order processing error: %v", err)
}
}
if successCount != 1 || duplicateCount != 1 {
t.Fatalf("success=%d duplicate=%d, want 1 and 1", successCount, duplicateCount)
}
orders := []struct {
tradeID string
}{
{tradeID: resp1.TradeId},
{tradeID: resp2.TradeId},
}
var paidCount int
var pendingCount int
for _, item := range orders {
order, err := data.GetOrderInfoByTradeId(item.tradeID)
if err != nil {
t.Fatalf("reload order %s: %v", item.tradeID, err)
}
switch order.Status {
case mdb.StatusPaySuccess:
paidCount++
if order.BlockTransactionId != "shared_block" {
t.Fatalf("paid order block transaction id = %s, want shared_block", order.BlockTransactionId)
}
case mdb.StatusWaitPay:
pendingCount++
if order.BlockTransactionId != "" {
t.Fatalf("pending order block transaction id = %s, want empty", order.BlockTransactionId)
}
default:
t.Fatalf("unexpected order status for %s: %d", item.tradeID, order.Status)
}
}
if paidCount != 1 || pendingCount != 1 {
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
}
}
+11 -2
View File
@@ -9,21 +9,30 @@ import (
"github.com/assimon/luuu/model/response"
)
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单
var ErrOrder = errors.New("不存在待支付订单或已过期")
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, errors.New("不存在待支付订单或已过期!")
return nil, ErrOrder
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token,
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
}
return resp, nil
}
+609
View File
@@ -0,0 +1,609 @@
package service
import (
"encoding/json"
"errors"
"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/go-resty/resty/v2"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
// 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)
}
}()
// 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
// 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名
queryAddrs := []string{address}
if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil {
queryAddrs = append(queryAddrs, usdtAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err)
}
if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil {
queryAddrs = append(queryAddrs, usdcAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err)
}
// 拉取签名并去重
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 := getTokenTypeAndAmount(transferInfo)
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)
}
// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额
func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) {
mint := transfer.Mint
// Native SOL
if mint == "SOL" {
return "SOL", transfer.Amount
}
// SPL Tokens
switch mint {
case USDT_Mint:
decimals := USDT_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDT", ADJustAmount(transfer.RawAmount, decimals)
case USDC_Mint:
decimals := USDC_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDC", ADJustAmount(transfer.RawAmount, decimals)
default:
// Unsupported token
return "", 0
}
}
const (
// Mint token
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
USDT_Decimals = 6
USDC_Decimals = 6
SOL_Decimals = 9
)
const (
// SPL Token instructions
InstructionTransfer = 3
InstructionTransferChecked = 12
)
const (
// System Program
SystemProgramID = "11111111111111111111111111111111"
InstructionSystemTransfer = 2
)
const (
TokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
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 {
fmt.Printf("FindATAAddress failed: %v\n", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint)
if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", 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 {
fmt.Printf("FindATAAddress failed: %v\n", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func FindATAAddress(owner, mint string) (string, error) {
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
if err != nil {
return "", fmt.Errorf("invalid owner public key: %w", err)
}
mintPubKey, err := solana.PublicKeyFromBase58(mint)
if err != nil {
return "", fmt.Errorf("invalid mint public key: %w", err)
}
ata, _, err := solana.FindAssociatedTokenAddress(ownerPubKey, mintPubKey)
if err != nil {
return "", fmt.Errorf("find associated token address failed: %w", err)
}
return ata.String(), nil
}
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
if programID == SystemProgramID && parsedType == "transfer" {
return parseSystemTransfer(instruction, txData)
}
if programID == TokenProgramID || programID == Token2022ProgramID {
switch parsedType {
case "transfer":
return parseSplTransfer(instruction, txData)
case "transferChecked":
return parseSplTransferChecked(instruction, txData)
}
}
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
return nil, nil
}
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
lamports := info.Get("lamports").Uint()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
return &TransferInfo{
Source: source,
Destination: destination,
Mint: "SOL",
Amount: ADJustAmount(lamports, SOL_Decimals),
RawAmount: lamports,
BlockTime: blockTime,
}, nil
}
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
amountStr := info.Get("amount").String()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
// Look up mint and decimals from postTokenBalances using the destination ATA
mint, decimals, found := findMintFromTokenBalances(destination, txData)
if !found {
// Try source ATA as fallback
mint, decimals, found = findMintFromTokenBalances(source, txData)
}
if !found {
return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
}
d := decimals
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &d,
BlockTime: blockTime,
}, nil
}
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
mint := info.Get("mint").String()
amountStr := info.Get("tokenAmount.amount").String()
decimals := int(info.Get("tokenAmount.decimals").Int())
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &decimals,
BlockTime: blockTime,
}, nil
}
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
accountIndex := -1
for i, key := range accountKeys {
if key.Get("pubkey").String() == ataAddress {
accountIndex = i
break
}
}
if accountIndex == -1 {
return "", 0, false
}
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
for _, balance := range balances {
if int(balance.Get("accountIndex").Int()) == accountIndex {
mint := balance.Get("mint").String()
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
return mint, decimals, true
}
}
return "", 0, false
}
+349
View File
@@ -0,0 +1,349 @@
package service
import (
"encoding/json"
"fmt"
"testing"
"github.com/tidwall/gjson"
)
func TestSolClientHealthy(t *testing.T) {
bodyData, err := SolRetryClient("getHealth", nil)
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)
}
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) {
tests := []struct {
name string
owner string
mint string
want string
}{
{
name: "RAY token ATA",
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
},
}
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)
}
+370 -106
View File
@@ -1,161 +1,425 @@
package service
import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"github.com/assimon/luuu/config"
"github.com/spf13/viper"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20"
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
type UsdtTrc20Resp struct {
PageSize int `json:"page_size"`
Code int `json:"code"`
Data []Data `json:"data"`
}
type TokenInfo struct {
TokenID string `json:"tokenId"`
TokenAbbr string `json:"tokenAbbr"`
TokenName string `json:"tokenName"`
TokenDecimal int `json:"tokenDecimal"`
TokenCanShow int `json:"tokenCanShow"`
TokenType string `json:"tokenType"`
TokenLogo string `json:"tokenLogo"`
TokenLevel string `json:"tokenLevel"`
IssuerAddr string `json:"issuerAddr"`
Vip bool `json:"vip"`
}
type Data struct {
Amount string `json:"amount"`
ApprovalAmount string `json:"approval_amount"`
BlockTimestamp int64 `json:"block_timestamp"`
Block int `json:"block"`
From string `json:"from"`
To string `json:"to"`
Hash string `json:"hash"`
Confirmed int `json:"confirmed"`
ContractType string `json:"contract_type"`
ContracTType int `json:"contractType"`
Revert int `json:"revert"`
ContractRet string `json:"contract_ret"`
EventType string `json:"event_type"`
IssueAddress string `json:"issue_address"`
Decimals int `json:"decimals"`
TokenName string `json:"token_name"`
ID string `json:"id"`
Direction int `json:"direction"`
}
// Trc20CallBack trc20回调
func Trc20CallBack(token string, wg *sync.WaitGroup) {
func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
resp, err := client.R().SetQueryParams(map[string]string{
"sort": "-timestamp",
"limit": "50",
"start": "0",
"direction": "2",
"db_version": "1",
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"address": token,
"start_timestamp": stdutil.ToString(startTime),
"end_timestamp": stdutil.ToString(endTime),
}).Get(UsdtTrc20ApiUri)
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(err)
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
}
var trc20Resp UsdtTrc20Resp
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
if err != nil {
panic(err)
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRX API response indicates failure")
}
if trc20Resp.PageSize <= 0 {
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
return
}
for _, transfer := range trc20Resp.Data {
if transfer.To != token || transfer.ContractRet != "SUCCESS" {
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue
}
decimalQuant, err := decimal.NewFromString(transfer.Amount)
if err != nil {
panic(err)
}
decimalDivisor := decimal.NewFromFloat(1000000)
amount := decimalQuant.Div(decimalDivisor).InexactFloat64()
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
if err != nil {
panic(err)
}
if tradeId == "" {
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue
}
order, err := data.GetOrderInfoByTradeId(tradeId)
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
}
if tron.EncodeCheck(toBytes) != address {
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
if err != nil {
panic(err)
}
// 区块的确认时间必须在订单创建时间之后
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
}
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if transfer.BlockTimestamp < createTime {
panic("Orders cannot actually be matched")
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
// 到这一步就完全算是支付成功了
req := &request.OrderProcessingRequest{
Token: token,
TradeId: tradeId,
ReceiveAddress: address,
Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: transfer.Hash,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
}
// 回调队列
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
// mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5))
// 发送机器人消息
msgTpl := `
<b>📢📢有新的交易支付成功!</b>
<pre>交易号:%s</pre>
<pre>订单号:%s</pre>
<pre>请求支付金额:%f cny</pre>
<pre>实际支付金额:%f usdt</pre>
<pre>钱包地址:%s</pre>
<pre>订单创建时间:%s</pre>
<pre>支付成功时间:%s</pre>
`
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
telegram.SendToBot(msg)
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
}
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRC20 API response indicates failure")
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
continue
}
if transfer.Get("to").String() != address {
continue
}
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
if err != nil {
panic(err)
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
}
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
}
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 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
}
}()
var usdt, usdc common.Address
var polygonUsdcE common.Address
switch chainNetwork {
case mdb.NetworkEthereum:
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
case mdb.NetworkBsc:
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
case mdb.NetworkPolygon:
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
case mdb.NetworkPlasma:
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
default:
return
}
var tokenSym string
switch {
case contract == usdt:
tokenSym = "USDT"
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
tokenSym = "USDC"
default:
net := evmChainLogLabel(chainNetwork)
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
return
}
net := evmChainLogLabel(chainNetwork)
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
}
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(chainNetwork, walletAddr, tokenSym, amount)
if err != nil {
log.Sugar.Warnf("[%s-%s][%s] lock lookup: %v", net, tokenSym, walletAddr, err)
return
}
if tradeID == "" {
log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
return
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Warnf("[%s-%s][%s] load order: %v", net, tokenSym, walletAddr, err)
return
}
if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
return
}
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
Token: tokenSym,
Network: chainNetwork,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[%s-%s][%s] skip resolved trade_id=%s hash=%s err=%v", net, tokenSym, walletAddr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
}
func sendPaymentNotification(order *mdb.Orders) {
msg := fmt.Sprintf(
"🎉 <b>收款成功通知</b>\n\n"+
"💰 <b>金额信息</b>\n"+
"├ 订单金额:<code>%.2f %s</code>\n"+
"└ 实际到账:<code>%.2f %s</code>\n\n"+
"📋 <b>订单信息</b>\n"+
"├ 交易号:<code>%s</code>\n"+
"├ 订单号:<code>%s</code>\n"+
"├ 网络:<code>%s</code>\n"+
"└ 钱包地址:<code>%s</code>\n\n"+
"⏰ <b>时间信息</b>\n"+
"├ 创建时间:%s\n"+
"└ 支付时间:%s",
order.Amount,
strings.ToUpper(order.Currency),
order.ActualAmount,
strings.ToUpper(order.Token),
order.TradeId,
order.OrderId,
networkDisplay(order.Network),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(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)
}
}
-70
View File
@@ -1,70 +0,0 @@
package handle
import (
"context"
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/hibiken/asynq"
)
const QueueOrderCallback = "order:callback"
func NewOrderCallbackQueue(order *mdb.Orders) (*asynq.Task, error) {
payload, err := json.Cjson.Marshal(order)
if err != nil {
return nil, err
}
return asynq.NewTask(QueueOrderCallback, payload,
asynq.Retention(config.GetOrderExpirationTimeDuration()),
), nil
}
func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error {
var order mdb.Orders
err := json.Cjson.Unmarshal(t.Payload(), &order)
if err != nil {
return err
}
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
defer func() {
data.SaveCallBackOrdersResp(&order)
}()
client := http_client.GetHttpClient()
orderResp := response.OrderNotifyResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
ActualAmount: order.ActualAmount,
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().SetHeader("powered-by", "Epusdt(https://github.com/assimon/epusdt)").SetBody(orderResp).Post(order.NotifyUrl)
if err != nil {
return err
}
body := string(resp.Body())
if body != "ok" {
order.CallBackConfirm = mdb.CallBackConfirmNo
return errors.New("not ok")
}
order.CallBackConfirm = mdb.CallBackConfirmOk
return nil
}
-39
View File
@@ -1,39 +0,0 @@
package handle
import (
"context"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/hibiken/asynq"
)
const QueueOrderExpiration = "order:expiration"
func NewOrderExpirationQueue(tradeId string) (*asynq.Task, error) {
return asynq.NewTask(QueueOrderExpiration, []byte(tradeId),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
), nil
}
// OrderExpirationHandle 设置订单过期
func OrderExpirationHandle(ctx context.Context, t *asynq.Task) error {
tradeId := string(t.Payload())
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil
}
err = data.UpdateOrderIsExpirationById(orderInfo.ID)
if err != nil {
return err
}
err = data.UnLockTransaction(orderInfo.Token, orderInfo.ActualAmount)
if err != nil {
return err
}
return nil
}
+13 -43
View File
@@ -1,52 +1,22 @@
package mq
import (
"fmt"
"sync"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/log"
"github.com/hibiken/asynq"
"github.com/spf13/viper"
"github.com/assimon/luuu/config"
)
var MClient *asynq.Client
var (
startOnce sync.Once
callbackLimiter chan struct{}
callbackInflight sync.Map
)
func Start() {
redis := asynq.RedisClientOpt{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")),
DB: viper.GetInt("redis_db"),
Password: viper.GetString("redis_passwd"),
}
initClient(redis)
go initListen(redis)
}
func initClient(redis asynq.RedisClientOpt) {
MClient = asynq.NewClient(redis)
}
func initListen(redis asynq.RedisClientOpt) {
srv := asynq.NewServer(
redis,
asynq.Config{
// Specify how many concurrent workers to use
Concurrency: viper.GetInt("queue_concurrency"),
// Optionally specify multiple queues with different priority.
Queues: map[string]int{
"critical": viper.GetInt("queue_level_critical"),
"default": viper.GetInt("queue_level_default"),
"low": viper.GetInt("queue_level_low"),
},
Logger: log.Sugar,
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(handle.QueueOrderExpiration, handle.OrderExpirationHandle)
mux.HandleFunc(handle.QueueOrderCallback, handle.OrderCallbackHandle)
if err := srv.Run(mux); err != nil {
log.Sugar.Fatalf("[queue] could not run server: %v", err)
}
startOnce.Do(func() {
callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
go runOrderExpirationLoop()
go runOrderCallbackLoop()
go runTransactionLockCleanupLoop()
})
}
+311
View File
@@ -0,0 +1,311 @@
package mq
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
)
const batchSize = 100
const sqliteBusyRetryAttempts = 3
type expirableOrder struct {
ID uint64 `gorm:"column:id"`
TradeId string `gorm:"column:trade_id"`
Network string `gorm:"column:network"`
ReceiveAddress string `gorm:"column:receive_address"`
Token string `gorm:"column:token"`
ActualAmount float64 `gorm:"column:actual_amount"`
}
func runOrderExpirationLoop() {
runLoop("order_expiration", processExpiredOrders)
}
func runOrderCallbackLoop() {
runLoop("order_callback", dispatchPendingCallbacks)
}
func runTransactionLockCleanupLoop() {
runLoop("transaction_lock_cleanup", cleanupExpiredTransactionLocks)
}
func runLoop(name string, fn func()) {
safeRun(name, fn)
ticker := time.NewTicker(config.GetQueuePollInterval())
defer ticker.Stop()
for range ticker.C {
safeRun(name, fn)
}
}
func safeRun(name string, fn func()) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[mq] %s panic: %v", name, err)
}
}()
fn()
}
func processExpiredOrders() {
expirationCutoff := time.Now().Add(-config.GetOrderExpirationTimeDuration())
for {
var orders []expirableOrder
err := withSQLiteBusyRetry(func() error {
return dao.Mdb.Model(&mdb.Orders{}).
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Order("id asc").
Limit(batchSize).
Find(&orders).Error
})
if err != nil {
log.Sugar.Errorf("[mq] query expired orders failed: %v", err)
return
}
if len(orders) == 0 {
return
}
for _, order := range orders {
expired, err := data.UpdateOrderIsExpirationById(order.ID, expirationCutoff)
if err != nil {
log.Sugar.Errorf("[mq] expire order failed, trade_id=%s, err=%v", order.TradeId, err)
continue
}
if !expired {
continue
}
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)
}
}
if len(orders) < batchSize {
return
}
}
}
func dispatchPendingCallbacks() {
maxRetry := config.GetOrderNoticeMaxRetry()
var orders []data.PendingCallbackOrder
err := withSQLiteBusyRetry(func() error {
var innerErr error
orders, innerErr = data.GetPendingCallbackOrders(maxRetry, batchSize)
return innerErr
})
if err != nil {
log.Sugar.Errorf("[mq] query callback orders failed: %v", err)
return
}
now := time.Now()
for _, order := range orders {
if !isCallbackDue(&order, now, maxRetry) {
continue
}
tradeID := order.TradeId
if _, loaded := callbackInflight.LoadOrStore(tradeID, struct{}{}); loaded {
continue
}
select {
case callbackLimiter <- struct{}{}:
go processCallback(tradeID)
default:
callbackInflight.Delete(tradeID)
return
}
}
}
func processCallback(tradeID string) {
defer func() {
<-callbackLimiter
callbackInflight.Delete(tradeID)
}()
freshOrder, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[mq] reload callback order failed, trade_id=%s, err=%v", tradeID, err)
return
}
if freshOrder.ID <= 0 || freshOrder.Status != mdb.StatusPaySuccess || freshOrder.CallBackConfirm != mdb.CallBackConfirmNo {
return
}
if err = sendOrderCallback(freshOrder); err != nil {
log.Sugar.Warnf("[mq] callback request failed, trade_id=%s, attempt=%d, err=%v", freshOrder.TradeId, freshOrder.CallbackNum+1, err)
freshOrder.CallBackConfirm = mdb.CallBackConfirmNo
} else {
freshOrder.CallBackConfirm = mdb.CallBackConfirmOk
}
if err = data.SaveCallBackOrdersResp(freshOrder); err != nil {
log.Sugar.Errorf("[mq] save callback result failed, trade_id=%s, err=%v", freshOrder.TradeId, err)
}
}
func sendOrderCallback(order *mdb.Orders) error {
switch order.PaymentType {
case mdb.PaymentTypeEpay:
// 构造 EPay 标准回调参数
notifyData := response.OrderNotifyResponseEpay{
PID: config.GetEpayPid(),
TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号
OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数
Type: "alipay",
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr2, err := sign.Get(notifyData, config.GetEpayKey())
if err != nil {
return err
}
// 使用 form-encoded POSTEPay 标准协议格式)
formData := url.Values{
"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"},
}
resp, err := http.PostForm(order.NotifyUrl, formData)
if err != nil {
return err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody))
default:
client := http_client.GetHttpClient()
orderResp := response.OrderNotifyResponse{
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, config.GetApiAuthToken())
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 string(resp.Body()) != "ok" {
return errors.New("not ok")
}
}
return nil
}
func cleanupExpiredTransactionLocks() {
if err := data.CleanupExpiredTransactionLocks(); err != nil {
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)
}
}
func withSQLiteBusyRetry(fn func() error) error {
var err error
for attempt := 1; attempt <= sqliteBusyRetryAttempts; attempt++ {
err = fn()
if err == nil {
return nil
}
if !isSQLiteBusyError(err) || attempt == sqliteBusyRetryAttempts {
return err
}
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
}
return err
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "database is locked") || strings.Contains(msg, "sqlite_busy")
}
func isCallbackDue(order *data.PendingCallbackOrder, now time.Time, maxRetry int) bool {
if order.CallBackConfirm != mdb.CallBackConfirmNo {
return false
}
if order.CallbackNum > maxRetry {
return false
}
if order.CallbackNum == 0 {
return true
}
nextRunAt := order.UpdatedAt.StdTime().Add(callbackRetryDelay(order.CallbackNum))
return !nextRunAt.After(now)
}
func callbackRetryDelay(attempts int) time.Duration {
if attempts <= 0 {
return 0
}
delay := config.GetCallbackRetryBaseDuration()
maxDelay := 5 * time.Minute
for i := 1; i < attempts; i++ {
if delay >= maxDelay/2 {
return maxDelay
}
delay *= 2
}
if delay > maxDelay {
return maxDelay
}
return delay
}
+273
View File
@@ -0,0 +1,273 @@
package mq
import (
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
)
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
order := &mdb.Orders{
TradeId: "trade_expired",
OrderId: "order_expired",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_1",
Token: "USDT",
Network: "tron",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create expired order: %v", err)
}
if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
t.Fatalf("age expired order: %v", err)
}
if err := data.LockTransaction("tron", order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock expired order: %v", err)
}
recentOrder := &mdb.Orders{
TradeId: "trade_recent",
OrderId: "order_recent",
Amount: 1,
Currency: "CNY",
ActualAmount: 1.01,
ReceiveAddress: "wallet_1",
Token: "USDT",
Network: "tron",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
}
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
t.Fatalf("create recent order: %v", err)
}
if err := data.LockTransaction("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock recent order: %v", err)
}
processExpiredOrders()
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload expired order: %v", err)
}
if expired.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", order.ReceiveAddress, order.Token, order.ActualAmount)
if err != nil {
t.Fatalf("expired order lock lookup: %v", err)
}
if lockTradeID != "" {
t.Fatalf("expired order lock still exists: %s", lockTradeID)
}
recent, err := data.GetOrderInfoByTradeId(recentOrder.TradeId)
if err != nil {
t.Fatalf("reload recent order: %v", err)
}
if recent.Status != mdb.StatusWaitPay {
t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay)
}
lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
if err != nil {
t.Fatalf("recent order lock lookup: %v", err)
}
if lockTradeID != recentOrder.TradeId {
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
}
}
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
order := &mdb.Orders{
TradeId: "trade_paid",
OrderId: "order_paid",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_1",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: "https://merchant.example/callback",
BlockTransactionId: "block_paid",
CallBackConfirm: mdb.CallBackConfirmNo,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create paid order: %v", err)
}
if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
t.Fatalf("age paid order: %v", err)
}
processExpiredOrders()
current, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload paid order: %v", err)
}
if current.Status != mdb.StatusPaySuccess {
t.Fatalf("paid order status = %d, want %d", current.Status, mdb.StatusPaySuccess)
}
if current.BlockTransactionId != "block_paid" {
t.Fatalf("paid order block transaction id = %s, want block_paid", current.BlockTransactionId)
}
}
func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback",
OrderId: "order_callback",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_1",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback",
CallbackNum: 1,
CallBackConfirm: mdb.CallBackConfirmNo,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
time.Sleep(200 * time.Millisecond)
if got := atomic.LoadInt32(&requestCount); got != 0 {
t.Fatalf("unexpected callback count before backoff elapsed: %d", got)
}
if err := dao.Mdb.Model(order).UpdateColumn("updated_at", time.Now().Add(-2*time.Second)).Error; err != nil {
t.Fatalf("age callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 2
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempt := atomic.AddInt32(&requestCount, 1)
if attempt == 1 {
http.Error(w, "retry later", http.StatusInternalServerError)
return
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_restart",
OrderId: "order_callback_restart",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_restart",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_restart",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
}
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, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmNo && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("first callback request count = %d, want 1", got)
}
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
if err := dao.Mdb.Model(order).UpdateColumn("updated_at", time.Now().Add(-2*time.Second)).Error; err != nil {
t.Fatalf("age callback order for retry: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 2
})
if got := atomic.LoadInt32(&requestCount); got != 2 {
t.Fatalf("total callback request count = %d, want 2", got)
}
}
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not satisfied before timeout")
}
+161 -13
View File
@@ -1,27 +1,175 @@
package route
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4"
"net/http"
)
// RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello epusdt, https://github.com/assimon/epusdt")
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
})
// ==== 支付相关=====
payRoute := e.Group("/pay")
// 收银台
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
// 状态检测
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
apiV1Route := e.Group("/api/v1")
// ====订单相关====
orderRoute := apiV1Route.Group("/order", middleware.CheckApiSign())
// 创建订单
orderRoute.POST("/create-transaction", comm.Ctrl.CreateTransaction)
payRoute := e.Group("/pay")
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
// payment routes
paymentRoute := e.Group("/payments")
// for epusdt
epusdtV1 := paymentRoute.Group("/epusdt/v1")
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
// add default token and currency for old plugin
body := make(map[string]interface{})
if err := ctx.Bind(&body); err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
if _, ok := body["token"]; !ok {
body["token"] = "usdt"
}
if _, ok := body["currency"]; !ok {
body["currency"] = "cny"
}
if _, ok := body["network"]; !ok {
body["network"] = "tron"
}
ctx.Set("request_body", body)
jsonBytes, err := json.Marshal(body)
if err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
ctx.Request().ContentLength = int64(len(jsonBytes))
return comm.Ctrl.CreateTransaction(ctx)
}, middleware.CheckApiSign())
// gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken())
gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken())
// wallet management routes
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
walletV1.POST("/add", comm.Ctrl.AddWallet)
walletV1.GET("/list", comm.Ctrl.ListWallets)
walletV1.GET("/:id", comm.Ctrl.GetWallet)
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
// epay v1 routes
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]
}
}
copyParams(ctx.QueryParams())
formParams, err := ctx.FormParams()
if err != nil && ctx.Request().Method == http.MethodPost {
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
}
if err == nil {
copyParams(formParams)
}
getString := func(m map[string]interface{}, key string) string {
v, ok := m[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
signstr := getString(params, "sign")
if signstr == "" {
return constant.SignatureErr
}
delete(params, "sign")
delete(params, "sign_type")
// we need to add pid to params for signature verification
params["pid"] = config.GetEpayPid()
checkSignature, err := sign.Get(params, config.GetApiAuthToken())
if err != nil {
return constant.SignatureErr
}
if checkSignature != signstr {
return constant.SignatureErr
}
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": "usdt",
"currency": "cny",
"network": "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)
jsonBytes, err := json.Marshal(body)
if err != nil {
return comm.Ctrl.FailJson(ctx, err)
}
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
ctx.Request().ContentLength = int64(len(jsonBytes))
ctx.Request().Method = http.MethodPost
ctx.Request().Header.Set("Content-Type", "application/json")
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
})
}
+604
View File
@@ -0,0 +1,604 @@
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/mdb"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4"
"github.com/spf13/viper"
)
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("api_auth_token", testAPIToken)
viper.Set("epay_pid", 1)
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.SupportedAsset{})
// 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 supported assets if empty
var supportCnt int64
dao.Mdb.Model(&mdb.SupportedAsset{}).Count(&supportCnt)
if supportCnt == 0 {
dao.Mdb.Create(&[]mdb.SupportedAsset{
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
})
}
e := echo.New()
RegisterRoute(e)
return e
}
func signBody(body map[string]interface{}) map[string]interface{} {
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
}
// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network.
func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
e := setupTestEnv(t)
body := signBody(map[string]interface{}{
"order_id": "test-tron-001",
"amount": 1.00,
"notify_url": "http://localhost/notify",
})
rec := doPost(e, "/payments/epusdt/v1/order/create-transaction", body)
t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
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"] != "TTestTronAddress001" {
t.Errorf("expected tron address, got: %v", data["receive_address"])
}
t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
}
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
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 doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", testAPIToken)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func doPostWithToken(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("Authorization", testAPIToken)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return resp
}
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)
}
}
}
func TestSupportedAssetCRUD(t *testing.T) {
e := setupTestEnv(t)
// public query list (no auth)
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/records", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public list failed: %v", resp)
}
// create requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arb",
"token": "usdt",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add supported asset failed: %v", resp)
}
created := resp["data"].(map[string]interface{})
assetID := fmt.Sprintf("%.0f", created["id"].(float64))
// get by id is public
req = httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/"+assetID, nil)
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("public get by id failed: %v", resp)
}
// update requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/update", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("update supported asset failed: %v", resp)
}
updated := resp["data"].(map[string]interface{})
if updated["network"] != "arbitrum" || updated["token"] != "USDC" {
t.Fatalf("unexpected updated data: %v", updated)
}
// delete requires auth
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/delete", nil)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("delete supported asset failed: %v", resp)
}
// recreate after delete should restore soft-deleted row, not unique-conflict
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("recreate after delete failed: %v", resp)
}
}
// TestWalletAddAndList tests adding wallets via API and listing them.
func TestWalletAddAndList(t *testing.T) {
e := setupTestEnv(t)
// Add a solana wallet
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "solana",
"address": "NewSolWallet001",
})
t.Logf("Add: %s", rec.Body.String())
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add wallet failed: %v", resp)
}
// Add a tron wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "tron",
"address": "NewTronWallet001",
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("add tron wallet failed: %v", resp)
}
// List all wallets
rec = doGet(e, "/payments/gmpay/v1/wallet/list")
resp = parseResp(t, rec)
wallets := resp["data"].([]interface{})
// 2 seeded + 2 added = 4
if len(wallets) != 4 {
t.Fatalf("expected 4 wallets, got %d: %v", len(wallets), wallets)
}
// List by network
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
resp = parseResp(t, rec)
wallets = resp["data"].([]interface{})
if len(wallets) != 2 {
t.Fatalf("expected 2 solana wallets, got %d", len(wallets))
}
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=tron")
resp = parseResp(t, rec)
wallets = resp["data"].([]interface{})
if len(wallets) != 2 {
t.Fatalf("expected 2 tron wallets, got %d", len(wallets))
}
}
// TestWalletDuplicateRejected tests that adding the same network+address twice fails.
func TestWalletDuplicateRejected(t *testing.T) {
e := setupTestEnv(t)
body := map[string]interface{}{"network": "solana", "address": "DupWallet001"}
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
resp := parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("first add failed: %v", resp)
}
// Same network+address should fail
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
resp = parseResp(t, rec)
if resp["status_code"].(float64) == 200 {
t.Fatal("expected duplicate to be rejected")
}
t.Logf("Duplicate rejected: %v", resp["message"])
// Same address, different network should succeed
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "tron",
"address": "DupWallet001",
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("same address on different network should succeed: %v", resp)
}
}
// TestWalletStatusAndDelete tests enable/disable/delete operations.
func TestWalletStatusAndDelete(t *testing.T) {
e := setupTestEnv(t)
// Add a wallet
rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
"network": "solana",
"address": "StatusTestWallet",
})
resp := parseResp(t, rec)
wallet := resp["data"].(map[string]interface{})
walletID := fmt.Sprintf("%.0f", wallet["id"].(float64))
// Get wallet
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("get wallet failed: %v", resp)
}
// Disable wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/status", map[string]interface{}{
"status": 2,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("disable wallet failed: %v", resp)
}
// Verify disabled — should not appear in available list
rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
resp = parseResp(t, rec)
wallets := resp["data"].([]interface{})
for _, w := range wallets {
wm := w.(map[string]interface{})
if wm["address"] == "StatusTestWallet" && wm["status"].(float64) != 2 {
t.Error("wallet should be disabled")
}
}
// Delete wallet
rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/delete", nil)
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("delete wallet failed: %v", resp)
}
// Verify deleted
rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
resp = parseResp(t, rec)
// Should return not found
if resp["status_code"].(float64) == 200 {
data := resp["data"].(map[string]interface{})
if data["id"].(float64) > 0 {
t.Error("wallet should be deleted")
}
}
}
// TestWalletAuthRequired tests that wallet APIs require auth token.
func TestWalletAuthRequired(t *testing.T) {
e := setupTestEnv(t)
// No auth header — should not return success
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/wallet/list", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// The response should indicate auth failure (not 200 success)
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
// echo may return plain text error
if rec.Code == http.StatusOK {
t.Error("expected auth failure without token")
}
t.Logf("Auth rejected (non-JSON): status=%d body=%s", rec.Code, rec.Body.String())
return
}
statusCode, _ := resp["status_code"].(float64)
if statusCode == 200 {
t.Error("expected auth failure without token")
}
t.Logf("Auth rejected: %v", resp)
}
// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
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"))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+501 -198
View File
File diff suppressed because one or more lines are too long
+972
View File
@@ -0,0 +1,972 @@
/* =============================================================
* GM Pay — payment.js
* 后端对接只需关注:
* 1. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址)
* 2. CONFIG.api.checkStatus — 轮询接口 URL
* 3. CONFIG.api.statusMap — 状态码映射
* 4. index.html 底部 <script> 中 ORDER / PAYMENT_OPTIONS 变量
* ============================================================= */
'use strict';
// ── DOM helpers ───────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const $$ = (sel) => document.querySelectorAll(sel);
const setText = (id, value) => {
const el = $(id);
if (el) el.textContent = value;
return el;
};
const setHtml = (id, value) => {
const el = $(id);
if (el) el.innerHTML = value;
return el;
};
// ── 全局配置(可按项目调整)─────────────────────────────────
const CONFIG = {
poll: {
interval: 5000, // 轮询间隔(毫秒)
maxErrors: 5, // 最多连续错误次数,超出进入 timeout 状态
timeout: 8000, // 单次请求超时(毫秒)
},
redirect: {
delay: 3000, // 支付成功后自动跳转延迟(毫秒)
},
// ---- 后端接口 ----
api: {
// 切换网络接口:POST { trade_id, token, network },返回完整订单对象
selectMethod: () => '/pay/switch-network',
// 轮询接口:GET,返回 { data: { status: number } }
checkStatus: (tradeId) => `/pay/check-status/${encodeURIComponent(tradeId)}`,
// status 状态码映射
statusMap: {
paid: 2, // 支付成功
expired: 3, // 已过期
},
},
// ---- 连接钱包 ----
wallet: {
enabled: false, // false = 隐藏「连接钱包支付」按钮
// TronLink deeplink,如需支持其他钱包替换此处
deeplink: (currentUrl) => {
const param = JSON.stringify({
url: currentUrl,
action: 'open',
dapp_name: 'GM Pay',
dapp_icon: 'https://www.gmwallet.app/favicon.png',
});
return 'tronlinkoutside://pull.activity?param=' + encodeURIComponent(param);
},
},
};
// ═══════════════════════════════════════════════════════════════
// SECTION 1 — 链图标 / 网络渲染
// ═══════════════════════════════════════════════════════════════
const _CHAIN_PRESETS = {
Ethereum: { bg: 'hsla(0,0%,55%,0.25)', color: 'hsla(0,0%,55%,1)', icon: 'ethereum' },
Solana: { bg: 'hsla(256,85%,65%,0.25)', color: 'hsla(256,85%,65%,1)', icon: 'solana' },
BSC: { bg: 'hsla(46,91%,49%,0.25)', color: 'hsla(46,91%,49%,1)', icon: 'bsc' },
Arbitrum: { bg: 'hsla(202,100%,54%,0.25)', color: 'hsla(202,100%,54%,1)', icon: 'arbitrum' },
TRON: { bg: 'hsla(350,100%,47%,0.20)', color: 'hsla(350,100%,47%,1)', icon: 'tron' },
Polygon: { bg: 'hsla(263,73%,56%,0.25)', color: 'hsla(263,73%,56%,1)', icon: 'polygon' },
Base: { bg: 'hsla(240,100%,61%,0.25)', color: 'hsla(240,100%,61%,1)', icon: 'base' },
OP: { bg: 'hsla(353,99%,51%,0.25)', color: 'hsla(353,99%,51%,1)', icon: 'op' },
HyperEVM: { bg: 'hsla(166,94%,79%,0.20)', color: 'hsla(166,94%,79%,1)', icon: 'hyperevm' },
Plasma: { bg: 'hsla(166,64%,32%,0.25)', color: 'hsla(166,64%,32%,1)', icon: 'plasma' },
Bitcoin: { bg: 'hsla(33,93%,54%,0.20)', color: 'hsla(33,93%,54%,1)', icon: 'bitcoin' },
Binance: { bg: 'hsla(46,91%,49%,0.24)', color: 'hsla(46,91%,49%,1)', icon: 'binance' },
};
const _CHAIN_ALIASES = {
evm:'Ethereum', eth:'Ethereum', ethereum:'Ethereum',
bsc:'BSC', bnb:'BSC',
arbitrum:'Arbitrum', arb:'Arbitrum',
sol:'Solana', solana:'Solana',
tron:'TRON', trx:'TRON',
polygon:'Polygon', matic:'Polygon', pol:'Polygon',
base:'Base',
op:'OP', optimism:'OP',
hyperevm:'HyperEVM', hyperliquid:'HyperEVM', hype:'HyperEVM',
plasma:'Plasma', xpl:'Plasma',
bitcoin:'Bitcoin', btc:'Bitcoin',
binance:'Binance', bnb_chain:'Binance',
};
const IMAGE_PREFIX = {
chain: '/static/images/',
tokenTrigger: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
tokenMenu: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
};
function _resolveChain(name) {
if (!name) return null;
const k = name.trim().toLowerCase();
const canonical = _CHAIN_ALIASES[k] || Object.keys(_CHAIN_PRESETS).find(c => c.toLowerCase() === k);
return canonical ? { label: canonical, ..._CHAIN_PRESETS[canonical] } : null;
}
/** 用于 order-info 顶部的网络 tag badge */
function networkTag(network) {
const chain = _resolveChain(network);
if (!chain) return `<span style="font-weight:600;color:var(--card-foreground)">${network}</span>`;
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px 2px 4px;border-radius:99px;background:${chain.bg};color:${chain.color};font-size:12px;font-weight:600;line-height:1.6">
<img src="${IMAGE_PREFIX.chain}${chain.icon}.png" width="14" height="14" style="width:14px;height:14px;border-radius:50%;object-fit:cover;flex-shrink:0" />
${chain.label}
</span>`;
}
/** 选择器触发器显示:网络 */
function _networkTriggerHtml(network) {
const chain = _resolveChain(network);
if (!chain) return `<span class="text-sm font-semibold" style="color:var(--card-foreground)">${network || '--'}</span>`;
return `<span style="display:inline-flex;align-items:center;gap:7px">
<img src="${IMAGE_PREFIX.chain}${chain.icon}.png" width="18" height="18" style="width:18px;height:18px;border-radius:50%;object-fit:cover;flex-shrink:0" />
<span class="text-sm font-semibold" style="color:var(--card-foreground)">${chain.label}</span>
</span>`;
}
/** 选择器触发器显示:Token */
function _tokenTriggerHtml(token) {
if (!token) return `<span class="text-sm font-semibold" style="color:var(--card-foreground)">--</span>`;
const src = `${IMAGE_PREFIX.tokenTrigger}${token.toLowerCase()}.png`;
return `<span style="display:inline-flex;align-items:center;gap:7px">
<img src="${src}" width="18" height="18" style="width:18px;height:18px;border-radius:50%;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'" />
<span class="text-sm font-semibold" style="color:var(--card-foreground)">${token}</span>
</span>`;
}
/** 下拉菜单中的网络选项 */
function _networkMenuItemHtml(opt, selected) {
const chain = _resolveChain(opt.network);
const icon = chain
? `<img src="${IMAGE_PREFIX.chain}${chain.icon}.png" width="16" height="16" style="width:16px;height:16px;border-radius:50%;object-fit:cover;flex-shrink:0" />`
: '';
return `<div class="select-option menu-item${selected ? ' is-selected' : ''}"
onclick="step1SetNetwork('${opt.network}')"
style="display:flex;align-items:center;gap:8px">${icon}${chain ? chain.label : opt.network}</div>`;
}
// ═══════════════════════════════════════════════════════════════
// SECTION 2 — 主题 & Lucide 图标
// ═══════════════════════════════════════════════════════════════
if (typeof lucide !== 'undefined') lucide.createIcons();
let currentTheme = localStorage.getItem('theme') || 'dark';
applyTheme(currentTheme);
function applyTheme(t) {
currentTheme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
$('icon-moon').style.display = t === 'light' ? 'block' : 'none';
$('icon-sun').style.display = t === 'dark' ? 'block' : 'none';
}
function toggleTheme(e) {
const next = currentTheme === 'light' ? 'dark' : 'light';
if (!document.startViewTransition) { applyTheme(next); return; }
const x = e ? e.clientX : window.innerWidth / 2;
const y = e ? e.clientY : window.innerHeight / 2;
const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
document.startViewTransition(() => applyTheme(next)).ready.then(() => {
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },
{ duration: 420, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)' }
);
});
}
// ═══════════════════════════════════════════════════════════════
// SECTION 3 — 国际化 (i18n)
// ═══════════════════════════════════════════════════════════════
const LANGS = {
en: {
scan_title: 'Scan or copy address to pay',
amount_to_pay: 'Amount to pay',
payment_address: 'Payment address',
i_have_transferred: 'I have transferred',
checking_blockchain: 'Checking blockchain…',
expires: 'Expires',
copied: 'Copied to clipboard',
verifying: 'Verifying…',
payment_success: 'Payment Successful',
redirecting: 'Redirecting…',
payment_expired: 'Payment Expired',
expired_sub: 'Please initiate a new payment',
order_id: 'Order ID',
order_amount: 'Order amount',
network_timeout: 'Connection Timeout',
timeout_sub: 'Unable to connect to the payment server',
retry: 'Retry',
back: 'Back',
order_not_found: 'Order Not Found',
not_found_sub: 'The order does not exist or has already expired',
select_method: 'Select payment method',
currency_label: 'Currency',
network_label: 'Network',
confirm: 'Confirm',
connect_wallet: 'Connect Wallet to Pay',
},
zh: {
scan_title: '扫码或复制地址付款',
amount_to_pay: '付款金额',
payment_address: '付款地址',
i_have_transferred: '我已转账',
checking_blockchain: '链上核验中…',
expires: '到期时间',
copied: '已复制到剪贴板',
verifying: '核验中…',
payment_success: '支付成功',
redirecting: '正在跳转…',
payment_expired: '支付已过期',
expired_sub: '请重新发起支付',
order_id: '订单 ID',
order_amount: '订单金额',
network_timeout: '连接超时',
timeout_sub: '无法连接至支付服务器',
retry: '重试',
back: '返回',
order_not_found: '订单不存在',
not_found_sub: '待支付订单不存在或已过期',
select_method: '选择支付方式',
currency_label: '币种',
network_label: '网络',
confirm: '确认',
connect_wallet: '连接钱包支付',
},
ja: {
scan_title: 'アドレスをスキャンまたはコピーして支払う',
amount_to_pay: '支払い金額',
payment_address: '支払いアドレス',
i_have_transferred: '送金しました',
checking_blockchain: 'ブロックチェーン確認中…',
expires: '有効期限',
copied: 'コピーしました',
verifying: '確認中…',
payment_success: '支払い完了',
redirecting: 'リダイレクト中…',
payment_expired: '支払い期限切れ',
expired_sub: '新しい支払いを開始してください',
order_id: '注文 ID',
order_amount: '注文金額',
network_timeout: '接続タイムアウト',
timeout_sub: '支払いサーバーに接続できません',
retry: '再試行',
back: '戻る',
order_not_found: '注文が見つかりません',
not_found_sub: '注文が存在しないか、すでに期限切れです',
select_method: '支払い方法を選択',
currency_label: '通貨',
network_label: 'ネットワーク',
confirm: '確認',
connect_wallet: 'ウォレットで支払う',
},
ko: {
scan_title: '주소를 스캔하거나 복사하여 결제',
amount_to_pay: '결제 금액',
payment_address: '결제 주소',
i_have_transferred: '이체 완료',
checking_blockchain: '블록체인 확인 중…',
expires: '만료 시간',
copied: '복사됨',
verifying: '확인 중…',
payment_success: '결제 성공',
redirecting: '리다이렉트 중…',
payment_expired: '결제 만료',
expired_sub: '새로운 결제를 시작하세요',
order_id: '주문 ID',
order_amount: '주문 금액',
network_timeout: '연결 시간 초과',
timeout_sub: '결제 서버에 연결할 수 없습니다',
retry: '다시 시도',
back: '돌아가기',
order_not_found: '주문 없음',
not_found_sub: '주문이 존재하지 않거나 이미 만료되었습니다',
select_method: '결제 수단 선택',
currency_label: '통화',
network_label: '네트워크',
confirm: '확인',
connect_wallet: '지갑으로 결제',
},
'zh-hk': {
scan_title: '掃碼或複製地址付款',
amount_to_pay: '付款金額',
payment_address: '付款地址',
i_have_transferred: '我已轉帳',
checking_blockchain: '鏈上核驗中…',
expires: '到期時間',
copied: '已複製到剪貼簿',
verifying: '核驗中…',
payment_success: '支付成功',
redirecting: '正在跳轉…',
payment_expired: '支付已過期',
expired_sub: '請重新發起支付',
order_id: '訂單 ID',
order_amount: '訂單金額',
network_timeout: '連線逾時',
timeout_sub: '無法連線至支付伺服器',
retry: '重試',
back: '返回',
order_not_found: '訂單不存在',
not_found_sub: '待支付訂單不存在或已過期',
select_method: '選擇支付方式',
currency_label: '幣種',
network_label: '網絡',
confirm: '確認',
connect_wallet: '連接錢包支付',
},
ru: {
scan_title: 'Отсканируйте или скопируйте адрес для оплаты',
amount_to_pay: 'Сумма к оплате',
payment_address: 'Адрес для оплаты',
i_have_transferred: 'Я перевёл',
checking_blockchain: 'Проверка блокчейна…',
expires: 'Истекает',
copied: 'Скопировано',
verifying: 'Проверка…',
payment_success: 'Оплата прошла успешно',
redirecting: 'Перенаправление…',
payment_expired: 'Срок оплаты истёк',
expired_sub: 'Пожалуйста, создайте новый платёж',
order_id: 'Заказ ID',
order_amount: 'Сумма заказа',
network_timeout: 'Тайм-аут подключения',
timeout_sub: 'Не удалось подключиться к серверу',
retry: 'Повторить',
back: 'Назад',
order_not_found: 'Заказ не найден',
not_found_sub: 'Заказ не существует или уже истёк',
select_method: 'Выберите способ оплаты',
currency_label: 'Валюта',
network_label: 'Сеть',
confirm: 'Подтвердить',
connect_wallet: 'Оплатить через кошелёк',
},
};
const LANG_LABELS = { en: 'EN', zh: '中文', 'zh-hk': '繁體', ja: '日本語', ko: '한국어', ru: 'RU' };
const SUPPORTED_LANGS = Object.keys(LANGS);
let currentLang = 'en';
const t = (key) => LANGS[currentLang]?.[key] ?? key;
function detectLang() {
const saved = localStorage.getItem('lang');
if (saved && SUPPORTED_LANGS.includes(saved)) return saved;
for (const nav of (navigator.languages || [navigator.language])) {
const lc = nav.toLowerCase();
if (lc.startsWith('zh-hk') || lc.startsWith('zh-tw') || lc.startsWith('zh-mo')) return 'zh-hk';
if (lc.startsWith('zh')) return 'zh';
if (lc.startsWith('ja')) return 'ja';
if (lc.startsWith('ko')) return 'ko';
if (lc.startsWith('ru')) return 'ru';
if (lc.startsWith('en')) return 'en';
}
return 'en';
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
$$('[data-i18n]').forEach(el => {
const v = t(el.dataset.i18n);
if (v) el.textContent = v;
});
setText('lang-label', LANG_LABELS[lang]);
$$('#dd-lang-menu .select-option').forEach(o =>
o.classList.toggle('is-selected', o.dataset.lang === lang));
closeAllSelects();
// 重新渲染含翻译文案的动态行
if (ORDER?.tradeId) renderOrderId();
if (ORDER?.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
}
// ═══════════════════════════════════════════════════════════════
// SECTION 4 — 下拉选择器
// ═══════════════════════════════════════════════════════════════
function toggleSelect(id) {
const wrap = $(id);
const trigger = wrap.querySelector('.select-trigger');
const menu = wrap.querySelector('.select-menu');
const wasOpen = menu.classList.contains('is-open');
closeAllSelects();
if (!wasOpen) {
menu.classList.add('is-open');
trigger?.classList.add('is-open');
}
}
function closeAllSelects() {
$$('.select-menu.is-open').forEach(m => {
m.classList.remove('is-open');
m.closest('.select-wrap')?.querySelector('.select-trigger')?.classList.remove('is-open');
});
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.select-wrap')) closeAllSelects();
});
// ═══════════════════════════════════════════════════════════════
// SECTION 5 — Toast & Clipboard
// ═══════════════════════════════════════════════════════════════
const CHECK_ICON = '<i data-lucide="check" width="15" height="15" stroke-width="2.5" color="#22c55e"></i>';
function showToast(msg) {
const el = $('toast');
if (!el) return;
el.textContent = msg || t('copied');
el.style.opacity = '1';
el.style.transform = 'translateX(-50%) translateY(0)';
clearTimeout(el._tid);
el._tid = setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateX(-50%) translateY(10px)';
}, 2000);
}
function flashCheck(btnId) {
const btn = $(btnId);
if (!btn) return;
const orig = btn.innerHTML;
btn.innerHTML = CHECK_ICON;
lucide.createIcons({ nodes: [btn] });
setTimeout(() => { btn.innerHTML = orig; lucide.createIcons({ nodes: [btn] }); }, 1800);
}
let _clipboard = null;
function initClipboard() {
const targets = [
{ id: 'copy-addr-box', text: ORDER.receiveAddress, iconId: 'btn-copy-addr' },
{ id: 'btn-copy-amount', text: ORDER.actualAmount, iconId: 'btn-copy-amount' },
];
targets.forEach(({ id, text }) => $(id)?.setAttribute('data-clipboard-text', text));
if (typeof ClipboardJS !== 'undefined') {
_clipboard?.destroy();
_clipboard = new ClipboardJS(targets.map(c => '#' + c.id).join(', '));
_clipboard.on('success', e => {
e.clearSelection();
const tgt = targets.find(c => c.id === e.trigger.id);
showToast();
if (tgt) flashCheck(tgt.iconId);
});
}
}
// ═══════════════════════════════════════════════════════════════
// SECTION 6 — 面板切换 & 导航
// ═══════════════════════════════════════════════════════════════
const PANEL_IDS = {
step1: 'step1-panel',
payment: 'payment-panel',
success: 'screen-success',
expired: 'screen-expired',
timeout: 'screen-timeout',
'not-found': 'screen-not-found',
};
const PANEL_ORDER = ['step1', 'payment', 'success', 'expired', 'timeout', 'not-found'];
let _currentPanel = 'step1';
function slideTo(name, dir) {
const fromEl = $(PANEL_IDS[_currentPanel]);
const toEl = $(PANEL_IDS[name]);
const viewport = $('panel-viewport');
if (!toEl || name === _currentPanel) return;
const d = dir ?? (PANEL_ORDER.indexOf(name) >= PANEL_ORDER.indexOf(_currentPanel) ? 1 : -1);
_currentPanel = name;
if (viewport) viewport.style.overflowX = 'hidden'; // 动画期间裁剪
if (fromEl) {
Object.assign(fromEl.style, { position: 'absolute', top: '0', left: '0', width: '100%' });
fromEl.style.animation = `${d > 0 ? 'slide-out-l' : 'slide-out-r'} 0.38s cubic-bezier(.4,0,.2,1) forwards`;
fromEl.addEventListener('animationend', () => {
fromEl.style.cssText = 'display:none';
if (viewport) viewport.style.overflowX = '';
}, { once: true });
} else if (viewport) {
setTimeout(() => { viewport.style.overflowX = ''; }, 420);
}
toEl.style.display = '';
toEl.style.animation = `${d > 0 ? 'slide-in-r' : 'slide-in-l'} 0.38s cubic-bezier(.4,0,.2,1) forwards`;
}
function showStatePanel(state) {
const isNotFound = state === 'not-found';
const orderInfo = $('order-info');
const stepProgress = $('step-progress');
if (orderInfo) orderInfo.style.display = isNotFound ? 'none' : '';
if (stepProgress) stepProgress.style.display = isNotFound ? 'none' : '';
slideTo(state);
}
function hideStatePanel() {
const orderInfo = $('order-info');
const stepProgress = $('step-progress');
if (orderInfo) orderInfo.style.display = '';
if (stepProgress) stepProgress.style.display = '';
slideTo('payment', -1);
}
function goBack() {
if (ORDER?.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
window.location.href = ORDER.redirectUrl;
} else if (document.referrer) {
window.location.href = document.referrer;
} else {
history.back();
}
}
function syncStatusCardHeight() {} // 兼容旧调用,保留空桩
// ═══════════════════════════════════════════════════════════════
// SECTION 7 — 步骤进度条
// ═══════════════════════════════════════════════════════════════
function setStepBar(step) {
const f1 = $('step-fill-1');
const f2 = $('step-fill-2');
if (!f1) return;
const BASE = 'height:100%;border-radius:9999px;background:var(--foreground);';
if (step === 1) {
f1.style.cssText = BASE + 'animation:fill-bar 0.9s ease-out forwards';
f2.style.cssText = BASE + 'width:0%';
} else {
f1.style.cssText = BASE + 'width:100%';
f2.style.cssText = BASE + 'animation:fill-bar 0.9s ease-out forwards';
}
}
// ═══════════════════════════════════════════════════════════════
// SECTION 8 — Step 1 — 币种 & 网络选择
// ═══════════════════════════════════════════════════════════════
let _step1Token = null;
let _step1Opt = null;
// 内置优先网络,按顺序匹配第一个可用选项
const _PREFERRED_NETWORKS = ['TRON', 'Solana'];
function initStep1() {
// 按内置优先顺序找到首个可用选项
_step1Opt = null;
for (const net of _PREFERRED_NETWORKS) {
_step1Opt = PAYMENT_OPTIONS.find(
o => (_resolveChain(o.network)?.label ?? o.network) === net
);
if (_step1Opt) break;
}
_step1Opt = _step1Opt ?? PAYMENT_OPTIONS[0];
_step1Token = _step1Opt.token;
_renderNetworkMenu();
_renderTokenMenu();
}
function _renderTokenMenu() {
// 按当前选中网络过滤可用币种
const opts = PAYMENT_OPTIONS.filter(o => o.network === _step1Opt?.network);
const tokens = [...new Set(opts.map(o => o.token))];
// 若当前 token 在新网络下不存在则重置为第一个
if (!tokens.includes(_step1Token)) {
_step1Token = tokens[0] ?? null;
_step1Opt = opts[0] ?? _step1Opt;
}
const menu = $('dd-token-menu');
if (!menu) return;
menu.innerHTML = tokens.map(tok => {
const src = `${IMAGE_PREFIX.tokenMenu}${tok.toLowerCase()}.png`;
return `<div class="select-option menu-item${tok === _step1Token ? ' is-selected' : ''}"
onclick="step1SetToken('${tok}')"
style="display:flex;align-items:center;gap:8px">
<img src="${src}" width="16" height="16" style="width:16px;height:16px;border-radius:50%;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'" />
${tok}
</div>`;
}).join('');
setHtml('token-label', _tokenTriggerHtml(_step1Token));
}
function _renderNetworkMenu() {
// 去重,每个网络只显示一项
const seen = new Set();
const networkOpts = PAYMENT_OPTIONS.filter(o => {
if (seen.has(o.network)) return false;
seen.add(o.network);
return true;
});
const menu = $('dd-network-menu');
if (menu) menu.innerHTML = networkOpts.map(opt => _networkMenuItemHtml(opt, opt.network === _step1Opt?.network)).join('');
setHtml('network-label', _networkTriggerHtml(_step1Opt?.network || '--'));
}
function step1SetToken(tok) {
_step1Token = tok;
// 在当前网络下找对应 opt
_step1Opt = PAYMENT_OPTIONS.find(o => o.network === _step1Opt?.network && o.token === tok) ?? _step1Opt;
setHtml('token-label', _tokenTriggerHtml(tok));
$$('#dd-token-menu .select-option').forEach(o =>
o.classList.toggle('is-selected', o.textContent.trim() === tok));
closeAllSelects();
}
function step1SetNetwork(networkName) {
// 找该网络下第一个 opt 作为默认
_step1Opt = PAYMENT_OPTIONS.find(o => o.network === networkName) ?? _step1Opt;
const chain = _resolveChain(networkName);
const label = chain ? chain.label : networkName;
$$('#dd-network-menu .select-option').forEach(o =>
o.classList.toggle('is-selected', o.textContent.trim() === label));
setHtml('network-label', _networkTriggerHtml(_step1Opt.network));
_renderTokenMenu();
closeAllSelects();
}
async function confirmStep1() {
const opt = _step1Opt || PAYMENT_OPTIONS[0];
const btn = $('btn-confirm-step1');
const span = btn?.querySelector('span') ?? btn;
const origText = span?.textContent;
// 1. 乐观 UI:禁用按钮防止重复提交
if (btn) { btn.disabled = true; if (span) span.textContent = '…'; }
try {
const res = await fetch(CONFIG.api.selectMethod(), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
trade_id: ORDER.tradeId,
token: opt.token.toLowerCase(),
network: opt.network.toLowerCase(),
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const resp = await res.json();
const data = resp?.data ?? resp;
// 2. 用后端返回的完整订单字段更新 ORDER,本地选项作为兜底
ORDER.tradeId = data?.trade_id ?? ORDER.tradeId;
// 同步新 trade_id 到 URL(路径末段),轮询将自动使用最新值
if (data?.trade_id) {
const _url = new URL(window.location.href);
_url.pathname = _url.pathname.replace(/\/[^/]+$/, '/' + encodeURIComponent(data.trade_id));
history.replaceState(null, '', _url.toString());
renderOrderId();
}
ORDER.token = data?.token ?? opt.token;
ORDER.network = data?.network ?? opt.network;
ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : opt.actualAmount;
ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount;
ORDER.currency = data?.currency ?? ORDER.currency;
ORDER.receiveAddress = data?.receive_address ?? opt.receiveAddress;
if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time);
if (data?.created_at != null) ORDER.createdAt = String(data.created_at);
if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url;
setStepBar(2);
slideTo('payment');
initOrder();
} catch (err) {
console.error('[confirmStep1]', err);
showToast('⚠ ' + (err.message || 'Request failed'));
} finally {
if (btn) { btn.disabled = false; if (span && origText) span.textContent = origText; }
}
}
// ═══════════════════════════════════════════════════════════════
// SECTION 9 — 订单渲染 & 倒计时
// ═══════════════════════════════════════════════════════════════
function truncateAddr(addr) {
return addr && addr.length > 10 ? `${addr.slice(0, 4)}...${addr.slice(-6)}` : addr;
}
function formatAddr(addr) {
if (!addr || addr.length <= 10) return addr;
return `<span style="font-weight:800;color:var(--primary)">${addr.slice(0, 4)}</span>`
+ `<span style="color:var(--muted-foreground)">${addr.slice(4, -6)}</span>`
+ `<span style="font-weight:800;color:var(--primary)">${addr.slice(-6)}</span>`;
}
function renderRow(id, label, value) {
const el = $(id);
if (el) el.innerHTML = `
<td style="width:1%;white-space:nowrap;padding-right:0.75em">${label}</td>
<td style="color:var(--card-foreground);font-weight:500;word-break:break-all">${value}</td>`;
}
function renderOrderId() {
if (ORDER?.tradeId && !ORDER.tradeId.startsWith('{{')) {
renderRow('display-order-id', t('order_id'), ORDER.tradeId);
}
}
function initOrder() {
if (!ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) { showNotFound(); return; }
setText('display-amount', `${ORDER.actualAmount} ${ORDER.token}`);
setHtml('field-address', formatAddr(ORDER.receiveAddress));
setHtml('display-network', networkTag(ORDER.network));
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
renderOrderId();
const qrcodeEl = $('qrcode');
if (qrcodeEl) {
new QRCode(qrcodeEl, {
text: ORDER.receiveAddress,
width: 176,
height: 176,
colorDark: '#111111',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M,
});
}
initCountdown();
checkOrderStatus();
initClipboard();
}
// 倒计时内部状态
const CIRCUMFERENCE = 2 * Math.PI * 20;
const _pad = (n) => String(n).padStart(2, '0');
let _countdownInterval = null;
let _totalSeconds = 0;
let _expiresAt = null;
function _lerpColor(a, b, ratio) {
return `rgb(${[0,1,2].map(i => Math.round(a[i] + (b[i] - a[i]) * ratio)).join(',')})`;
}
function _ratioColor(ratio) {
if (ratio >= 0.5) return '#22c55e';
if (ratio >= 0.2) return _lerpColor([34,197,94], [249,115,22], (0.5 - ratio) / 0.3);
return _lerpColor([249,115,22], [239,68,68], (0.2 - ratio) / 0.2);
}
function initCountdown() {
const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
_expiresAt = parseTime(ORDER.expirationTime);
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
if (remaining <= 0) { showExpired(); return; }
const created = parseTime(ORDER.createdAt);
_totalSeconds = (!isNaN(created) && !isNaN(_expiresAt))
? Math.max(1, Math.round((_expiresAt - created) / 1000))
: 600;
$('ring').style.strokeDasharray = CIRCUMFERENCE;
_expiresAt = new Date(_expiresAt.getTime() - 1000); // 避免初始渲染停顿
_tickCountdown();
_countdownInterval = setInterval(_tickCountdown, 1000);
}
function _tickCountdown() {
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
const timeStr = h > 0 ? `${_pad(h)}:${_pad(m)}:${_pad(s)}` : `${_pad(m)}:${_pad(s)}`;
const color = _ratioColor(_totalSeconds > 0 ? remaining / _totalSeconds : 0);
const offset = CIRCUMFERENCE * (1 - (_totalSeconds > 0 ? remaining / _totalSeconds : 0));
$('countdown').textContent = timeStr;
$('countdown').style.color = color;
$('ring').style.stroke = color;
$('ring').style.strokeDashoffset = offset;
const inlineEl = $('countdown-inline');
if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
if (remaining <= 0) { clearInterval(_countdownInterval); showExpired(); }
}
// ═══════════════════════════════════════════════════════════════
// SECTION 10 — API 轮询
// 后端对接:修改 CONFIG.api.checkStatus 与 CONFIG.api.statusMap
// ═══════════════════════════════════════════════════════════════
let _pollTimer = null;
let _pollStopped = false;
let _pollErrors = 0;
function checkOrderStatus() {
if (_pollStopped || !ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) return;
fetch(CONFIG.api.checkStatus(ORDER.tradeId), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CONFIG.poll.timeout),
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(({ data }) => {
_pollErrors = 0;
const { paid, expired } = CONFIG.api.statusMap;
if (data?.status === paid) onPaymentSuccess();
else if (data?.status === expired) showExpired();
else _scheduleNextPoll();
})
.catch(() => {
if (++_pollErrors >= CONFIG.poll.maxErrors) showTimeout();
else _scheduleNextPoll();
});
}
function _scheduleNextPoll() {
if (!_pollStopped) _pollTimer = setTimeout(checkOrderStatus, CONFIG.poll.interval);
}
function stopPolling() {
_pollStopped = true;
clearTimeout(_pollTimer);
}
function retryPolling() {
_pollErrors = 0;
_pollStopped = false;
hideStatePanel();
checkOrderStatus();
}
// ═══════════════════════════════════════════════════════════════
// SECTION 11 — 支付状态处理
// ═══════════════════════════════════════════════════════════════
function onPaymentSuccess() {
stopPolling();
clearInterval(_countdownInterval);
showStatePanel('success');
if (ORDER?.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
setTimeout(() => { window.location.href = ORDER.redirectUrl; }, CONFIG.redirect.delay);
}
}
function showExpired() {
stopPolling();
clearInterval(_countdownInterval);
const btn = $('btn-transferred');
if (btn) btn.disabled = true;
showStatePanel('expired');
}
function showTimeout() {
stopPolling();
showStatePanel('timeout');
}
function showNotFound() {
stopPolling();
clearInterval(_countdownInterval);
const btn = $('btn-transferred');
if (btn) btn.disabled = true;
showStatePanel('not-found');
}
// ═══════════════════════════════════════════════════════════════
// SECTION 12 — 转账按钮 & 连接钱包
// ═══════════════════════════════════════════════════════════════
function handleTransfer() {
const btn = $('btn-transferred');
if (!btn) return;
const span = btn.querySelector('span');
if (!span) return;
span.textContent = t('verifying');
btn.disabled = true;
_pollErrors = 0;
_pollStopped = false;
clearTimeout(_pollTimer);
checkOrderStatus();
setTimeout(() => {
span.textContent = t('i_have_transferred');
btn.disabled = false;
}, 4000);
}
function connectWallet() {
window.location.href = CONFIG.wallet.deeplink(window.location.href);
}
// ═══════════════════════════════════════════════════════════════
// SECTION 13 — 初始化入口
// ═══════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
setLang(detectLang());
if (!ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) {
showNotFound();
return;
}
// 在 Step 1 阶段就填充订单信息卡片
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
setText('display-amount', `${ORDER.amount} ${ORDER.currency || ''}`);
}
renderOrderId();
// 确保 PAYMENT_OPTIONS 存在(服务端未注入时降级为内置默认选项)
if (typeof PAYMENT_OPTIONS === 'undefined' || !Array.isArray(PAYMENT_OPTIONS) || !PAYMENT_OPTIONS.length) {
window.PAYMENT_OPTIONS = [
{ token: 'USDT', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'TRX', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDT', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDC', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDT', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
{ token: 'USDC', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
];
}
// isselect=true 时跳过 Step 1,直接进入支付面板
const _isSelect = ORDER.is_selected && ORDER.is_selected !== 'false' && !ORDER.is_selected.startsWith('{{');
if (_isSelect) {
$('step1-panel').style.display = 'none';
$('payment-panel').style.display = '';
_currentPanel = 'payment';
setStepBar(2);
initOrder();
} else {
initStep1();
setStepBar(1);
}
// 连接钱包按钮开关
const walletBtn = $('btn-connect-wallet');
if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none';
});
-44
View File
@@ -1,44 +0,0 @@
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
background-color: #fafbfc;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+24 -4
View File
@@ -1,12 +1,32 @@
package task
import "github.com/robfig/cron/v3"
import (
"github.com/assimon/luuu/util/log"
"github.com/robfig/cron/v3"
)
func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
go StartEthereumWebSocketListener()
go StartBscWebSocketListener()
//go StartPolygonWebSocketListener()
//go StartPlasmaWebSocketListener()
c := cron.New()
// 汇率监听
c.AddJob("@every 60s", UsdtRateJob{})
// trc20钱包监听
c.AddJob("@every 5s", ListenTrc20Job{})
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
// solana钱包监听
_, 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)")
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
+112
View File
@@ -0,0 +1,112 @@
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"
)
var (
// USDT / USDC 合约地址(BSC 主网)
bscUsdtContract = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
bscUsdcContract = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
)
type bscRecipientSnapshot struct {
addrs map[string]struct{}
}
var bscWatchedRecipients atomic.Pointer[bscRecipientSnapshot]
func StartBscWebSocketListener() {
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 range 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 := "wss://bsc.drpc.org"
query := ethereum.FilterQuery{
Addresses: []common.Address{
bscUsdtContract,
bscUsdcContract,
},
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[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
}
+132
View File
@@ -0,0 +1,132 @@
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"
)
var (
// USDT / USDC 合约地址(ETH 主网)
usdtContract = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdcContract = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
// Transfer 事件签名
transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
)
type ethRecipientSnapshot struct {
addrs map[string]struct{}
}
var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
func StartEthereumWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil {
log.Sugar.Errorf("Failed to get wallet addresses: %v", err)
return
}
StoreEthRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range 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 := "wss://ethereum.publicnode.com"
query := ethereum.FilterQuery{
Addresses: []common.Address{
usdtContract,
usdcContract,
},
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[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(&ethRecipientSnapshot{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
}
func formatAmount(amount *big.Int, decimals int) string {
f := new(big.Float).SetInt(amount)
divisor := new(big.Float).SetFloat64(float64Pow(10, decimals))
result := new(big.Float).Quo(f, divisor)
return result.Text('f', 6)
}
func float64Pow(a, b int) float64 {
result := 1.0
for i := 0; i < b; i++ {
result *= float64(a)
}
return result
}
+81
View File
@@ -0,0 +1,81 @@
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"
)
func runEvmWsLogListener(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 {
client, err := ethclient.Dial(wsURL)
if err != nil {
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
failWait = nextBackoff(failWait, maxBackoff)
continue
}
logsCh := make(chan types.Log)
sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh)
if err != nil {
client.Close()
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
failWait = nextBackoff(failWait, maxBackoff)
continue
}
failWait = minBackoff
log.Sugar.Infof("%s connected, subscribed to USDT/USDC Transfer logs", logPrefix)
recvLoop(client, sub, logsCh, logPrefix, handleLog)
time.Sleep(rejoinWait)
}
}
func recvLoop(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 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)
}
}
}
func nextBackoff(cur, max time.Duration) time.Duration {
n := cur * 2
if n > max {
return max
}
return n
}
+108
View File
@@ -0,0 +1,108 @@
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"
)
var (
plasmaUsdt0Contract = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
)
type plasmaRecipientSnapshot struct {
addrs map[string]struct{}
}
var plasmaWatchedRecipients atomic.Pointer[plasmaRecipientSnapshot]
func StartPlasmaWebSocketListener() {
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 range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
if err != nil {
log.Sugar.Warnf("[PLASMA-WS] refresh wallet addresses: %v", err)
continue
}
storePlasmaRecipientsFromWallets(w)
}
}()
// 文档提供 https://rpc.plasma.to;常见实现同主机 wss
wsURL := "wss://rpc.plasma.to"
query := ethereum.FilterQuery{
Addresses: []common.Address{plasmaUsdt0Contract},
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[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
}
+114
View File
@@ -0,0 +1,114 @@
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"
)
var (
// Polygon PoS 主网 USDT / USDC(原生)/ USDC.e(桥接)
polygonUsdtContract = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
polygonUsdcContract = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
polygonUsdcEContract = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
)
type polygonRecipientSnapshot struct {
addrs map[string]struct{}
}
var polygonWatchedRecipients atomic.Pointer[polygonRecipientSnapshot]
func StartPolygonWebSocketListener() {
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 range 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 := "wss://polygon-bor-ws.publicnode.com"
query := ethereum.FilterQuery{
Addresses: []common.Address{
polygonUsdtContract,
polygonUsdcContract,
polygonUsdcEContract,
},
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[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
}
+38
View File
@@ -0,0 +1,38 @@
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")
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")
}
+13 -6
View File
@@ -1,32 +1,39 @@
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"
"sync"
)
type ListenTrc20Job struct {
}
type ListenTrc20Job struct{}
var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
walletAddress, err := data.GetAvailableWalletAddress()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Error(err)
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return
}
if len(walletAddress) <= 0 {
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return
}
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup
for _, address := range walletAddress {
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
wg.Add(1)
go service.Trc20CallBack(address.Token, &wg)
go service.Trc20CallBack(address.Address, &wg)
}
wg.Wait()
log.Sugar.Debug("[ListenTrc20Job] Job completed")
}
-62
View File
@@ -1,62 +0,0 @@
package task
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"time"
)
const UsdtRateApiUri = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail/chart"
type UsdtRateJob struct {
}
type UsdtRateResp struct {
Data Data `json:"data"`
Status Status `json:"status"`
}
type Status struct {
Timestamp time.Time `json:"timestamp"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
Elapsed string `json:"elapsed"`
CreditCount int `json:"credit_count"`
}
type Data struct {
Points map[string]Points `json:"points"`
}
type Points struct {
V []float64 `json:"v"`
C []float64 `json:"c"`
}
func (r UsdtRateJob) Run() {
client := http_client.GetHttpClient()
resp, err := client.R().SetQueryString("id=825&range=1H&convertId=2787").SetHeader("Accept", "application/json").Get(UsdtRateApiUri)
if err != nil {
log.Sugar.Error("usdt rate get err:", err.Error())
return
}
var usdtResp UsdtRateResp
err = json.Cjson.Unmarshal(resp.Body(), &usdtResp)
if err != nil {
log.Sugar.Error("Unmarshal usdt resp err:", err.Error())
return
}
if usdtResp.Status.ErrorCode != "0" {
log.Sugar.Error("usdt resp err:", usdtResp.Status.ErrorMessage)
return
}
for _, points := range usdtResp.Data.Points {
if len(points.C) > 0 && points.C[0] > 0 {
config.UsdtRate = math.MustParsePrecFloat64(points.C[0], 2)
return
}
}
}
+154 -18
View File
@@ -2,6 +2,10 @@ package telegram
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
@@ -11,25 +15,61 @@ import (
)
const (
ReplayAddWallet = "请发给我一个合法的钱包地址"
ReplaySelectNetwork = "请选择要添加的钱包网络"
ReplayAddWallet = "请发送 %s 网络收款地址"
pendingWalletAddressTTL = 5 * time.Minute
)
type pendingWalletAddressState struct {
RequestedAt time.Time
Network string
}
var pendingWalletAddressUsers sync.Map
func OnTextMessageHandle(c tb.Context) error {
if c.Message().ReplyTo.Text == ReplayAddWallet {
defer bots.Delete(c.Message().ReplyTo)
msgText := c.Message().Text
if !isValidTronAddress(msgText) {
_ = c.Send(fmt.Sprintf("钱包[%s]添加失败: 非Tron地址!", msgText))
msg := c.Message()
if msg == nil {
return nil
}
_, err := data.AddWalletAddress(msgText)
sender := c.Sender()
senderID := int64(0)
if sender != nil {
senderID = sender.ID
}
isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ")
isPendingFlow := isWalletAddressPending(senderID)
if !isReplyFlow && !isPendingFlow {
return nil
}
if isReplyFlow {
defer bots.Delete(msg.ReplyTo)
}
msgText := strings.TrimSpace(msg.Text)
state, _ := getPendingWalletAddressState(senderID)
if state.Network == "" {
_ = c.Send("请先选择网络,再发送地址。")
return nil
}
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 {
return c.Send(err.Error())
}
_ = c.Send(fmt.Sprintf("钱包[%s]添加成功!", msgText))
pendingWalletAddressUsers.Delete(senderID)
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功(%s", storeAddress, strings.ToUpper(state.Network)))
return WalletList(c)
}
return nil
}
func WalletList(c tb.Context) error {
@@ -37,39 +77,81 @@ func WalletList(c tb.Context) error {
if err != nil {
return err
}
var btnList [][]tb.InlineButton
for _, wallet := range wallets {
status := "已启用✅"
if wallet.Status == mdb.TokenStatusDisable {
status = "已禁用🚫"
}
var temp []tb.InlineButton
net := wallet.Network
if net == "" {
net = mdb.NetworkTron
}
btnInfo := tb.InlineButton{
Unique: wallet.Token,
Text: fmt.Sprintf("%s[%s]", wallet.Token, status),
Unique: wallet.Address,
Text: fmt.Sprintf("[%s] %s [%s]", net, wallet.Address, status),
Data: strutil.MustString(wallet.ID),
}
bots.Handle(&btnInfo, WalletInfo)
btnList = append(btnList, append(temp, btnInfo))
btnList = append(btnList, []tb.InlineButton{btnInfo})
}
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error {
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
ForceReply: true,
chains, err := getEnabledSupportedNetworks()
if err != nil {
return c.Send("读取支持链失败:" + err.Error())
}
if len(chains) == 0 {
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})
return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
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 {
id := mathutil.MustUint(c.Data())
tokenInfo, err := data.GetWalletAddressById(id)
if err != nil {
return c.Send(err.Error())
}
enableBtn := tb.InlineButton{
Text: "启用",
Unique: "enableBtn",
@@ -89,11 +171,18 @@ func WalletInfo(c tb.Context) error {
Text: "返回",
Unique: "WalletList",
}
bots.Handle(&enableBtn, EnableWallet)
bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList)
return c.EditOrReply(tokenInfo.Token, &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,
disableBtn,
@@ -140,3 +229,50 @@ func DelWallet(c tb.Context) error {
}
return WalletList(c)
}
func isWalletAddressPending(userID int64) bool {
if userID <= 0 {
return false
}
value, ok := pendingWalletAddressUsers.Load(userID)
if !ok {
return false
}
state, ok := value.(pendingWalletAddressState)
if !ok || time.Since(state.RequestedAt) > pendingWalletAddressTTL {
pendingWalletAddressUsers.Delete(userID)
return false
}
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) {
assets, err := data.ListEnabledSupportedAssets()
if err != nil {
return nil, err
}
set := make(map[string]struct{})
for _, a := range assets {
n := strings.ToLower(strings.TrimSpace(a.Network))
if n == "" {
continue
}
set[n] = struct{}{}
}
networks := make([]string, 0, len(set))
for n := range set {
networks = append(networks, n)
}
sort.Strings(networks)
return networks, nil
}
+8 -1
View File
@@ -46,14 +46,21 @@ func RegisterHandle() {
// SendToBot 主动发送消息机器人消息
func SendToBot(msg string) {
go func() {
if bots == nil {
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
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.Error(err)
log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
} else {
log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
}
}()
}
+46
View File
@@ -2,10 +2,24 @@ package telegram
import (
"crypto/sha256"
"encoding/hex"
"strings"
"github.com/assimon/luuu/model/mdb"
"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 地址是否合法
func isValidTronAddress(addr string) bool {
// 基本过滤
@@ -32,3 +46,35 @@ func isValidTronAddress(addr string) bool {
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
}
+23 -11
View File
@@ -1,17 +1,23 @@
package constant
var Errno = map[int]string{
400: "系统错误",
401: "签名认证错误",
10001: "钱包地址已存在,请勿重复添加",
10002: "支付交易已存在,请勿重复创建",
10003: "无可用钱包地址,无法发起支付",
10004: "支付金额有误, 无法满足最小支付单位",
10005: "无可用金额通道",
10006: "汇率计算错误",
10007: "订单区块已处理",
10008: "订单不存在",
10009: "无法解析请求参数",
400: "system error",
401: "signature verification failed",
10001: "wallet address already exists",
10002: "order already exists",
10003: "no available wallet address",
10004: "invalid payment amount",
10005: "no available amount channel",
10006: "rate calculation failed",
10007: "block transaction already processed",
10008: "order does not exist",
10009: "failed to parse request params",
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: "supported asset already exists",
10015: "supported asset not found",
}
var (
@@ -26,6 +32,12 @@ var (
OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009)
OrderStatusConflict = Err(10010)
SubOrderLimitExceeded = Err(10011)
CannotSwitchSubOrder = Err(10012)
OrderNotWaitPay = Err(10013)
SupportedAssetAlreadyExists = Err(10014)
SupportedAssetNotFound = Err(10015)
)
type RspError struct {
+3 -2
View File
@@ -1,8 +1,9 @@
package http_client
import (
"github.com/go-resty/resty/v2"
"time"
"github.com/go-resty/resty/v2"
)
// GetHttpClient 获取请求客户端
@@ -13,6 +14,6 @@ func GetHttpClient(proxys ...string) *resty.Client {
proxy := proxys[0]
client.SetProxy(proxy)
}
client.SetTimeout(time.Second * 5)
client.SetTimeout(time.Second * 10)
return client
}
+32 -5
View File
@@ -7,27 +7,37 @@ import (
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"time"
)
var Sugar *zap.SugaredLogger
func Init() {
writeSyncer := getLogWriter()
encoder := getEncoder()
core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel)
level := getLogLevel()
core := zapcore.NewTee(
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
)
logger := zap.New(core, zap.AddCaller())
Sugar = logger.Sugar()
}
func getEncoder() zapcore.Encoder {
func getFileEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
func getLogWriter() zapcore.WriteSyncer {
func getConsoleEncoder() zapcore.Encoder {
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return zapcore.NewConsoleEncoder(encoderConfig)
}
func getFileWriter() zapcore.WriteSyncer {
file := fmt.Sprintf("%s/log_%s.log",
config.LogSavePath,
time.Now().Format("20060102"))
@@ -40,3 +50,20 @@ func getLogWriter() zapcore.WriteSyncer {
}
return zapcore.AddSync(lumberJackLogger)
}
func getConsoleWriter() zapcore.WriteSyncer {
return zapcore.Lock(os.Stdout)
}
func getLogLevel() zapcore.Level {
switch config.LogLevel {
case "debug":
return zapcore.DebugLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
+103
View File
@@ -0,0 +1,103 @@
# API 变更文档
## 概述
## 后台配置说明(重要)
### 对于 dujiaoka 用户
**只需要修改一个地方**:在 dujiaoka 后台支付插件配置中,将 API 地址前缀从 `/api` 改为 `/payments/epusdt` 即可。
**示例**
```
旧配置:https://your-domain.com/api/v1/order/create-transaction
新配置:https://your-domain.com/payments/epusdt/v1/order/create-transaction
```
**就这么简单!** 其他配置(密钥、回调地址等)完全不需要修改。
---
## 路由变更对照表
### API 路由
| 原路由 | 新路由 | 说明 |
|--------|--------|------|
| `POST /api/v1/order/create-transaction` | `POST /payments/epusdt/v1/order/create-transaction` | 创建交易订单 |
## 新增配置项说明
本次更新新增了以下配置项(位于 `src/.env.example` 文件):
### 1. `api_rate_url` - 汇率接口 URL
用于获取实时汇率的 API 地址。系统会根据此接口动态获取不同币种的汇率。
```bash
# 汇率接口url
api_rate_url=https://your-rate-api.com/
```
**API 格式要求**
系统会请求 `{api_rate_url}/{currency}.json`,例如:
- `https://your-rate-api.com/cny.json`
- `https://your-rate-api.com/usd.json`
**返回格式示例**
```json
{
"cny": {
"usdt": 0.1389,
"trx": 0.0123
}
}
```
其中 `0.1389` 表示 1 CNY = 0.1389 USDT(即 1 USDT ≈ 7.2 CNY
**说明**
- 支持自建汇率 API,只需按照上述格式返回数据即可
### 2. `tron_grid_api_key` - TRON Grid API Key
TRON Grid API 密钥,用于提高 API 请求限制和稳定性。
```bash
tron_grid_api_key=
```
**如何获取 API Key**
1. 访问 [https://www.trongrid.io/](https://www.trongrid.io/)
2. 注册账号并登录
3. 在控制台创建 API Key
4. 将 API Key 填入配置文件
**为什么需要 API Key**
-**提高请求限制**:免费账号有更高的 API 调用配额
-**更好的稳定性**:避免公共 API 的限流问题
-**支持更多功能**:为后续支持 TRX 等其他代币做准备
### 配置示例
`.env` 配置示例:
```bash
# 订单过期时间(分钟)
order_expiration_time=15
# 订单回调失败最大重试次数
order_notice_max_retry=0
# 汇率接口url(动态获取汇率)
api_rate_url=
# TRON Grid API Key(推荐配置)
tron_grid_api_key=your-api-key-here
```
+104
View File
@@ -0,0 +1,104 @@
1. 创建和进去这个目录
```shell
mkdir epusdt && cd epusdt
```
2. 把配置文件放进去这个目录,只需要改
app_url
tron_grid_api_key
api_rate_url
```shell
cat <<EOF > env
app_name=epusdt
app_uri=https://dujiaoka.com
log_level=info
http_access_log=false
sql_debug=false
http_listen=:8000
static_path=/static
runtime_root_path=/runtime
log_save_path=/logs
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=
# postgres config
postgres_host=127.0.0.1
postgres_port=3306
postgres_user=mysql_user
postgres_passwd=mysql_password
postgres_database=database_name
postgres_table_prefix=
postgres_max_idle_conns=10
postgres_max_open_conns=100
postgres_max_life_time=6
# mysql config
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql_user
mysql_passwd=mysql_password
mysql_database=database_name
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# background scheduler config
queue_concurrency=10
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
tg_bot_token=
tg_proxy=
tg_manage=
api_auth_token=
order_expiration_time=10
order_notice_max_retry=0
forced_usdt_rate=
api_rate_url=https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/
tron_grid_api_key=
EOF
```
3. docker compose 创建
```shell
cat <<EOF > docker-compose.yaml
services:
epusdt:
image: gmwallet/epusdt:alpine
restart: always
build:
context: .
dockerfile: Dockerfile
volumes:
- ./env:/app/.env
ports:
- "8000:8000"
EOF
```
4. 运行
```shell
docker compose up -d
```
5. 配置独角兽后台
商户密钥: http://your_domain/payments/epusdt/v1/order/create-transaction