101 Commits

Author SHA1 Message Date
line-6000 097c716714 Merge pull request #33 from GMwalletApp/dev-payment
- Enable polygon,plasma supports
2026-04-15 18:43:46 +08:00
line-6000 f7c5f678c4 - Enable polygon,plasma supports
- Fix network select
- Fix store evm address logic
2026-04-15 18:36:50 +08:00
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
line-6000 1e4cf0fdd5 Update README.md 2026-03-24 11:57:41 +08:00
line-6000 d4ed951058 Update README.md 2026-03-24 11:52:34 +08:00
line-6000 0d4b6dc52a Update README.md 2026-03-24 11:51:32 +08:00
ashang c0787d19b3 Merge pull request #15 from CarpCap/master
手动部署文档,修复错误的命令
2026-01-27 15:54:08 +08:00
ashang 0c2f3da947 Merge pull request #14 from AnnonaOrg/main
为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间
2026-01-27 14:13:19 +08:00
CarpCap 89529ce4c1 手动部署文档,修复错误的命令 2026-01-27 13:56:50 +08:00
dbidib 7e777bf1d6 Merge pull request #9 from tmpMeM/main
为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间
2026-01-27 12:19:38 +08:00
dev 9def1ec447 为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间 2026-01-27 12:18:32 +08:00
ashang 5e1cd4e637 Update README.md 2026-01-27 11:05:05 +08:00
ashang 91a65773fd Merge pull request #13 from CarpCap/master
手动运行epusdt教程 版本更新为0.0.3
2026-01-27 11:03:32 +08:00
ashang 28e8e9236f Merge pull request #12 from AnnonaOrg/main
新增支持postgres/sqlite数据库,Dockerfile文件
2026-01-27 11:02:47 +08:00
dbidib 2a39c43cae Merge pull request #8 from tmpMeM/main
更新carbon依赖
2026-01-27 10:27:39 +08:00
dev 14435ba426 更新carbon依赖 2026-01-25 13:03:31 +08:00
ashang 883001bc53 Update README.md 2025-11-27 10:25:25 +08:00
dbidib 89c6d1965a Merge pull request #7 from tmpMeM/main
debug
2025-11-22 17:19:58 +08:00
“dev” 61d3cc819e debug 2025-11-22 17:07:25 +08:00
CarpCap 2aa3d43e57 手动运行epusdt教程 版本更新为0.0.3 2025-09-22 04:29:27 +08:00
ashang c20cacbc64 Update README.md 2025-09-03 09:29:39 +08:00
dbidib e174dac6be Merge pull request #6 from tmpMeM/main
bot poller time.second ,synchronous true
2025-07-23 12:42:50 +08:00
“dev” 011d602ffe bot poller time.second ,synchronous true 2025-07-23 12:39:17 +08:00
ashang 9171884fc0 Update README.md
ad
2025-05-26 11:37:57 +08:00
dbidib fb14ae5387 Merge pull request #5 from tmpMeM/main
debug
2025-04-07 09:32:07 +08:00
“dev” 1ab580ea13 debug 2025-04-07 09:31:04 +08:00
dbidib d2d1f1dbbb Merge pull request #4 from tmpMeM/main
debug
2025-04-07 09:26:46 +08:00
“dev” 9b08e49ba1 debug 2025-04-07 09:25:22 +08:00
dbidib 0c6c76c0c3 Merge pull request #3 from tmpMeM/main
remove port
2025-02-13 23:23:24 +08:00
“dev” 84fc330ce1 remove port 2025-02-13 23:21:47 +08:00
ashang 1cded44570 Update README.md
反诈提醒
2024-12-27 16:55:19 +08:00
dbidib 1697b6f410 Merge pull request #2 from tmpMeM/main
修改数据库连接失败后的处理逻辑为报错,然后退出程序
2024-10-14 21:21:58 +08:00
noreply 67206df6a7 修改数据库连接失败后的处理逻辑为报错,然后退出程序 2024-10-14 21:20:46 +08:00
dbidib 4ce58c836c Merge pull request #1 from tmpMeM/main
修改数据库连接失败挂起的逻辑为10s后重试
2024-10-14 20:16:00 +08:00
noreply 392c922130 修改数据库连接失败挂起的逻辑为10s后重试 2024-10-14 20:14:10 +08:00
noreply b697409ec0 修改数据库连接失败挂起的逻辑为10s后重试 2024-10-14 20:12:34 +08:00
ashang 6bab71abb4 remove ad 2024-08-30 10:08:40 +08:00
noreply c09886f6da debug 2024-07-29 12:25:13 +08:00
noreply ab443fd808 debug 2024-07-29 10:27:58 +08:00
noreply e386591e0c debug 2024-07-29 10:25:07 +08:00
noreply 3d02b41dba add sqlite 2024-07-29 10:07:22 +08:00
ashang 0487bd0a45 Update README.md
ad
2024-05-23 21:25:41 +08:00
noreply 2cb9ed4393 README 2024-04-17 11:30:54 +08:00
noreply 136af2676a README 2024-04-17 11:24:12 +08:00
noreply ed0a7f7d07 debug 2024-04-16 23:55:11 +08:00
noreply d3ed2c927e 新增 订单回调失败最大重试次数 配置项 2024-04-16 20:32:04 +08:00
noreply 9031240e2a del env 2024-04-10 11:06:49 +08:00
noreply aae8308973 add pg conf 2024-04-10 11:00:50 +08:00
ashang 71f0a9cdc3 Update README.md 2023-07-13 16:46:45 +08:00
ashang 254a38fa33 Merge pull request #9 from tbxark-archive/master
fix: mysql密码中有特殊字符导致拼接的MysqlDns错误
2023-07-13 16:46:13 +08:00
TBXark 2b805ff770 fix: mysql密码中有特殊字符导致拼接的MysqlDns错误 2023-05-22 14:26:23 +08:00
ashang 0c4249634c Update README.md 2023-05-13 12:22:42 +08:00
ashang 6789315efd Update README.md 2022-12-15 13:32:10 +08:00
99 changed files with 9213 additions and 1814 deletions
+46
View File
@@ -0,0 +1,46 @@
name: docker-alpine-epusdt
on:
workflow_dispatch:
push:
branches:
- "main"
env:
IMAGE_NAME: epusdt
jobs:
push-docker-hub:
runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps:
- uses: actions/checkout@v6
- name: Compute Build Metadata
id: meta
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- 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
+4
View File
@@ -1,4 +1,8 @@
.idea/ .idea/
src/runtime/ src/runtime/
src/.env src/.env
src/*.db
src/*.db-shm
src/*.db-wal
.DS_Store .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
+24
View File
@@ -0,0 +1,24 @@
FROM golang:alpine AS builder
RUN apk add --no-cache --update git build-base
ENV CGO_ENABLED=0
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/src/static /app/static
COPY --from=builder /app/src/static /static
COPY --from=builder /app/epusdt .
VOLUME /app/conf
ENTRYPOINT ["./epusdt", "http", "start"]
+149 -71
View File
@@ -1,91 +1,169 @@
## Epusdt (Easy Payment Usdt) # Epusdt Easy Payment USDT
<p align="center"> <p align="center">
<img src="wiki/img/usdtlogo.png"> <img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
</p> </p>
<p align="center">
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
</p>
## 项目简介 <p align="center">
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`) <a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql``redis` <a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉 <a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰 </p>
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
## 项目特点 <p align="center">
- 支持私有化部署,无需担心钱包被篡改和吞单😁 <a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备 <a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
- 多钱包地址轮询,提高订单并发率 <a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
- 异步队列响应,优雅及高性能 <a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用 </p>
- 支持`http api`,其他系统亦可接入
- `Telegram`机器人接入,便捷使用和支付消息快速通知 ---
## 🌍 What is Epusdt?
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
### 🔗 已支持网络与代币
| 网络 | 代币 |
|------|------|
| **TRC20** (Tron) | USDT、TRX|
| **ERC20** (Ethereum) | USDT、USDC、ETH |
| **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
---
## 🔌 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
| 领域 | 已支持系统 |
|------|-----------|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
| **更多** | 简易HTTP API 10分钟内接入 |
👉 查看更多集成列表与插件:[plugins/](plugins/)
---
## ✨ 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
- **异步队列** — 高性能消息回调,优雅处理高并发场景
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
- **Telegram Bot** — 实时支付通知,快捷管理与监控
---
## 📖 文档与教程
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
快速入门:
| 教程 | 说明 |
|------|------|
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
---
## 🏗️ 项目结构
## 项目结构
``` ```
Epusdt Epusdt
├── plugins ---> (已集成的插件库,例如dujiaoka) ├── plugins/ → 已集成的系统插件(独角数卡等)
├── src ---> (项目核心目录) ├── src/ → 项目核心代码
├── sdk ---> (接入SDK) ├── sdk/ → 接入 SDK
├── sql ---> (安装sql文件或更新sql文件) ├── sql/ → 数据库安装 / 升级脚本
└── wiki ---> (知识库) └── wiki/ → 文档与知识库
``` ```
## 教程: ---
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
## 已适配系统插件 ## 🔧 实现原理
- 独角数卡[插件地址](plugins/dujiaoka)
## 🔥推荐服务器 Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
- (美国免备案vps,配置2核2G仅需`20.98$``145RMB`一年/支持支付宝付款)[👉🏻点我直达](https://my.racknerd.com/aff.php?aff=2745&pid=681)
## 加入交流/意见反馈
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
## 设计实现
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
监听钱包地址`usdt`代币入账事件,通过`金额差异``时效性`来判定交易归属信息,
可参考下方`流程图`
``` ```
简单的原理 工作流程
1.客户需支付20.05usdt 1. 客户发起支付,需支付 20.05 USDT
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05 2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
3.发起支付的时候,我们可以判定钱包address_120.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟 3.address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败 4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功 5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
``` ```
### 流程图:
![Implementation principle](wiki/img/implementation_principle.jpg)
## 打赏 ![Epusdt 支付流程图](wiki/img/implementation_principle.jpg)
如果该项目对您有所帮助,希望可以请我喝一杯咖啡☕️
```
Usdt(trc20)打赏地址: TNEns8t9jbWENbStkQdVQtHMGpbsYsQjZK
```
<img src="wiki/img/usdt_thanks.jpeg" width = "300" height = "400" alt="usdt扫码打赏"/>
---
## 💬 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。
## 声明 加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
`Epusdt`为开源的产品,仅用于学习交流使用!
不可用于任何违反中华人民共和国(含台湾省)或使用者所在地区法律法规的用途。 | 渠道 | 链接 |
因为作者即本人仅完成代码的开发和开源活动(开源即任何人都可以下载使用或修改分发),从未参与用户的任何运营和盈利活动。 |------|------|
且不知晓用户后续将程序源代码用于何种用途,故用户使用过程中所带来的任何法律责任即由用户自己承担。 | 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
``` | 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
!!!Warning!!! | 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
项目中所涉及区块链代币均为学习用途,作者并不赞成区块链所繁衍出代币的金融属性
亦不鼓励和支持任何"挖矿""炒币""虚拟币ICO"等非法行为 ---
虚拟币市场行为不受监管要求和控制,投资交易需谨慎,仅供学习区块链知识
``` ## 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.
+11
View File
@@ -0,0 +1,11 @@
services:
epusdt:
image: gmwallet/epusdt:alpine
restart: always
build:
context: .
dockerfile: Dockerfile
volumes:
- ./env:/app/.env
ports:
- "8000:8000"
+41 -30
View File
@@ -1,60 +1,71 @@
app_name=epusdt app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com app_uri=https://dujiaoka.com
#是否开启debug,默认false log_level=info
app_debug=false http_access_log=false
#http服务监听端口 sql_debug=false
http_listen=:8000 http_listen=:8000
#静态资源文件目录
static_path=/static static_path=/static
#缓存路径
runtime_root_path=/runtime runtime_root_path=/runtime
#日志配置
log_save_path=/logs log_save_path=/logs
log_max_size=32 log_max_size=32
log_max_age=7 log_max_age=7
max_backups=3 max_backups=3
# mysql配置 # 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_host=127.0.0.1
mysql_port=3306 mysql_port=3306
mysql_user=mysql账号 mysql_user=mysql_user
mysql_passwd=mysql密码 mysql_passwd=mysql_password
mysql_database=数据库 mysql_database=database_name
mysql_table_prefix= mysql_table_prefix=
mysql_max_idle_conns=10 mysql_max_idle_conns=10
mysql_max_open_conns=100 mysql_max_open_conns=100
mysql_max_life_time=6 mysql_max_life_time=6
# redis配置 # sqlite runtime store config
redis_host=127.0.0.1 runtime_sqlite_filename=epusdt-runtime.db
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_concurrency=10
queue_level_critical=6 queue_poll_interval_ms=1000
queue_level_default=3 callback_retry_base_seconds=5
queue_level_low=1
#机器人Apitoken
tg_bot_token= tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy= tg_proxy=
#管理员userid
tg_manage= tg_manage=
#api接口认证token
api_auth_token= api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10 order_expiration_time=10
order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate= 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 .DS_Store
dist/ dist/
*.log
+42 -24
View File
@@ -1,44 +1,62 @@
# This is an example .goreleaser.yml file with some sensible defaults. version: 2
# Make sure to check the documentation at https://goreleaser.com
project_name: epusdt project_name: epusdt
before: before:
hooks: hooks:
# You may remove this if you don't use go modules. - go test ./...
#- go mod tidy
# you may remove this if you don't need go generate
#- go generate ./...
builds: builds:
- env: - id: epusdt
main: .
binary: epusdt
env:
- CGO_ENABLED=0 - 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: goos:
- darwin
- linux - linux
- darwin
- windows - windows
goarch: goarch:
- amd64 - amd64
- arm - arm64
- 386
archives: archives:
- replacements: - id: epusdt
darwin: Darwin ids:
linux: Linux - epusdt
windows: Windows name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
386: i386
amd64: x86_64
id: epusdt
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format: tar.gz
files: files:
- .env.example - .env.example
- static/* - static/**
- runtime/* format_overrides:
- goos: windows
formats:
- zip
checksum: checksum:
name_template: 'checksums.txt' name_template: "SHA256SUMS"
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-next" name_template: "{{ incpatch .Version }}-next"
release:
github:
owner: GMwalletApp
name: epusdt
prerelease: auto
changelog: changelog:
sort: asc sort: asc
filters: filters:
exclude: exclude:
- '^docs:' - "^docs:"
- '^test:' - "^test:"
- "^chore:"
- "^ci:"
- "^fix\\(ci\\):"
- "^build\\(release\\):"
- "^Merge pull request"
+8 -16
View File
@@ -1,7 +1,8 @@
package bootstrap package bootstrap
import ( import (
"github.com/assimon/luuu/command" "sync"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/mq" "github.com/assimon/luuu/mq"
@@ -10,24 +11,15 @@ import (
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
) )
//Start 服务启动 var initOnce sync.Once
func Start() {
// 配置加载 func InitApp() {
initOnce.Do(func() {
config.Init() config.Init()
// 日志加载
log.Init() log.Init()
// Mysql启动 dao.Init()
dao.MysqlInit()
// redis启动
dao.RedisInit()
// 队列启动
mq.Start() mq.Start()
// telegram机器人启动
go telegram.BotStart() go telegram.BotStart()
// 定时任务
go task.Start() 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 ( import (
"context" "context"
"net/http"
"os"
"os/signal"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/middleware" "github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route" "github.com/assimon/luuu/route"
@@ -12,16 +18,12 @@ import (
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"net/http"
"os"
"os/signal"
"time"
) )
var httpCmd = &cobra.Command{ var httpCmd = &cobra.Command{
Use: "http", Use: "http",
Short: "http服务", Short: "http service",
Long: "http服务相关命令", Long: "http service commands",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
}, },
} }
@@ -32,9 +34,11 @@ func init() {
var startCmd = &cobra.Command{ var startCmd = &cobra.Command{
Use: "start", Use: "start",
Short: "启动", Short: "start",
Long: "启动http服务", Long: "start http service",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
bootstrap.InitApp()
printBanner()
HttpServerStart() HttpServerStart()
}, },
} }
@@ -44,23 +48,22 @@ func HttpServerStart() {
e := echo.New() e := echo.New()
e.HideBanner = true e.HideBanner = true
e.HTTPErrorHandler = customHTTPErrorHandler e.HTTPErrorHandler = customHTTPErrorHandler
// 中间件注册
MiddlewareRegister(e) MiddlewareRegister(e)
// 路由注册
route.RegisterRoute(e) route.RegisterRoute(e)
// 静态目录注册 e.Static(config.StaticPath, config.StaticFilePath)
e.Static(config.StaticPath, "static")
httpListen := viper.GetString("http_listen") httpListen := viper.GetString("http_listen")
go func() { go func() {
if err = e.Start(httpListen); err != http.ErrServerClosed { if err = e.Start(httpListen); err != http.ErrServerClosed {
log.Sugar.Error(err) 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, os.Kill) signal.Notify(quit, os.Interrupt, os.Kill)
<-quit <-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err = e.Shutdown(ctx); err != nil { if err = e.Shutdown(ctx); err != nil {
@@ -68,16 +71,13 @@ func HttpServerStart() {
} }
} }
// MiddlewareRegister 中间件注册
func MiddlewareRegister(e *echo.Echo) { func MiddlewareRegister(e *echo.Echo) {
if config.AppDebug { if config.HTTPAccessLog {
e.Debug = true
e.Use(echoMiddleware.Logger()) e.Use(echoMiddleware.Logger())
} }
e.Use(middleware.RequestUUID()) e.Use(middleware.RequestUUID())
} }
// customHTTPErrorHandler 默认消息提示
func customHTTPErrorHandler(err error, e echo.Context) { func customHTTPErrorHandler(err error, e echo.Context) {
code := http.StatusInternalServerError code := http.StatusInternalServerError
msg := "server error" msg := "server error"
@@ -95,5 +95,4 @@ func customHTTPErrorHandler(err error, e echo.Context) {
resp.Message = he.Msg resp.Message = he.Msg
} }
_ = e.JSON(http.StatusOK, resp) _ = e.JSON(http.StatusOK, resp)
return
} }
+10 -1
View File
@@ -1,6 +1,11 @@
package command package command
import "github.com/spf13/cobra" import (
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var configPath string
var rootCmd = &cobra.Command{} var rootCmd = &cobra.Command{}
@@ -9,5 +14,9 @@ func Execute() error {
} }
func init() { 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) 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)
}
+275 -23
View File
@@ -1,60 +1,169 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"github.com/spf13/viper" "log"
"net/url"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"github.com/assimon/luuu/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
) )
var ( var (
AppDebug bool HTTPAccessLog bool
SQLDebug bool
LogLevel string
MysqlDns string MysqlDns string
RuntimePath string RuntimePath string
LogSavePath string LogSavePath string
StaticPath string StaticPath string
StaticFilePath string
TgBotToken string TgBotToken string
TgProxy string TgProxy string
TgManage int64 TgManage int64
UsdtRate float64 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() { func Init() {
viper.AddConfigPath("./") configPath, err := resolveConfigFilePath()
viper.SetConfigFile(".env")
err := viper.ReadInConfig()
if err != nil { if err != nil {
panic(err) panic(err)
} }
gwd, err := os.Getwd() configRootPath = filepath.Dir(configPath)
viper.SetConfigFile(configPath)
err = viper.ReadInConfig()
if err != nil { if err != nil {
panic(err) panic(err)
} }
AppDebug = viper.GetBool("app_debug") HTTPAccessLog = viper.GetBool("http_access_log")
StaticPath = viper.GetString("static_path") SQLDebug = viper.GetBool("sql_debug")
RuntimePath = fmt.Sprintf( LogLevel = normalizeLogLevel(viper.GetString("log_level"))
"%s%s", StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
gwd, StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
viper.GetString("runtime_root_path")) RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = fmt.Sprintf( LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
"%s%s", mustMkdir(RuntimePath)
RuntimePath, mustMkdir(LogSavePath)
viper.GetString("log_save_path"))
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
viper.GetString("mysql_user"), url.QueryEscape(viper.GetString("mysql_user")),
viper.GetString("mysql_passwd"), url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf( fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
viper.GetString("mysql_database")) viper.GetString("mysql_database"))
TgBotToken = viper.GetString("tg_bot_token") TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy") TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
}
func mustMkdir(path string) {
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 { func GetAppVersion() string {
return "0.0.2" return BuildVersion
}
func GetBuildCommit() string {
return BuildCommit
}
func GetBuildDate() string {
return BuildDate
} }
func GetAppName() string { func GetAppName() string {
@@ -73,6 +182,73 @@ func GetApiAuthToken() string {
return viper.GetString("api_auth_token") 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 { func GetUsdtRate() float64 {
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate") forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 { if forcedUsdtRate > 0 {
@@ -96,3 +272,79 @@ func GetOrderExpirationTimeDuration() time.Duration {
timer := GetOrderExpirationTime() timer := GetOrderExpirationTime()
return time.Minute * time.Duration(timer) 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 package comm
import ( import (
"encoding/json"
"fmt"
"log"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service" "github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
@@ -22,3 +26,53 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
} }
return c.SucJson(ctx, resp) 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 package comm
import ( import (
"encoding/json"
"fmt" "fmt"
"html/template"
"net/http"
"path/filepath"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service" "github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"html/template"
"net/http"
) )
// CheckoutCounter 收银台 // CheckoutCounter 收银台
@@ -15,12 +18,27 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id") tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId) resp, err := service.GetCheckoutCounterByTradeId(tradeId)
if err != nil { if err != nil {
return ctx.String(http.StatusOK, err.Error()) if err == service.ErrOrder {
} tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html"))
if err != nil { if err != nil {
return ctx.String(http.StatusOK, err.Error()) 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) 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)
}
+100 -19
View File
@@ -1,34 +1,115 @@
module github.com/assimon/luuu module github.com/assimon/luuu
go 1.16 go 1.25.0
require ( require (
github.com/go-redis/redis/v8 v8.11.5 github.com/btcsuite/btcutil v1.0.2
github.com/go-resty/resty/v2 v2.7.0 github.com/dromara/carbon/v2 v2.6.15
github.com/golang-module/carbon/v2 v2.0.1 github.com/ethereum/go-ethereum v1.15.11
github.com/google/btree v1.0.0 // indirect github.com/gagliardetto/solana-go v1.16.0
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/go-resty/resty/v2 v2.11.0
github.com/google/uuid v1.3.0 // indirect
github.com/gookit/color v1.5.0 github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6 github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1 github.com/gookit/validate v1.3.1
github.com/hibiken/asynq v0.23.0
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0 github.com/labstack/echo/v4 v4.6.0
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/libtnb/sqlite v1.0.1
github.com/natefinch/lumberjack v2.0.0+incompatible github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/shengdoushi/base58 v1.0.0
github.com/shopspring/decimal v1.3.1 github.com/shopspring/decimal v1.3.1
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cobra v1.9.1
github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.20.1
github.com/spf13/viper v1.9.0 github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.17.0 go.uber.org/zap v1.27.0
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/telebot.v3 v3.0.0 gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.1.2 gorm.io/driver/mysql v1.5.1
gorm.io/gorm v1.21.15 gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
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/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.1.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
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/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/go-testing-interface v1.14.1 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
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/pflag v1.0.6 // indirect
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
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
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
) )
+378 -708
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 package main
import ( import (
"github.com/assimon/luuu/bootstrap" "github.com/assimon/luuu/command"
"github.com/assimon/luuu/config"
"github.com/gookit/color" "github.com/gookit/color"
) )
@@ -12,7 +11,7 @@ func main() {
color.Error.Println("[Start Server Err!!!] ", err) color.Error.Println("[Start Server Err!!!] ", err)
} }
}() }()
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ") if err := command.Execute(); err != nil {
color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "assimon", "https://github.com/assimon/epusdt") panic(err)
bootstrap.Start() }
} }
+2 -1
View File
@@ -2,12 +2,13 @@ package middleware
import ( import (
"bytes" "bytes"
"io/ioutil"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json" "github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign" "github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"io/ioutil"
) )
func CheckApiSign() echo.MiddlewareFunc { 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)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package dao
import (
"log"
)
func Init() {
if err := DBInit(); err != nil {
log.Fatalf("DBInit err: %v", err)
}
if err := RuntimeInit(); err != nil {
log.Fatalf("RuntimeInit err: %v", err)
}
}
+16 -33
View File
@@ -1,47 +1,30 @@
package dao package dao
import ( import (
"github.com/assimon/luuu/config" "strings"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"time"
) )
var Mdb *gorm.DB var Mdb *gorm.DB
// MysqlInit 数据库初始化 func DBInit() error {
func MysqlInit() { dbType := viper.GetString("db_type")
var err error if strings.EqualFold(dbType, "postgres") {
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{ if err := PostgreSQLInit(); err != nil {
NamingStrategy: schema.NamingStrategy{ return err
TablePrefix: viper.GetString("mysql_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
panic(err)
} }
if config.AppDebug { } else if strings.EqualFold(dbType, "sqlite") {
Mdb = Mdb.Debug() if err := SqliteInit(); err != nil {
return err
} }
sqlDB, err := Mdb.DB() } else {
if err != nil { if err := MysqlInit(); err != nil {
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err) return err
panic(err)
} }
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
panic(err)
} }
log.Sugar.Debug("[store_db] mysql connDB success")
MdbTableInit()
return nil
} }
+56
View File
@@ -0,0 +1,56 @@
package dao
import (
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// MysqlInit 数据库初始化
func MysqlInit() error {
var err error
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("mysql_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[store_db] mysql open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err)
// panic(err)
time.Sleep(10 * time.Second)
MysqlInit()
return err
}
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
// panic(err)
time.Sleep(10 * time.Second)
MysqlInit()
return err
}
log.Sugar.Debug("[store_db] mysql connDB success")
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package dao
import (
"fmt"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// PostgreSQLInit 数据库初始化
func PostgreSQLInit() error {
var err error
user := viper.GetString("postgres_user")
pass := viper.GetString("postgres_passwd")
host := viper.GetString("postgres_host")
port := viper.GetString("postgres_port")
dbname := viper.GetString("postgres_database")
dsn := fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
user, pass, host, port, dbname,
)
Mdb, err = gorm.Open(postgres.Open(dsn),
&gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("postgres_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
},
)
if err != nil {
color.Red.Printf("[store_db] postgres open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] postgres get DB,err=%s\n", err)
// panic(err)
return err
}
sqlDB.SetMaxIdleConns(viper.GetInt("postgres_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("postgres_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("postgres_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] postgres connDB err:%s", err.Error())
// panic(err)
return err
}
log.Sugar.Debug("[store_db] postgres connDB success")
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package dao
import (
"os"
"path/filepath"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// SqliteInit 数据库初始化
func SqliteInit() error {
var err error
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 = openDB(dbFilename, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("sqlite_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[store_db] sqlite open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
if _, err = configureSQLite(Mdb, 1); err != nil {
color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error())
return err
}
log.Sugar.Debug("[store_db] sqlite connDB success")
return nil
}
+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
}
+60
View File
@@ -0,0 +1,60 @@
package dao
import (
"sync"
"github.com/assimon/luuu/model/mdb"
"github.com/gookit/color"
)
var once sync.Once
// 自动建表
func MdbTableInit() {
once.Do(func() {
if err := Mdb.AutoMigrate(&mdb.Orders{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
// panic(err)
return
}
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
// panic(err)
return
}
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
return
}
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)
}
}
-41
View File
@@ -1,41 +0,0 @@
package dao
import (
"context"
"fmt"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"github.com/gookit/color"
"github.com/spf13/viper"
"time"
)
var Rdb *redis.Client
func RedisInit() {
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)
} else {
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
}
}
+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
}
+225 -55
View File
@@ -1,109 +1,279 @@
package data package data
import ( import (
"context" "errors"
"fmt" "strings"
"time"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/go-redis/redis/v8" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
"time" "gorm.io/gorm/clause"
) )
var ( var ErrTransactionLocked = errors.New("transaction amount is already locked")
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
)
// 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) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err return order, err
} }
// GetOrderInfoByTradeId 通过交易号查询订单 // GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err return order, err
} }
// CreateOrderWithTransaction 事务创建订单 // CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error return tx.Model(order).Create(order).Error
return err
} }
// GetOrderByBlockIdWithTransaction 通过区块获取订单 // GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
order := new(mdb.Orders) 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 return order, err
} }
// OrderSuccessWithTransaction 事务支付成功 // OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error { func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{ result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", req.TradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId, "block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess, "status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo, "callback_confirm": mdb.CallBackConfirmNo,
}).Error })
return err return result.RowsAffected > 0, result.Error
} }
// GetPendingCallbackOrders 查询出等待回调的订单 // GetPendingCallbackOrders returns the minimal callback scheduling state.
func GetPendingCallbackOrders() ([]mdb.Orders, error) { func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
var orders []mdb.Orders var orders []PendingCallbackOrder
err := dao.Mdb.Model(orders). query := dao.Mdb.Model(&mdb.Orders{}).
Where("callback_num < ?", 5). Select("trade_id", "callback_num", "callback_confirm", "updated_at").
Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess). 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 Find(&orders).Error
return orders, err return orders, err
} }
// SaveCallBackOrdersResp 保存订单回调结果 // MarkParentOrderSuccess updates the parent order with the sub-order's payment details.
func SaveCallBackOrdersResp(order *mdb.Orders) error { // Token and network are NOT overwritten — the parent keeps its original values.
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{ func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
"callback_num": gorm.Expr("callback_num + ?", 1), result := dao.Mdb.Model(&mdb.Orders{}).
"callback_confirm": order.CallBackConfirm, Where("trade_id = ?", parentTradeId).
}).Error Where("status = ?", mdb.StatusWaitPay).
return err 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设置订单过期 // MarkOrderSelected sets is_selected=true for the given trade_id.
func UpdateOrderIsExpirationById(id uint64) error { func MarkOrderSelected(tradeId string) error {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error return dao.Mdb.Model(&mdb.Orders{}).
return err Where("trade_id = ?", tradeId).
Update("is_selected", true).Error
} }
// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号 // RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { // Called on the parent order when a sub-order is created or returned.
ctx := context.Background() func RefreshOrderExpiration(tradeId string) error {
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) return dao.Mdb.Model(&mdb.Orders{}).
result, err := dao.Rdb.Get(ctx, cacheKey).Result() Where("trade_id = ?", tradeId).
if err == redis.Nil { Update("created_at", time.Now()).Error
return "", nil
} }
// 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 { if err != nil {
return "", err return "", err
} }
return result, nil if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
} }
// LockTransaction 锁定交易 // LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error { func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
ctx := context.Background() scaledAmount, amountText := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) normalizedToken := normalizeLockToken(token)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err() 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 return err
} }
// UnLockTransaction 解锁交易 result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
func UnLockTransaction(token string, amount float64) error { if result.Error != nil {
ctx := context.Background() return result.Error
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) }
err := dao.Rdb.Del(ctx, cacheKey).Err() if result.RowsAffected == 0 {
return err return ErrTransactionLocked
}
return nil
})
}
// 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
}
+59 -7
View File
@@ -1,14 +1,38 @@
package data package data
import ( import (
"strings"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
) )
// AddWalletAddress 创建钱包 // AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
func AddWalletAddress(token string) (*mdb.WalletAddress, error) { func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token) return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
}
// isEVMNetwork 判断是否是 EVM 网络
func isEVMNetwork(network string) bool {
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
}
return false
}
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
// evm 网络地址统一小写,tron 和 solana 保持原样
if isEVMNetwork(network) {
address = strings.ToLower(address)
}
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -16,17 +40,28 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists return nil, constant.WalletAddressAlreadyExists
} }
walletAddress := &mdb.WalletAddress{ walletAddress := &mdb.WalletAddress{
Token: token, Network: network,
Address: address,
Status: mdb.TokenStatusEnable, Status: mdb.TokenStatusEnable,
} }
err = dao.Mdb.Create(walletAddress).Error err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err return walletAddress, err
} }
// GetWalletAddressByToken 通过钱包地址获取token // GetWalletAddressByNetworkAndAddress 通过网络和地址查询
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) { func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress) 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 return walletAddress, err
} }
@@ -50,6 +85,16 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err 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 获得所有钱包地址 // GetAllWalletAddress 获得所有钱包地址
func GetAllWalletAddress() ([]mdb.WalletAddress, error) { func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
var WalletAddressList []mdb.WalletAddress var WalletAddressList []mdb.WalletAddress
@@ -57,6 +102,13 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err 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 启用禁用钱包 // ChangeWalletAddressStatus 启用禁用钱包
func ChangeWalletAddressStatus(id uint64, status int) error { func ChangeWalletAddressStatus(id uint64, status int) error {
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
+2 -2
View File
@@ -1,12 +1,12 @@
package mdb package mdb
import ( import (
"github.com/golang-module/carbon/v2" "github.com/dromara/carbon/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type BaseModel struct { type BaseModel struct {
ID uint64 `gorm:"column:id;primary_key" json:"id"` ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"`
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"` CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"` UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
+22 -12
View File
@@ -8,22 +8,32 @@ const (
CallBackConfirmNo = 2 CallBackConfirmNo = 2
) )
const (
PaymentTypeEpay = "Epay"
)
type Orders struct { type Orders struct {
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号 TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易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
BlockTransactionId string `gorm:"column:block_transaction_id" json:"block_transaction_id"` // 区块id ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数 BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 Amount float64 `gorm:"column:amount" json:"amount"`
Token string `gorm:"column:token" json:"token"` // 所属钱包地址 Currency string `gorm:"column:currency" json:"currency"`
Status int `gorm:"column:status" json:"status"` // 1:等待支付,2:支付成功,3:已过期 ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 Token string `gorm:"column:token" json:"token"`
CallbackNum int `gorm:"column:callback_num" json:"callback_num"` // 回调次数 Network string `gorm:"column:network" json:"network"`
CallBackConfirm int `gorm:"column:callback_confirm" json:"callback_confirm"` // 回调是否已确认 1是 2否 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 BaseModel
} }
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string { func (o *Orders) TableName() string {
return "orders" 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 TokenStatusDisable = 2
) )
// WalletAddress 钱包表 const (
NetworkTron = "tron"
NetworkSolana = "solana"
NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
)
type WalletAddress struct { type WalletAddress struct {
Token string `gorm:"column:token" json:"token"` // 钱包token Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
Status int64 `gorm:"column:status" json:"status"` // 1:启用 2:禁用 Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel BaseModel
} }
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string { func (w *WalletAddress) TableName() string {
return "wallet_address" return "wallet_address"
} }
+26
View File
@@ -5,15 +5,23 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求 // CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct { type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"` OrderId string `json:"order_id" 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"` Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"` NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"` Signature string `json:"signature" validate:"required"`
RedirectUrl string `json:"redirect_url"` RedirectUrl string `json:"redirect_url"`
Name string `json:"name"`
PaymentType string `json:"payment_type"`
} }
func (r CreateTransactionRequest) Translates() map[string]string { func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{ return validate.MS{
"OrderId": "订单号", "OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Network": "网络",
"Amount": "支付金额", "Amount": "支付金额",
"NotifyUrl": "异步回调网址", "NotifyUrl": "异步回调网址",
"Signature": "签名", "Signature": "签名",
@@ -22,8 +30,26 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理 // OrderProcessingRequest 订单处理
type OrderProcessingRequest struct { type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string Token string
Network string
Amount float64 Amount float64
TradeId string TradeId string
BlockTransactionId 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订单号 TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数 Amount float64 `json:"amount"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 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"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址 PaymentUrl string `json:"payment_url"` // 收银台地址
} }
@@ -17,8 +19,22 @@ type OrderNotifyResponse struct {
OrderId string `json:"order_id"` // 客户交易id OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数 Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_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 BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名 Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 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 { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id"` // epusdt订单号
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额
Token string `json:"token"` // 收款钱包地址 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"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"` RedirectUrl string `json:"redirect_url"`
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected"`
} }
type CheckStatusResponse struct { 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"`
}
+292 -69
View File
@@ -1,52 +1,61 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"math/rand"
"strings"
"sync"
"time"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response" "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/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math" "github.com/assimon/luuu/util/math"
"github.com/golang-module/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"math/rand"
"sync"
"time"
) )
const ( const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额 CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额 UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额 UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100 // 最大递增次数 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) { func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() 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) 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) decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// Usdt是否可以满足最低支付金额 if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId) exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -54,134 +63,210 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 { if exist.ID > 0 {
return nil, constant.OrderAlreadyExists return nil, constant.OrderAlreadyExists
} }
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress() walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(walletAddress) <= 0 { if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress 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 { if err != nil {
return nil, err return nil, err
} }
if availableToken == "" { if availableAddress == "" {
return nil, constant.NotAvailableAmountErr return nil, constant.NotAvailableAmountErr
} }
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: GenerateCode(), TradeId: tradeID,
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: req.Amount,
Currency: currency,
ActualAmount: availableAmount, ActualAmount: availableAmount,
Token: availableToken, ReceiveAddress: availableAddress,
Token: token,
Network: network,
Status: mdb.StatusWaitPay, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl, NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
} }
err = data.CreateOrderWithTransaction(tx, order) if err = data.CreateOrderWithTransaction(tx, order); err != nil {
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
// 锁定支付池 if err = tx.Commit().Error; err != nil {
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
tx.Commit()
// 超时过期消息队列 expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()))
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{ resp := &response.CreateTransactionResponse{
TradeId: order.TradeId, TradeId: order.TradeId,
OrderId: order.OrderId, OrderId: order.OrderId,
Amount: order.Amount, Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount, ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token, Token: order.Token,
ExpirationTime: ExpirationTime, ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
} }
return resp, nil return resp, nil
} }
// OrderProcessing 成功处理订单 // OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error { func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId) exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil { if err != nil {
tx.Rollback()
return err return err
} }
if exist.ID > 0 { if exist.ID > 0 {
tx.Rollback() tx.Rollback()
return constant.OrderBlockAlreadyProcess return constant.OrderBlockAlreadyProcess
} }
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req) updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
// 解锁交易 if !updated {
err = data.UnLockTransaction(req.Token, req.Amount) tx.Rollback()
if err != nil { return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback() tx.Rollback()
return err 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 return nil
} }
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 // Parent order paid directly: expire all sub-orders and release their locks
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { if order.ParentTradeId == "" {
availableToken := "" subs, subErr := data.GetActiveSubOrders(order.TradeId)
availableAmount := amount if subErr != nil {
calculateAvailableWalletFunc := func(amount float64) (string, error) { log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
availableWallet := "" return nil
for _, address := range walletAddress { }
token := address.Token for _, sub := range subs {
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) 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 { 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
}
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
}
if errors.Is(err, data.ErrTransactionLocked) {
continue
}
return "", err return "", err
} }
if result == "" { return "", nil
availableWallet = token
break
}
}
return availableWallet, nil
} }
for i := 0; i < IncrementalMaximumNumber; i++ { for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount) address, err := tryLockWalletFunc(availableAmount)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
// 拿不到可用钱包就累加金额 if address == "" {
if token == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue continue
} }
availableToken = token availableAddress = address
break break
} }
return availableToken, availableAmount, nil return availableAddress, availableAmount, nil
} }
// GenerateCode 订单号生成 // GenerateCode creates a unique trade id.
func GenerateCode() string { func GenerateCode() string {
date := time.Now().Format("20060102") date := time.Now().Format("20060102")
r := rand.Intn(1000) r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
} }
// GetOrderInfoByTradeId 通过交易号获取订单 // GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId) order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
@@ -192,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
} }
return order, nil return order, nil
} }
const MaxSubOrders = 2
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
network := strings.ToLower(strings.TrimSpace(req.Network))
// 1. Load parent order
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return nil, err
}
if parent.ID <= 0 {
return nil, constant.OrderNotExists
}
if parent.ParentTradeId != "" {
return nil, constant.CannotSwitchSubOrder
}
if parent.Status != mdb.StatusWaitPay {
return nil, constant.OrderNotWaitPay
}
// 2. Same token+network as parent → mark selected and return
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
_ = data.MarkOrderSelected(parent.TradeId)
parent.IsSelected = true
return buildCheckoutResponse(parent), nil
}
// 3. Existing active sub-order for this token+network → return it
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
if err != nil {
return nil, err
}
if existing.ID > 0 {
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
return buildCheckoutResponse(existing), nil
}
// 4. Check sub-order limit
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
if count >= MaxSubOrders {
return nil, constant.SubOrderLimitExceeded
}
// 5. Calculate amount for the new network
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 6. Find and lock wallet
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)
}
}
+13 -3
View File
@@ -2,27 +2,37 @@ package service
import ( import (
"errors" "errors"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response" "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) { func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId) orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, errors.New("不存在待支付订单或已过期!") return nil, ErrOrder
} }
resp := &response.CheckoutCounterResponse{ resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId, TradeId: orderInfo.TradeId,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount, ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token, Token: orderInfo.Token,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampWithMillisecond(), Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl, RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
} }
return resp, nil 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)
}
+382 -110
View File
@@ -1,153 +1,425 @@
package service package service
import ( import (
"encoding/hex"
"errors"
"fmt" "fmt"
"github.com/assimon/luuu/model/data" "math/big"
"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/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/golang-module/carbon/v2"
"github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"net/http" "net/http"
"strings"
"sync" "sync"
"github.com/assimon/luuu/config"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
"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 { func Trc20CallBack(address string, wg *sync.WaitGroup) {
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) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
log.Sugar.Error(err) 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() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampWithMillisecond() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampWithMillisecond() endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
resp, err := client.R().SetQueryParams(map[string]string{ resp, err := client.R().SetQueryParams(map[string]string{
"sort": "-timestamp", "order_by": "block_timestamp,desc",
"limit": "50", "limit": "100",
"start": "0", "only_to": "true",
"direction": "2", "min_timestamp": stdutil.ToString(startTime),
"db_version": "1", "max_timestamp": stdutil.ToString(endTime),
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
"address": token,
"start_timestamp": stdutil.ToString(startTime),
"end_timestamp": stdutil.ToString(endTime),
}).Get(UsdtTrc20ApiUri)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if resp.StatusCode() != http.StatusOK { 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) success := gjson.GetBytes(resp.Body(), "success").Bool()
if err != nil { if !success {
panic(err) 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 return
} }
for _, transfer := range trc20Resp.Data { log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
if transfer.To != token || transfer.ContractRet != "SUCCESS" {
for i, transfer := range transfers {
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue continue
} }
decimalQuant, err := decimal.NewFromString(transfer.Amount) if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
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 == "" {
continue 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 { if err != nil {
panic(err) panic(err)
} }
// 区块的确认时间必须在订单创建时间之后 if tradeID == "" {
createTime := order.CreatedAt.TimestampWithMillisecond() log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
if transfer.BlockTimestamp < createTime { continue
panic("Orders cannot actually be matched")
} }
// 到这一步就完全算是支付成功了 log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
Token: token, ReceiveAddress: address,
TradeId: tradeId, Token: "TRX",
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: transfer.Hash, BlockTransactionId: txID,
} }
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { 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) panic(err)
} }
// 回调队列
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) sendPaymentNotification(order)
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5)) log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
// 发送机器人消息 }
msgTpl := ` }
<b>📢📢有新的交易支付成功</b>
<pre>交易号%s</pre> func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
<pre>订单号%s</pre> defer wg.Done()
<pre>请求支付金额%f cny</pre> defer func() {
<pre>实际支付金额%f usdt</pre> if err := recover(); err != nil {
<pre>钱包地址%s</pre> log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
<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()) 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) 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)
}
} }
-67
View File
@@ -1,67 +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), 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
}
-35
View File
@@ -1,35 +0,0 @@
package handle
import (
"context"
"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)), 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
}
+14 -43
View File
@@ -1,51 +1,22 @@
package mq package mq
import ( import (
"fmt" "sync"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/config"
"github.com/hibiken/asynq"
"github.com/spf13/viper"
) )
var MClient *asynq.Client var (
startOnce sync.Once
callbackLimiter chan struct{}
callbackInflight sync.Map
)
func Start() { func Start() {
redis := asynq.RedisClientOpt{ startOnce.Do(func() {
Addr: fmt.Sprintf( callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
"%s:%s", go runOrderExpirationLoop()
viper.GetString("redis_host"), go runOrderCallbackLoop()
viper.GetString("redis_port")), go runTransactionLockCleanupLoop()
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)
}
} }
+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 package route
import ( import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/controller/comm" "github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware" "github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"net/http"
) )
// RegisterRoute 路由注册 // RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) { func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error { 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") payRoute := e.Group("/pay")
// ====订单相关==== payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
orderRoute := apiV1Route.Group("/order", middleware.CheckApiSign()) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
// 创建订单 payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
orderRoute.POST("/create-transaction", comm.Ctrl.CreateTransaction)
// 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

+500 -198
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-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;
}
+25 -5
View File
@@ -1,12 +1,32 @@
package task package task
import "github.com/robfig/cron/v3" import (
"github.com/assimon/luuu/util/log"
"github.com/robfig/cron/v3"
)
func Start() { func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
go StartEthereumWebSocketListener()
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
c := cron.New() c := cron.New()
// 汇率监听
c.AddJob("@every 60s", UsdtRateJob{})
// trc20钱包监听 // trc20钱包监听
c.AddJob("@every 5s", ListenTrc20Job{}) _, err := c.AddJob("@every 5s", ListenTrc20Job{})
c.Start() 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-rpc.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 package task
import ( import (
"sync"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service" "github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
"sync"
) )
type ListenTrc20Job struct { type ListenTrc20Job struct{}
}
var gListenTrc20JobLock sync.Mutex var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() { func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock() gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock() defer gListenTrc20JobLock.Unlock()
walletAddress, err := data.GetAvailableWalletAddress() log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil { if err != nil {
log.Sugar.Error(err) log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return return
} }
if len(walletAddress) <= 0 { if len(walletAddress) <= 0 {
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return return
} }
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup var wg sync.WaitGroup
for _, address := range walletAddress { for _, address := range walletAddress {
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
wg.Add(1) wg.Add(1)
go service.Trc20CallBack(address.Token, &wg) go service.Trc20CallBack(address.Address, &wg)
} }
wg.Wait() 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
}
}
}
+157 -15
View File
@@ -2,6 +2,11 @@ package telegram
import ( import (
"fmt" "fmt"
"sort"
"strings"
"sync"
"time"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/gookit/goutil/mathutil" "github.com/gookit/goutil/mathutil"
@@ -10,60 +15,143 @@ import (
) )
const ( 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 { func OnTextMessageHandle(c tb.Context) error {
if c.Message().ReplyTo.Text == ReplayAddWallet { msg := c.Message()
defer bots.Delete(c.Message().ReplyTo) if msg == nil {
_, err := data.AddWalletAddress(c.Message().Text) return nil
}
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 { if err != nil {
return c.Send(err.Error()) return c.Send(err.Error())
} }
c.Send(fmt.Sprintf("钱包[%s]添加成功!", c.Message().Text)) pendingWalletAddressUsers.Delete(senderID)
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功(%s", storeAddress, strings.ToUpper(state.Network)))
return WalletList(c) return WalletList(c)
} }
return nil
}
func WalletList(c tb.Context) error { func WalletList(c tb.Context) error {
wallets, err := data.GetAllWalletAddress() wallets, err := data.GetAllWalletAddress()
if err != nil { if err != nil {
return err return err
} }
var btnList [][]tb.InlineButton var btnList [][]tb.InlineButton
for _, wallet := range wallets { for _, wallet := range wallets {
status := "已启用✅" status := "已启用✅"
if wallet.Status == mdb.TokenStatusDisable { if wallet.Status == mdb.TokenStatusDisable {
status = "已禁用🚫" status = "已禁用🚫"
} }
var temp []tb.InlineButton net := wallet.Network
if net == "" {
net = mdb.NetworkTron
}
btnInfo := tb.InlineButton{ btnInfo := tb.InlineButton{
Unique: wallet.Token, Unique: wallet.Address,
Text: fmt.Sprintf("%s[%s]", wallet.Token, status), Text: fmt.Sprintf("[%s] %s [%s]", net, wallet.Address, status),
Data: strutil.MustString(wallet.ID), Data: strutil.MustString(wallet.ID),
} }
bots.Handle(&btnInfo, WalletInfo) bots.Handle(&btnInfo, WalletInfo)
btnList = append(btnList, append(temp, btnInfo)) btnList = append(btnList, []tb.InlineButton{btnInfo})
} }
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"} addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error { bots.Handle(&addBtn, func(c tb.Context) error {
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{ chains, err := getEnabledSupportedNetworks()
ForceReply: true, 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}) btnList = append(btnList, []tb.InlineButton{addBtn})
return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
InlineKeyboard: btnList, InlineKeyboard: btnList,
}) })
} }
func SelectWalletNetwork(c tb.Context) error {
network := strings.ToLower(strings.TrimSpace(c.Data()))
if network == "" {
return c.Send("请选择有效网络")
}
if sender := c.Sender(); sender != nil {
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{
RequestedAt: time.Now(),
Network: network,
})
}
return c.Send(fmt.Sprintf(ReplayAddWallet, strings.ToUpper(network)), &tb.ReplyMarkup{
ForceReply: true,
})
}
func WalletInfo(c tb.Context) error { func WalletInfo(c tb.Context) error {
id := mathutil.MustUint(c.Data()) id := mathutil.MustUint(c.Data())
tokenInfo, err := data.GetWalletAddressById(id) tokenInfo, err := data.GetWalletAddressById(id)
if err != nil { if err != nil {
return c.Send(err.Error()) return c.Send(err.Error())
} }
enableBtn := tb.InlineButton{ enableBtn := tb.InlineButton{
Text: "启用", Text: "启用",
Unique: "enableBtn", Unique: "enableBtn",
@@ -83,11 +171,18 @@ func WalletInfo(c tb.Context) error {
Text: "返回", Text: "返回",
Unique: "WalletList", Unique: "WalletList",
} }
bots.Handle(&enableBtn, EnableWallet) bots.Handle(&enableBtn, EnableWallet)
bots.Handle(&disableBtn, DisableWallet) bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet) bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList) 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, enableBtn,
disableBtn, disableBtn,
@@ -134,3 +229,50 @@ func DelWallet(c tb.Context) error {
} }
return WalletList(c) 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
}
+10 -2
View File
@@ -15,7 +15,8 @@ func BotStart() {
var err error var err error
botSetting := tb.Settings{ botSetting := tb.Settings{
Token: config.TgBotToken, Token: config.TgBotToken,
Poller: &tb.LongPoller{Timeout: 10 * time.Second}, Poller: &tb.LongPoller{Timeout: 50 * time.Second},
Synchronous: true,
} }
if config.TgProxy != "" { if config.TgProxy != "" {
botSetting.URL = config.TgProxy botSetting.URL = config.TgProxy
@@ -45,14 +46,21 @@ func RegisterHandle() {
// SendToBot 主动发送消息机器人消息 // SendToBot 主动发送消息机器人消息
func SendToBot(msg string) { func SendToBot(msg string) {
go func() { go func() {
if bots == nil {
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
return
}
user := tb.User{ user := tb.User{
ID: config.TgManage, ID: config.TgManage,
} }
log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
_, err := bots.Send(&user, msg, &tb.SendOptions{ _, err := bots.Send(&user, msg, &tb.SendOptions{
ParseMode: tb.ModeHTML, ParseMode: tb.ModeHTML,
}) })
if err != nil { 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)
} }
}() }()
} }
+80
View File
@@ -0,0 +1,80 @@
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 {
// 基本过滤
if len(addr) < 26 || len(addr) > 35 || addr[0] != 'T' {
return false
}
decoded := base58.Decode(addr)
if len(decoded) != 25 {
return false
}
// TRON 主网地址必须以 0x41 开头
if decoded[0] != 0x41 {
return false
}
// Base58Check 校验
payload := decoded[:21] // 前 21 字节
checksum := decoded[21:] // 后 4 字节
hash := sha256.Sum256(payload)
hash2 := sha256.Sum256(hash[:])
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 package constant
var Errno = map[int]string{ var Errno = map[int]string{
400: "系统错误", 400: "system error",
401: "签名认证错误", 401: "signature verification failed",
10001: "钱包地址已存在,请勿重复添加", 10001: "wallet address already exists",
10002: "支付交易已存在,请勿重复创建", 10002: "order already exists",
10003: "无可用钱包地址,无法发起支付", 10003: "no available wallet address",
10004: "支付金额有误, 无法满足最小支付单位", 10004: "invalid payment amount",
10005: "无可用金额通道", 10005: "no available amount channel",
10006: "汇率计算错误", 10006: "rate calculation failed",
10007: "订单区块已处理", 10007: "block transaction already processed",
10008: "订单不存在", 10008: "order does not exist",
10009: "无法解析请求参数", 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 ( var (
@@ -26,6 +32,12 @@ var (
OrderBlockAlreadyProcess = Err(10007) OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008) OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009) ParamsMarshalErr = Err(10009)
OrderStatusConflict = Err(10010)
SubOrderLimitExceeded = Err(10011)
CannotSwitchSubOrder = Err(10012)
OrderNotWaitPay = Err(10013)
SupportedAssetAlreadyExists = Err(10014)
SupportedAssetNotFound = Err(10015)
) )
type RspError struct { type RspError struct {
+3 -2
View File
@@ -1,8 +1,9 @@
package http_client package http_client
import ( import (
"github.com/go-resty/resty/v2"
"time" "time"
"github.com/go-resty/resty/v2"
) )
// GetHttpClient 获取请求客户端 // GetHttpClient 获取请求客户端
@@ -13,6 +14,6 @@ func GetHttpClient(proxys ...string) *resty.Client {
proxy := proxys[0] proxy := proxys[0]
client.SetProxy(proxy) client.SetProxy(proxy)
} }
client.SetTimeout(time.Second * 5) client.SetTimeout(time.Second * 10)
return client return client
} }
+32 -5
View File
@@ -7,27 +7,37 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"os"
"time" "time"
) )
var Sugar *zap.SugaredLogger var Sugar *zap.SugaredLogger
func Init() { func Init() {
writeSyncer := getLogWriter() level := getLogLevel()
encoder := getEncoder() core := zapcore.NewTee(
core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
)
logger := zap.New(core, zap.AddCaller()) logger := zap.New(core, zap.AddCaller())
Sugar = logger.Sugar() Sugar = logger.Sugar()
} }
func getEncoder() zapcore.Encoder { func getFileEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig() encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig) 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", file := fmt.Sprintf("%s/log_%s.log",
config.LogSavePath, config.LogSavePath,
time.Now().Format("20060102")) time.Now().Format("20060102"))
@@ -40,3 +50,20 @@ func getLogWriter() zapcore.WriteSyncer {
} }
return zapcore.AddSync(lumberJackLogger) 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
+7 -3
View File
@@ -8,9 +8,9 @@ cd /var/www/
mkdir epusdt mkdir epusdt
chmod 777 -R /var/www/epusdt chmod 777 -R /var/www/epusdt
cd epusdt cd epusdt
wget https://github.com/assimon/epusdt/releases/download/v0.0.2/epusdt_v0.0.2_Linux_x86_64.tar.gz wget https://github.com/assimon/epusdt/releases/download/v0.0.3/epusdt_0.0.3_Linux_x86_64.tar.gz
tar -xzf epusdt_v0.0.2_Linux_x86_64.tar.gz tar -xzf epusdt_0.0.3_Linux_x86_64.tar.gz
rm epusdt_v0.0.2_Linux_x86_64.tar.gz rm epusdt_0.0.3_Linux_x86_64.tar.gz
``` ```
## 2.导入Sql ## 2.导入Sql
- 创建sql文件 - 创建sql文件
@@ -169,6 +169,10 @@ order_expiration_time=10
forced_usdt_rate= forced_usdt_rate=
``` ```
⚠️注意:配置文件里面不认识的不要修改,留空即可,不会改又要瞎改,除非你对项目源代码很熟悉很有信心😁 ⚠️注意:配置文件里面不认识的不要修改,留空即可,不会改又要瞎改,除非你对项目源代码很熟悉很有信心😁
必填配置项:app_uri、mysql配置、redis配置、api_auth_token
选填配置项:tg_bot_token、tg_manage
## 6、配置supervisor ## 6、配置supervisor
为了保证`Epusdt`常驻后台运行,我们需要配置`supervisor`来实现进程监听 为了保证`Epusdt`常驻后台运行,我们需要配置`supervisor`来实现进程监听
```bash ```bash