1 Commits

Author SHA1 Message Date
Ashang 549b55da6b 修复asynq交叉编译问题,version0.0.2 2022-04-14 17:17:26 +08:00
85 changed files with 1742 additions and 5508 deletions
-46
View File
@@ -1,46 +0,0 @@
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
@@ -1,79 +0,0 @@
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
+1 -5
View File
@@ -1,8 +1,4 @@
.idea/
src/runtime/
src/.env
src/*.db
src/*.db-shm
src/*.db-wal
.DS_Store
.env
.DS_Store
-157
View File
@@ -1,157 +0,0 @@
# 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
@@ -1,24 +0,0 @@
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"]
+20 -7
View File
@@ -7,7 +7,7 @@
<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>
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.2"><img src="https://img.shields.io/badge/version-v0.0.2-green" alt="version v0.0.2"></a>
</p>
@@ -38,12 +38,8 @@ Epusdt
```
## 教程:
- docker 运行教程 [docker-RUN](wiki/docker-RUN.md)
- API 文档( 自定义汇率接口 ) [API](wiki/LEGACY_API_MIGRATION.md)
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
## 已适配系统插件
- 独角数卡[插件地址](plugins/dujiaoka)
@@ -67,7 +63,24 @@ Epusdt
### 流程图:
![Implementation principle](wiki/img/implementation_principle.jpg)
## 打赏
如果该项目对您有所帮助,希望可以请我喝一杯咖啡☕️
```
Usdt(trc20)打赏地址: TNEns8t9jbWENbStkQdVQtHMGpbsYsQjZK
```
<img src="wiki/img/usdt_thanks.jpeg" width = "300" height = "400" alt="usdt扫码打赏"/>
## 声明
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
`Epusdt`为开源的产品,仅用于学习交流使用!
不可用于任何违反中华人民共和国(含台湾省)或使用者所在地区法律法规的用途。
因为作者即本人仅完成代码的开发和开源活动(开源即任何人都可以下载使用或修改分发),从未参与用户的任何运营和盈利活动。
且不知晓用户后续将程序源代码用于何种用途,故用户使用过程中所带来的任何法律责任即由用户自己承担。
```
!!!Warning!!!
项目中所涉及区块链代币均为学习用途,作者并不赞成区块链所繁衍出代币的金融属性
亦不鼓励和支持任何"挖矿""炒币""虚拟币ICO"等非法行为
虚拟币市场行为不受监管要求和控制,投资交易需谨慎,仅供学习区块链知识
```
-70
View File
@@ -1,70 +0,0 @@
# 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
@@ -1,208 +0,0 @@
# 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
@@ -1,11 +0,0 @@
services:
epusdt:
image: gmwallet/epusdt:alpine
restart: always
build:
context: .
dockerfile: Dockerfile
volumes:
- ./env:/app/.env
ports:
- "8000:8000"
+31 -34
View File
@@ -1,63 +1,60 @@
app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com
log_level=info
http_access_log=false
sql_debug=false
#是否开启debug,默认false
app_debug=false
#http服务监听端口
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配置
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql_user
mysql_passwd=mysql_password
mysql_database=database_name
mysql_user=mysql账号
mysql_passwd=mysql密码
mysql_database=数据库
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# redis配置
redis_host=127.0.0.1
redis_port=6379
redis_passwd=
redis_db=5
redis_pool_size=5
redis_max_retries=3
redis_idle_timeout=1000
# background scheduler config
# 消息队列配置
queue_concurrency=10
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
queue_level_critical=6
queue_level_default=3
queue_level_low=1
#机器人Apitoken
tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy=
#管理员userid
tg_manage=
#api接口认证token
api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10
order_notice_max_retry=0
forced_usdt_rate=
api_rate_url=
tron_grid_api_key=
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate=
+24 -43
View File
@@ -1,62 +1,43 @@
version: 2
project_name: epusdt
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
before:
hooks:
- go test ./...
# You may remove this if you don't use go modules.
#- go mod tidy
# you may remove this if you don't need go generate
#- go generate ./...
builds:
- id: epusdt
main: .
binary: epusdt
env:
- env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
goos:
- linux
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- arm
- 386
archives:
- id: epusdt
ids:
- epusdt
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
id: epusdt
name_template: "{{ .ProjectName }}_v0.0.2_{{ .Os }}_{{ .Arch }}"
format: tar.gz
files:
- .env.example
- static/**
format_overrides:
- goos: windows
formats:
- zip
- static/*
- runtime/*
checksum:
name_template: "SHA256SUMS"
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
release:
github:
owner: GMwalletApp
name: epusdt
prerelease: auto
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
- "^fix\\(ci\\):"
- "^build\\(release\\):"
- "^Merge pull request"
- '^docs:'
- '^test:'
+21 -13
View File
@@ -1,8 +1,7 @@
package bootstrap
import (
"sync"
"github.com/assimon/luuu/command"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/mq"
@@ -11,15 +10,24 @@ import (
"github.com/assimon/luuu/util/log"
)
var initOnce sync.Once
func InitApp() {
initOnce.Do(func() {
config.Init()
log.Init()
dao.Init()
mq.Start()
go telegram.BotStart()
go task.Start()
})
//Start 服务启动
func Start() {
// 配置加载
config.Init()
// 日志加载
log.Init()
// Mysql启动
dao.MysqlInit()
// redis启动
dao.RedisInit()
// 队列启动
mq.Start()
// telegram机器人启动
go telegram.BotStart()
// 定时任务
go task.Start()
err := command.Execute()
if err != nil {
panic(err)
}
}
-18
View File
@@ -1,18 +0,0 @@
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",
)
}
+19 -18
View File
@@ -2,12 +2,6 @@ package command
import (
"context"
"net/http"
"os"
"os/signal"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route"
@@ -18,12 +12,16 @@ import (
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"net/http"
"os"
"os/signal"
"time"
)
var httpCmd = &cobra.Command{
Use: "http",
Short: "http service",
Long: "http service commands",
Short: "http服务",
Long: "http服务相关命令",
Run: func(cmd *cobra.Command, args []string) {
},
}
@@ -34,11 +32,9 @@ func init() {
var startCmd = &cobra.Command{
Use: "start",
Short: "start",
Long: "start http service",
Short: "启动",
Long: "启动http服务",
Run: func(cmd *cobra.Command, args []string) {
bootstrap.InitApp()
printBanner()
HttpServerStart()
},
}
@@ -48,22 +44,23 @@ func HttpServerStart() {
e := echo.New()
e.HideBanner = true
e.HTTPErrorHandler = customHTTPErrorHandler
// 中间件注册
MiddlewareRegister(e)
// 路由注册
route.RegisterRoute(e)
e.Static(config.StaticPath, config.StaticFilePath)
// 静态目录注册
e.Static(config.StaticPath, "static")
httpListen := viper.GetString("http_listen")
go func() {
if err = e.Start(httpListen); err != http.ErrServerClosed {
log.Sugar.Error(err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, os.Kill)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err = e.Shutdown(ctx); err != nil {
@@ -71,13 +68,16 @@ func HttpServerStart() {
}
}
// MiddlewareRegister 中间件注册
func MiddlewareRegister(e *echo.Echo) {
if config.HTTPAccessLog {
if config.AppDebug {
e.Debug = true
e.Use(echoMiddleware.Logger())
}
e.Use(middleware.RequestUUID())
}
// customHTTPErrorHandler 默认消息提示
func customHTTPErrorHandler(err error, e echo.Context) {
code := http.StatusInternalServerError
msg := "server error"
@@ -95,4 +95,5 @@ func customHTTPErrorHandler(err error, e echo.Context) {
resp.Message = he.Msg
}
_ = e.JSON(http.StatusOK, resp)
return
}
+1 -10
View File
@@ -1,11 +1,6 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var configPath string
import "github.com/spf13/cobra"
var rootCmd = &cobra.Command{}
@@ -14,9 +9,5 @@ func Execute() error {
}
func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env")
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
config.SetConfigPath(configPath)
}
rootCmd.AddCommand(httpCmd)
}
-26
View File
@@ -1,26 +0,0 @@
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)
}
+32 -264
View File
@@ -1,169 +1,60 @@
package config
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/assimon/luuu/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
"os"
"time"
)
var (
HTTPAccessLog bool
SQLDebug bool
LogLevel string
MysqlDns string
RuntimePath string
LogSavePath string
StaticPath string
StaticFilePath string
TgBotToken string
TgProxy string
TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
configRootPath string
explicitConfigPath string
AppDebug bool
MysqlDns string
RuntimePath string
LogSavePath string
StaticPath string
TgBotToken string
TgProxy string
TgManage int64
UsdtRate float64
)
func SetConfigPath(path string) {
explicitConfigPath = strings.TrimSpace(path)
}
func Init() {
configPath, err := resolveConfigFilePath()
viper.AddConfigPath("./")
viper.SetConfigFile(".env")
err := viper.ReadInConfig()
if err != nil {
panic(err)
}
configRootPath = filepath.Dir(configPath)
viper.SetConfigFile(configPath)
err = viper.ReadInConfig()
gwd, err := os.Getwd()
if err != nil {
panic(err)
}
HTTPAccessLog = viper.GetBool("http_access_log")
SQLDebug = viper.GetBool("sql_debug")
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath)
mustMkdir(LogSavePath)
AppDebug = viper.GetBool("app_debug")
StaticPath = viper.GetString("static_path")
RuntimePath = fmt.Sprintf(
"%s%s",
gwd,
viper.GetString("runtime_root_path"))
LogSavePath = fmt.Sprintf(
"%s%s",
RuntimePath,
viper.GetString("log_save_path"))
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
url.QueryEscape(viper.GetString("mysql_user")),
url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
viper.GetString("mysql_user"),
viper.GetString("mysql_passwd"),
fmt.Sprintf(
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
viper.GetString("mysql_database"))
TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
}
func mustMkdir(path string) {
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
}
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error":
return strings.ToLower(strings.TrimSpace(level))
default:
return "info"
}
}
func normalizeStaticURLPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/static"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func resolvePathFromBase(basePath string, path string, fallback string) string {
path = strings.TrimSpace(path)
if path == "" {
return fallback
}
if filepath.IsAbs(path) {
return path
}
path = strings.TrimPrefix(path, "/")
path = strings.TrimPrefix(path, "\\")
return filepath.Join(basePath, filepath.FromSlash(path))
}
func resolveConfigFilePath() (string, error) {
if explicitConfigPath != "" {
return normalizeConfiguredPath(explicitConfigPath)
}
if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" {
return normalizeConfiguredPath(envPath)
}
return normalizeConfiguredPath(".env")
}
func normalizeConfiguredPath(input string) (string, error) {
path := strings.TrimSpace(input)
if path == "" {
path = ".env"
}
if !filepath.IsAbs(path) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
path = filepath.Join(cwd, path)
}
info, err := os.Stat(path)
if err == nil && info.IsDir() {
path = filepath.Join(path, ".env")
info, err = os.Stat(path)
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("config file not found: %s", path)
}
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("config path must point to a file, got directory: %s", path)
}
return path, nil
}
func GetAppVersion() string {
return BuildVersion
}
func GetBuildCommit() string {
return BuildCommit
}
func GetBuildDate() string {
return BuildDate
return "0.0.2"
}
func GetAppName() string {
@@ -182,73 +73,6 @@ func GetApiAuthToken() string {
return viper.GetString("api_auth_token")
}
func GetRateApiUrl() string {
rateURL := viper.GetString("api_rate_url")
if rateURL == "" {
rateURL = os.Getenv("API_RATE_URL")
}
if rateURL == "" {
log.Println("api_rate_url is empty")
}
RateApiUrl = rateURL
return rateURL
}
func GetRateForCoin(coin string, base string) float64 {
coin = strings.ToLower(strings.TrimSpace(coin))
base = strings.ToLower(strings.TrimSpace(base))
if coin == "" || base == "" {
return 0
}
if coin == base {
return 1
}
if coin == "usdt" {
switch base {
case "usd":
return 1
case "cny":
usdtRate := GetUsdtRate()
if usdtRate > 0 {
return 1 / usdtRate
}
}
}
baseURL := RateApiUrl
if baseURL == "" {
baseURL = GetRateApiUrl()
}
if baseURL == "" {
log.Printf("rate api url is empty")
return 0.0
}
if baseURL[len(baseURL)-1] != '/' {
baseURL += "/"
}
client := http_client.GetHttpClient()
resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base))
if err != nil {
log.Printf("call rate api error: %s", err.Error())
return 0.0
}
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
log.Printf("call rate api unexpected status: %s", resp.Status())
return 0.0
}
targetRate := 0.0
gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool {
if key.String() == coin {
targetRate = value.Float()
return false
}
return true
})
return targetRate
}
func GetUsdtRate() float64 {
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 {
@@ -272,59 +96,3 @@ func GetOrderExpirationTimeDuration() time.Duration {
timer := GetOrderExpirationTime()
return time.Minute * time.Duration(timer)
}
func GetRuntimeSqlitePath() string {
filename := viper.GetString("runtime_sqlite_filename")
if filename == "" {
filename = "epusdt-runtime.db"
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(RuntimePath, filepath.FromSlash(filename))
}
func GetPrimarySqlitePath() string {
filename := strings.TrimSpace(viper.GetString("sqlite_database_filename"))
if filename == "" {
return filepath.Join(configRootPath, "epusdt.db")
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(configRootPath, filepath.FromSlash(filename))
}
func GetQueueConcurrency() int {
concurrency := viper.GetInt("queue_concurrency")
if concurrency <= 0 {
return 10
}
return concurrency
}
func GetQueuePollInterval() time.Duration {
interval := viper.GetInt("queue_poll_interval_ms")
if interval <= 0 {
interval = 1000
}
return time.Duration(interval) * time.Millisecond
}
func GetOrderNoticeMaxRetry() int {
retry := viper.GetInt("order_notice_max_retry")
if retry < 0 {
return 0
}
return retry
}
func GetCallbackRetryBaseDuration() time.Duration {
seconds := viper.GetInt("callback_retry_base_seconds")
if seconds <= 0 {
seconds = 5
}
return time.Duration(seconds) * time.Second
}
-118
View File
@@ -1,118 +0,0 @@
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)
}
if got != configPath {
t.Fatalf("config path = %s, want %s", got, configPath)
}
}
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)
}
}
+4 -14
View File
@@ -1,14 +1,13 @@
package comm
import (
"html/template"
"net/http"
"path/filepath"
"fmt"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4"
"html/template"
"net/http"
)
// CheckoutCounter 收银台
@@ -16,21 +15,12 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
if err != nil {
if err == service.ErrOrder {
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
emptyResp := response.CheckoutCounterResponse{}
return tmpl.Execute(ctx.Response(), emptyResp)
}
return ctx.String(http.StatusOK, err.Error())
}
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
}
resp.Network = "TRON"
return tmpl.Execute(ctx.Response(), resp)
}
-30
View File
@@ -1,30 +0,0 @@
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)
}
+15 -81
View File
@@ -1,96 +1,30 @@
module github.com/assimon/luuu
go 1.25.0
go 1.16
require (
github.com/btcsuite/btcutil v1.0.2
github.com/dromara/carbon/v2 v2.6.15
github.com/gagliardetto/solana-go v1.16.0
github.com/go-resty/resty/v2 v2.11.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-resty/resty/v2 v2.7.0
github.com/golang-module/carbon/v2 v2.0.1
github.com/google/uuid v1.3.0 // indirect
github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1
github.com/hibiken/asynq v0.23.0
github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0
github.com/libtnb/sqlite v1.0.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0
github.com/shengdoushi/base58 v1.0.0
github.com/shopspring/decimal v1.3.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.27.0
gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.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/benbjohnson/clock v1.3.5 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // 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-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/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/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/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/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/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
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.9.0
go.uber.org/zap v1.17.0
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.1.2
gorm.io/gorm v1.21.15
)
+698 -244
View File
File diff suppressed because it is too large Load Diff
-96
View File
@@ -1,96 +0,0 @@
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)
}
}
+5 -4
View File
@@ -1,7 +1,8 @@
package main
import (
"github.com/assimon/luuu/command"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"github.com/gookit/color"
)
@@ -11,7 +12,7 @@ func main() {
color.Error.Println("[Start Server Err!!!] ", err)
}
}()
if err := command.Execute(); err != nil {
panic(err)
}
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "assimon", "https://github.com/assimon/epusdt")
bootstrap.Start()
}
+1 -2
View File
@@ -2,13 +2,12 @@ package middleware
import (
"bytes"
"io/ioutil"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign"
"github.com/labstack/echo/v4"
"io/ioutil"
)
func CheckApiSign() echo.MiddlewareFunc {
-15
View File
@@ -1,15 +0,0 @@
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)
}
}
+36 -19
View File
@@ -1,30 +1,47 @@
package dao
import (
"strings"
"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"
"time"
)
var Mdb *gorm.DB
func DBInit() error {
dbType := viper.GetString("db_type")
if strings.EqualFold(dbType, "postgres") {
if err := PostgreSQLInit(); err != nil {
return err
}
} else if strings.EqualFold(dbType, "sqlite") {
if err := SqliteInit(); err != nil {
return err
}
} else {
if err := MysqlInit(); err != nil {
return err
}
// MysqlInit 数据库初始化
func MysqlInit() {
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 {
panic(err)
}
MdbTableInit()
return nil
if config.AppDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] mysql get DB,err=%s\n", 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")
}
-56
View File
@@ -1,56 +0,0 @@
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
@@ -1,63 +0,0 @@
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
@@ -1,47 +0,0 @@
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
@@ -1,16 +0,0 @@
//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
@@ -1,16 +0,0 @@
//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
}
-26
View File
@@ -1,26 +0,0 @@
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
}
})
}
+41
View File
@@ -0,0 +1,41 @@
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)
}
}
-51
View File
@@ -1,51 +0,0 @@
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.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
@@ -1,35 +0,0 @@
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
}
+60 -135
View File
@@ -1,184 +1,109 @@
package data
import (
"errors"
"strings"
"time"
"context"
"fmt"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
var ErrTransactionLocked = errors.New("transaction amount is already locked")
var (
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
)
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.
// GetOrderInfoByOrderId 通过客户订单号查询订单
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err
}
// GetOrderInfoByTradeId fetches an order by epusdt trade id.
// GetOrderInfoByTradeId 通过交易号查询订单
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err
}
// CreateOrderWithTransaction creates an order in the active database transaction.
// CreateOrderWithTransaction 事务创建订单
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
return tx.Model(order).Create(order).Error
err := tx.Model(order).Create(order).Error
return err
}
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
// GetOrderByBlockIdWithTransaction 通过区块获取订单
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
return order, err
}
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", req.TradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
})
return result.RowsAffected > 0, result.Error
// OrderSuccessWithTransaction 事务支付成功
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
}).Error
return err
}
// GetPendingCallbackOrders returns the minimal callback scheduling state.
func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
var orders []PendingCallbackOrder
query := dao.Mdb.Model(&mdb.Orders{}).
Select("trade_id", "callback_num", "callback_confirm", "updated_at").
Where("callback_num <= ?", maxRetry).
// GetPendingCallbackOrders 查询出等待回调的订单
func GetPendingCallbackOrders() ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(orders).
Where("callback_num < ?", 5).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess).
Order("updated_at asc")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&orders).Error
Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp persists a callback attempt result.
// SaveCallBackOrdersResp 保存订单回调结果
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
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
return err
}
// 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
// UpdateOrderIsExpirationById 通过id设置订单过期
func UpdateOrderIsExpirationById(id uint64) error {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error
return err
}
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()).
Limit(1).
Find(&lock).Error
// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
result, err := dao.Rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", nil
}
if err != nil {
return "", err
}
if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
return result, nil
}
// LockTransaction reserves an address+token+amount pair in sqlite until expiration.
func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token)
now := time.Now()
lock := &mdb.TransactionLock{
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("address = ?", address).
Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now).
Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrTransactionLocked
}
return nil
})
// LockTransaction 锁定交易
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
return err
}
// UnLockTransaction releases the reservation for address+token+amount.
func UnLockTransaction(address string, token string, amount float64) error {
scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB.
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
// UnLockTransaction 解锁交易
func UnLockTransaction(token string, amount float64) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Del(ctx, cacheKey).Err()
return err
}
+7 -7
View File
@@ -7,8 +7,8 @@ import (
)
// AddWalletAddress 创建钱包
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(address)
func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token)
if err != nil {
return nil, err
}
@@ -16,17 +16,17 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists
}
walletAddress := &mdb.WalletAddress{
Address: address,
Status: mdb.TokenStatusEnable,
Token: token,
Status: mdb.TokenStatusEnable,
}
err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取address
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
// GetWalletAddressByToken 通过钱包地址获取token
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error
return walletAddress, err
}
+2 -2
View File
@@ -1,12 +1,12 @@
package mdb
import (
"github.com/dromara/carbon/v2"
"github.com/golang-module/carbon/v2"
"gorm.io/gorm"
)
type BaseModel struct {
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"`
ID uint64 `gorm:"column:id;primary_key" json:"id"`
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
+12 -14
View File
@@ -9,23 +9,21 @@ const (
)
type Orders struct {
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
Amount float64 `gorm:"column:amount" json:"amount"`
Currency string `gorm:"column:currency" json:"currency"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
Token string `gorm:"column:token" json:"token"`
Network string `gorm:"column:network" json:"network"`
Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id
BlockTransactionId string `gorm:"column:block_transaction_id" json:"block_transaction_id"` // 区块id
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
Status int `gorm:"column:status" json:"status"` // 1:等待支付,2:支付成功,3:已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
CallbackNum int `gorm:"column:callback_num" json:"callback_num"` // 回调次数
CallBackConfirm int `gorm:"column:callback_confirm" json:"callback_confirm"` // 回调是否已确认 1是 2否
BaseModel
}
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string {
return "orders"
}
-19
View File
@@ -1,19 +0,0 @@
package mdb
import "time"
type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"`
Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
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"
}
+4 -3
View File
@@ -5,13 +5,14 @@ const (
TokenStatusDisable = 2
)
// WalletAddress 钱包表
type WalletAddress struct {
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"`
Status int64 `gorm:"column:status;default:1" json:"status"`
Token string `gorm:"column:token" json:"token"` // 钱包token
Status int64 `gorm:"column:status" json:"status"` // 1:启用 2:禁用
BaseModel
}
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string {
return "wallet_address"
}
-9
View File
@@ -5,9 +5,6 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"`
Currency string `json:"currency" validate:"required"` // 法币 如:cny
Token string `json:"token" validate:"required"` // 币种 如:usdt
Network string `json:"network" validate:"required"` // 网络 如:TRON
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"`
@@ -17,9 +14,6 @@ type CreateTransactionRequest struct {
func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{
"OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Network": "网络",
"Amount": "支付金额",
"NotifyUrl": "异步回调网址",
"Signature": "签名",
@@ -28,10 +22,7 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理
type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string
Network string
Amount float64
TradeId string
BlockTransactionId string
+2 -5
View File
@@ -5,10 +5,8 @@ type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
Token string `json:"token"` // 收款钱包地址
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址
}
@@ -19,8 +17,7 @@ type OrderNotifyResponse struct {
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
Token string `json:"token"` // 收款钱包地址
BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
+2 -7
View File
@@ -2,15 +2,10 @@ package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token"` // 所属币种 TRX USDT......
Currency string `json:"currency"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Network string `json:"network"` // 网络 TRON ETH ...
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"`
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
}
type CheckStatusResponse struct {
+71 -87
View File
@@ -1,58 +1,52 @@
package service
import (
"errors"
"fmt"
"math/rand"
"strings"
"sync"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/golang-module/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"math/rand"
"sync"
"time"
)
const (
CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额
UsdtMinimumPaymentAmount = 0.001 // usdt最低支付金额
UsdtAmountPerIncrement = 0.001 // usdt每次递增金额
IncrementalMaximumNumber = 100 // 最大递增次数
)
var gCreateTransactionLock sync.Mutex
var gOrderProcessingLock sync.Mutex
// CreateTransaction creates a new payment order.
// CreateTransaction 创建订单
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
// 按照汇率转化USDT
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
// Usdt是否可以满足最低支付金额
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil {
return nil, err
@@ -60,7 +54,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 {
return nil, constant.OrderAlreadyExists
}
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress()
if err != nil {
return nil, err
@@ -68,136 +62,126 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress)
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 3)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress)
if err != nil {
return nil, err
}
if availableAddress == "" {
if availableToken == "" {
return nil, constant.NotAvailableAmountErr
}
tx := dao.Mdb.Begin()
order := &mdb.Orders{
TradeId: tradeID,
OrderId: req.OrderId,
Amount: req.Amount,
Currency: currency,
ActualAmount: availableAmount,
ReceiveAddress: availableAddress,
Token: token,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
TradeId: GenerateCode(),
OrderId: req.OrderId,
Amount: req.Amount,
ActualAmount: availableAmount,
Token: availableToken,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
}
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
err = data.CreateOrderWithTransaction(tx, order)
if err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err
}
if err = tx.Commit().Error; err != nil {
// 锁定支付池
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err
}
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
tx.Commit()
// 超时过期消息队列
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()))
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: expirationTime,
ExpirationTime: ExpirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
}
// OrderProcessing marks an order as paid and releases its sqlite reservation.
// OrderProcessing 成功处理订单
func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil {
tx.Rollback()
return err
}
if exist.ID > 0 {
tx.Rollback()
return constant.OrderBlockAlreadyProcess
}
updated, err := data.OrderSuccessWithTransaction(tx, req)
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req)
if err != nil {
tx.Rollback()
return err
}
if !updated {
tx.Rollback()
return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
// 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount)
if err != nil {
tx.Rollback()
return err
}
if err = data.UnLockTransaction(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)
}
tx.Commit()
return nil
}
// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := ""
availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) {
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := ""
for _, address := range walletAddress {
err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
token := address.Token
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
if err != nil {
return "", err
}
if errors.Is(err, data.ErrTransactionLocked) {
continue
if result == "" {
availableWallet = token
break
}
return "", err
}
return "", nil
return availableWallet, nil
}
for i := 0; i < IncrementalMaximumNumber; i++ {
address, err := tryLockWalletFunc(availableAmount)
token, err := calculateAvailableWalletFunc(availableAmount)
if err != nil {
return "", 0, err
}
if address == "" {
// 拿不到可用钱包就累加金额
if token == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue
}
availableAddress = address
availableToken = token
break
}
return availableAddress, availableAmount, nil
return availableToken, availableAmount, nil
}
// GenerateCode creates a unique trade id.
// GenerateCode 订单号生成
func GenerateCode() string {
date := time.Now().Format("20060102")
r := rand.Intn(1000)
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
}
// GetOrderInfoByTradeId returns a validated order.
// GetOrderInfoByTradeId 通过交易号获取订单
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
-300
View File
@@ -1,300 +0,0 @@
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",
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(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(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,
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(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,
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,
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,
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)
}
}
+3 -12
View File
@@ -2,36 +2,27 @@ package service
import (
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
)
var ErrOrder = errors.New("不存在待支付订单或已过期")
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, ErrOrder
return nil, errors.New("不存在待支付订单或已过期!")
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token,
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampWithMillisecond(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
}
return resp, nil
}
-233
View File
@@ -1,233 +0,0 @@
package service
import (
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
)
func SolCallBack(address string) {}
const (
// Mint token
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
)
const (
TokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
)
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
}
const (
InstructionTransfer = 3
InstructionTransferChecked = 12
)
type TransferInfo struct {
ProgramID string
Type string
Source string
Destination string
Mint string
Authority string
Amount uint64
Decimals *uint8
}
func isTokenProgram(programID solana.PublicKey) bool {
p := programID.String()
return p == TokenProgramID || p == Token2022ProgramID
}
func DecodeTransfer(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
if len(data) < 9 {
return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
}
if data[0] != InstructionTransfer {
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
}
if len(accountIndexes) < 3 {
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
}
amount := binary.LittleEndian.Uint64(data[1:9])
sourceIdx := accountIndexes[0]
destIdx := accountIndexes[1]
authIdx := accountIndexes[2]
if int(sourceIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) {
return nil, errors.New("account index out of range")
}
info := &TransferInfo{
ProgramID: programID.String(),
Type: "transfer",
Source: accountKeys[sourceIdx].String(),
Destination: accountKeys[destIdx].String(),
Authority: accountKeys[authIdx].String(),
Amount: amount,
}
return info, nil
}
type TransferCheckedInfo struct {
ProgramID string
Type string
Source string
Destination string
Mint string
Authority string
Amount uint64
Decimals uint8
}
func DecodeTransferCheck(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
if len(data) < 10 {
return nil, fmt.Errorf("invalid transferChecked data length: %d", len(data))
}
if data[0] != InstructionTransferChecked {
return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
}
if len(accountIndexes) < 4 {
return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
}
amount := binary.LittleEndian.Uint64(data[1:9])
decimals := uint8(data[9])
sourceIdx := accountIndexes[0]
mintIdx := accountIndexes[1]
destIdx := accountIndexes[2]
authIdx := accountIndexes[3]
if int(sourceIdx) >= len(accountKeys) ||
int(mintIdx) >= len(accountKeys) ||
int(destIdx) >= len(accountKeys) ||
int(authIdx) >= len(accountKeys) {
return nil, errors.New("account index out of range")
}
info := &TransferInfo{
ProgramID: programID.String(),
Type: "transferChecked",
Source: accountKeys[sourceIdx].String(),
Mint: accountKeys[mintIdx].String(),
Destination: accountKeys[destIdx].String(),
Authority: accountKeys[authIdx].String(),
Amount: amount,
Decimals: &decimals,
}
return info, nil
}
// ParseTransactionTransfers
func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
signature, err := solana.SignatureFromBase58(sig)
if err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
tx, err := client.GetTransaction(
ctx,
signature,
&rpc.GetTransactionOpts{
Encoding: solana.EncodingBase64,
Commitment: rpc.CommitmentConfirmed,
MaxSupportedTransactionVersion: func(v uint64) *uint64 { return &v }(0),
},
)
if err != nil {
return nil, fmt.Errorf("get transaction failed: %w", err)
}
if tx == nil {
return nil, errors.New("transaction not found")
}
decodedTx, err := tx.Transaction.GetTransaction()
if err != nil {
return nil, fmt.Errorf("decode transaction failed: %w", err)
}
msg := decodedTx.Message
accountKeys := msg.AccountKeys
results := make([]*TransferInfo, 0)
for _, ix := range msg.Instructions {
if int(ix.ProgramIDIndex) >= len(accountKeys) {
continue
}
programID := accountKeys[ix.ProgramIDIndex]
if !isTokenProgram(programID) {
continue
}
data := ix.Data
if len(data) == 0 {
continue
}
switch data[0] {
case InstructionTransfer:
info, err := DecodeTransfer(data, accountKeys, toUint16Slice(ix.Accounts), programID)
if err == nil {
results = append(results, info)
}
case InstructionTransferChecked:
info, err := DecodeTransferCheck(data, accountKeys, toUint16Slice(ix.Accounts), programID)
if err == nil {
results = append(results, info)
}
}
}
return results, nil
}
func toUint16Slice(in []uint16) []uint16 {
out := make([]uint16, len(in))
for i, v := range in {
out[i] = uint16(v)
}
return out
}
func DecodeBase64InstructionData(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
-45
View File
@@ -1,45 +0,0 @@
package service
import (
"context"
"fmt"
"testing"
"github.com/gagliardetto/solana-go/rpc"
)
func TestParseSolTransaction(t *testing.T) {
client := rpc.New("https://api.mainnet-beta.solana.com")
sig := ""
txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
if err != nil {
panic(err)
}
for _, item := range txInfo {
fmt.Println("type =", item.Type)
fmt.Println("program =", item.ProgramID)
fmt.Println("from =", item.Source)
fmt.Println("to =", item.Destination)
fmt.Println("mint =", item.Mint)
fmt.Println("authority =", item.Authority)
fmt.Println("amount =", item.Amount)
if item.Decimals != nil {
fmt.Println("decimals =", *item.Decimals)
}
fmt.Println("-----")
}
}
func TestFindATAAddress(t *testing.T) {
owner := ""
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
ata, err := FindATAAddress(owner, mint)
if err != nil {
panic(err)
}
fmt.Println("ATA =", ata)
}
+97 -230
View File
@@ -1,281 +1,148 @@
package service
import (
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/assimon/luuu/config"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/golang-module/carbon/v2"
"github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
"net/http"
"sync"
)
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
const UsdtTrc20ApiUri = "https://apilist.tronscan.org/api/token_trc20/transfers"
func Trc20CallBack(address string, wg *sync.WaitGroup) {
type UsdtTrc20Resp struct {
Total int `json:"total"`
RangeTotal int `json:"rangeTotal"`
TokenTransfers []TokenTransfers `json:"token_transfers"`
}
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"`
Vip bool `json:"vip"`
}
type TokenTransfers struct {
TransactionID string `json:"transaction_id"`
BlockTs int64 `json:"block_ts"`
FromAddress string `json:"from_address"`
ToAddress string `json:"to_address"`
Block int `json:"block"`
ContractAddress string `json:"contract_address"`
Quant string `json:"quant"`
ApprovalAmount string `json:"approval_amount"`
EventType string `json:"event_type"`
ContractType string `json:"contract_type"`
Confirmed bool `json:"confirmed"`
ContractRet string `json:"contractRet"`
FinalResult string `json:"finalResult"`
TokenInfo TokenInfo `json:"tokenInfo"`
FromAddressIsContract bool `json:"fromAddressIsContract"`
ToAddressIsContract bool `json:"toAddressIsContract"`
Revert bool `json:"revert"`
}
// Trc20CallBack trc20回调
func Trc20CallBack(token string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
startTime := carbon.Now().AddHours(-24).TimestampWithMillisecond()
endTime := carbon.Now().TimestampWithMillisecond()
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)
"limit": "200",
"start": "0",
"direction": "in",
"tokens": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"relatedAddress": token,
"start_timestamp": stdutil.ToString(startTime),
"end_timestamp": stdutil.ToString(endTime),
}).Get(UsdtTrc20ApiUri)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
panic(err)
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRX API response indicates failure")
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue
}
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue
}
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
}
if tron.EncodeCheck(toBytes) != address {
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
if err != nil {
panic(err)
}
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
}
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "TRX",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
}
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
var trc20Resp UsdtTrc20Resp
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
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)
if trc20Resp.Total <= 0 {
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 {
for _, transfer := range trc20Resp.TokenTransfers {
if transfer.ToAddress != token || transfer.FinalResult != "SUCCESS" {
continue
}
if transfer.Get("to").String() != address {
continue
}
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
decimalQuant, err := decimal.NewFromString(transfer.Quant)
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)
decimalDivisor := decimal.NewFromFloat(1000000)
amount := decimalQuant.Div(decimalDivisor).InexactFloat64()
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
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)
if tradeId == "" {
continue
}
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
panic(err)
}
// 区块的确认时间必须在订单创建时间之后
createTime := order.CreatedAt.TimestampWithMillisecond()
if transfer.BlockTs < createTime {
panic("Orders cannot actually be matched")
}
// 到这一步就完全算是支付成功了
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
TradeId: tradeID,
Token: token,
TradeId: tradeId,
Amount: amount,
BlockTransactionId: txID,
BlockTransactionId: transfer.TransactionID,
}
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)
// 回调队列
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5))
// 发送机器人消息
msgTpl := `
<b>📢📢有新的交易支付成功!</b>
<pre>交易号:%s</pre>
<pre>订单号:%s</pre>
<pre>请求支付金额:%f cny</pre>
<pre>实际支付金额:%f usdt</pre>
<pre>钱包地址:%s</pre>
<pre>订单创建时间:%s</pre>
<pre>支付成功时间:%s</pre>
`
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
telegram.SendToBot(msg)
}
}
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\n"+
"⏰ <b>时间信息</b>\n"+
"├ 创建时间:%s\n"+
"└ 支付时间:%s",
order.Amount,
strings.ToUpper(order.Currency),
order.ActualAmount,
strings.ToUpper(order.Token),
order.TradeId,
order.OrderId,
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(msg)
}
+67
View File
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,35 @@
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
}
+43 -14
View File
@@ -1,22 +1,51 @@
package mq
import (
"sync"
"github.com/assimon/luuu/config"
"fmt"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/log"
"github.com/hibiken/asynq"
"github.com/spf13/viper"
)
var (
startOnce sync.Once
callbackLimiter chan struct{}
callbackInflight sync.Map
)
var MClient *asynq.Client
func Start() {
startOnce.Do(func() {
callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
go runOrderExpirationLoop()
go runOrderCallbackLoop()
go runTransactionLockCleanupLoop()
})
redis := asynq.RedisClientOpt{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")),
DB: viper.GetInt("redis_db"),
Password: viper.GetString("redis_passwd"),
}
initClient(redis)
go initListen(redis)
}
func initClient(redis asynq.RedisClientOpt) {
MClient = asynq.NewClient(redis)
}
func initListen(redis asynq.RedisClientOpt) {
srv := asynq.NewServer(
redis,
asynq.Config{
// Specify how many concurrent workers to use
Concurrency: viper.GetInt("queue_concurrency"),
// Optionally specify multiple queues with different priority.
Queues: map[string]int{
"critical": viper.GetInt("queue_level_critical"),
"default": viper.GetInt("queue_level_default"),
"low": viper.GetInt("queue_level_low"),
},
Logger: log.Sugar,
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(handle.QueueOrderExpiration, handle.OrderExpirationHandle)
mux.HandleFunc(handle.QueueOrderCallback, handle.OrderCallbackHandle)
if err := srv.Run(mux); err != nil {
log.Sugar.Fatalf("[queue] could not run server: %v", err)
}
}
-257
View File
@@ -1,257 +0,0 @@
package mq
import (
"errors"
"net/http"
"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"`
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", "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.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 {
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
}
-271
View File
@@ -1,271 +0,0 @@
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",
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(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",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
}
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
t.Fatalf("create recent order: %v", err)
}
if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
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(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(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")
}
+10 -43
View File
@@ -1,60 +1,27 @@
package route
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/labstack/echo/v4"
"net/http"
)
// RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
return c.String(http.StatusOK, "hello epusdt, https://github.com/assimon/epusdt")
})
// ==== 支付相关=====
payRoute := e.Group("/pay")
// 收银台
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
// 状态检测
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
// 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))
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())
apiV1Route := e.Group("/api/v1")
// ====订单相关====
orderRoute := apiV1Route.Group("/order", middleware.CheckApiSign())
// 创建订单
orderRoute.POST("/create-transaction", comm.Ctrl.CreateTransaction)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

+228 -256
View File
File diff suppressed because one or more lines are too long
-395
View File
@@ -1,395 +0,0 @@
/* =============================================
iOS 26 Liquid Glass Design System
============================================= */
/* ---------- CSS Variables ---------- */
:root, [data-theme="light"] {
--background: #f2f2f7;
--foreground: #1c1c1e;
--card: rgba(255,255,255,0.72);
--card-foreground: #1c1c1e;
--popover: rgba(255,255,255,0.88);
--popover-foreground:#1c1c1e;
--primary: #ffffff;
--primary-foreground:#1c1c1e;
--primary-hover: #e5e5ea;
--secondary: rgba(120,120,128,0.12);
--muted: rgba(120,120,128,0.08);
--muted-foreground: #8e8e93;
--accent: rgba(255,255,255,0.12);
--accent-foreground: #1c1c1e;
--border: rgba(60,60,67,0.12);
--border-hover: rgba(60,60,67,0.22);
--success: #34c759;
--warning: #ff9500;
--destructive: #ff3b30;
--glass-bg: rgba(255,255,255,0.65);
--glass-border: rgba(255,255,255,0.8);
--glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset;
--card-shadow: 0 4px 24px rgba(0,0,0,.08);
--popover-shadow: 0 12px 40px rgba(0,0,0,.14);
--blob-1: rgba(200,200,200,0.18);
--blob-2: rgba(52,199,89,0.14);
--blob-3: rgba(255,149,0,0.10);
}
[data-theme="dark"] {
--background: #000000;
--foreground: #f5f5f7;
--card: rgba(28,28,30,0.82);
--card-foreground: #f5f5f7;
--popover: rgba(44,44,46,0.92);
--popover-foreground:#e5e5ea;
--primary: #ffffff;
--primary-foreground:#1c1c1e;
--primary-hover: #e5e5ea;
--secondary: rgba(120,120,128,0.18);
--muted: rgba(120,120,128,0.12);
--muted-foreground: #636366;
--accent: rgba(255,255,255,0.15);
--accent-foreground: #ffffff;
--border: rgba(255,255,255,0.1);
--border-hover: rgba(255,255,255,0.18);
--success: #30d158;
--warning: #ff9f0a;
--destructive: #ff453a;
--glass-bg: rgba(28,28,30,0.75);
--glass-border: rgba(255,255,255,0.10);
--glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset;
--card-shadow: 0 4px 32px rgba(0,0,0,.55);
--popover-shadow: 0 12px 40px rgba(0,0,0,.5);
--blob-1: rgba(255,255,255,0.14);
--blob-2: rgba(48,209,88,0.10);
--blob-3: rgba(255,159,10,0.08);
}
/* ---------- Ambient blobs ---------- */
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
animation: blob-drift 18s ease-in-out infinite alternate;
}
.blob-1 {
width: 420px; height: 420px;
background: var(--blob-1);
top: -120px; left: -100px;
animation-delay: 0s;
}
.blob-2 {
width: 340px; height: 340px;
background: var(--blob-2);
bottom: -80px; right: -60px;
animation-delay: -6s;
}
.blob-3 {
width: 260px; height: 260px;
background: var(--blob-3);
top: 45%; left: 55%;
animation-delay: -12s;
}
@keyframes blob-drift {
0% { transform: translate(0,0) scale(1); }
50% { transform: translate(30px,-20px) scale(1.06); }
100% { transform: translate(-20px,30px) scale(0.96); }
}
/* ---------- Liquid Glass Card ---------- */
.glass-card {
background: var(--glass-bg);
-webkit-backdrop-filter: blur(28px) saturate(1.6);
backdrop-filter: blur(28px) saturate(1.6);
border: 1px solid var(--glass-border);
border-radius: 22px;
box-shadow: var(--glass-shadow);
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
}
/* ---------- Flippable Status Card ---------- */
.status-card {
position: relative;
perspective: 1600px;
transition: height 0.38s cubic-bezier(.22,1,.36,1);
}
.status-card-inner {
position: relative;
width: 100%;
transform-style: preserve-3d;
transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
}
.status-card.is-flipped .status-card-inner {
transform: rotateY(180deg);
}
.status-face {
position: absolute;
inset: 0;
width: 100%;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform-style: preserve-3d;
}
.status-face-front {
z-index: 2;
}
.status-face-back {
transform: rotateY(180deg);
}
.state-panel-shell {
padding-top: 2px;
}
.state-screen {
display: none;
}
.state-screen.is-active {
display: flex;
}
/* ---------- Timer ---------- */
.timer-ring-track {
stroke: var(--border);
transition: stroke .3s;
}
.timer-ring-progress {
transform: rotate(-90deg);
transform-origin: 50% 50%;
transition: stroke-dashoffset 1s linear, stroke .5s;
}
.timer-icon {
color: var(--muted-foreground);
}
/* ---------- QR wrapper ---------- */
.qr-wrapper {
background: #ffffff;
border-radius: 18px;
padding: 14px;
box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
display: inline-block;
}
/* ---------- iOS Pill button ---------- */
.ios-pill {
background: var(--glass-bg);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
backdrop-filter: blur(16px) saturate(1.4);
border: 1px solid var(--glass-border);
border-radius: 99px;
padding: 6px 13px;
color: var(--card-foreground);
transition: background 0.15s, opacity 0.15s;
}
.ios-pill:hover { opacity: 0.82; }
.ios-pill:active { opacity: 0.6; }
.ios-pill.w-9 { padding: 0; justify-content: center; }
/* ---------- iOS Rows ---------- */
.ios-row {
display: flex;
align-items: center;
padding: 14px 16px;
transition: background 0.12s;
}
.ios-row:active { background: var(--muted); }
/* ---------- Icon button ---------- */
.ios-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px; height: 32px;
border-radius: 10px;
color: var(--muted-foreground);
background: var(--secondary);
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.ios-icon-btn:hover { background: var(--accent); color: var(--primary); }
/* ---------- Primary CTA button ---------- */
.ios-btn-primary {
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: 16px;
padding: 16px 24px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.2px;
cursor: pointer;
transition: background 0.15s, transform 0.1s, opacity 0.15s;
box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset;
}
[data-theme="dark"] .ios-btn-primary {
box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset;
}
.ios-btn-primary:hover { background: var(--primary-hover); }
.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; }
.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
/* ---------- Secondary button ---------- */
.ios-btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--secondary);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 13px;
padding: 11px 28px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.ios-btn-secondary:hover { background: var(--accent); }
.ios-btn-secondary:active { opacity: 0.7; }
/* ---------- State icon circle ---------- */
.ios-state-icon {
width: 76px; height: 76px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
/* ---------- Context Menu ---------- */
.ios-menu {
background: var(--popover);
-webkit-backdrop-filter: blur(30px) saturate(1.8);
backdrop-filter: blur(30px) saturate(1.8);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: var(--popover-shadow);
overflow: hidden;
}
.ios-menu.is-open {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
.ios-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 15px;
font-size: 14px;
font-weight: 500;
color: var(--card-foreground);
cursor: pointer;
transition: background 0.1s;
}
.ios-menu-item:hover { background: var(--accent); }
.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; }
.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); }
/* ---------- Select states ---------- */
.select-trigger.is-open { opacity: 0.8; }
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
/* ---------- Toast ---------- */
#toast {
position: fixed;
bottom: 36px;
left: 50%;
transform: translateX(-50%) translateY(12px);
background: rgba(28,28,30,0.88);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 10px 20px;
border-radius: 20px;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 4px 20px rgba(0,0,0,.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s, transform 0.22s;
z-index: 9999;
white-space: nowrap;
}
[data-theme="light"] #toast {
background: rgba(44,44,46,0.9);
}
/* ---------- Animations ---------- */
@keyframes state-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ---------- View Transition ---------- */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) { z-index: 1; }
::view-transition-new(root) { z-index: 9; }
/* ---------- Debug toolbar ---------- */
#debug-bar {
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
}
#debug-toggle {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: #7c3aed; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 12px rgba(124,58,237,.5);
transition: background .15s, transform .1s;
}
#debug-toggle:hover { background: #6d28d9; }
#debug-toggle:active { transform: scale(.92); }
#debug-panel {
display: none; flex-direction: column; gap: 5px;
background: rgba(15,15,20,.92);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid rgba(124,58,237,.35); border-radius: 14px;
padding: 12px; min-width: 150px;
box-shadow: 0 8px 32px rgba(0,0,0,.5);
}
#debug-panel.is-open { display: flex; }
.debug-label {
font-size: 10px; font-weight: 700; color: #a78bfa;
letter-spacing: .12em; text-transform: uppercase;
margin-bottom: 4px; padding-bottom: 6px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.debug-btn {
background: rgba(255,255,255,.07); color: #d1d5db;
border: 1px solid rgba(255,255,255,.1); border-radius: 8px;
padding: 6px 10px; font-size: 12px; font-weight: 500;
cursor: pointer; text-align: left; font-family: inherit;
transition: background .12s, color .12s;
}
.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; }
.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; }
.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; }
.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; }
.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; }
.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; }
.debug-btn--not-found {
background: rgba(142,142,147,.15);
color: #8e8e93;
}
@media (prefers-reduced-motion: reduce) {
.status-card,
.status-card-inner,
.status-face,
.timer-ring-progress {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
-585
View File
@@ -1,585 +0,0 @@
// DOM helpers
const $ = (id) => document.getElementById(id);
const $$ = (sel) => document.querySelectorAll(sel);
// ---- Network Tag ----
const _CHAIN_PRESETS = {
Ethereum: { bg: 'hsla(0,0%,55%,0.25)', color: 'hsla(0,0%,55%,1)', icon: 'ethereum' },
Solana: { bg: 'hsla(256,85%,65%,0.25)', color: 'hsla(256,85%,65%,1)', icon: 'solana' },
BSC: { bg: 'hsla(46,91%,49%,0.25)', color: 'hsla(46,91%,49%,1)', icon: 'bsc' },
Arbitrum: { bg: 'hsla(202,100%,54%,0.25)', color: 'hsla(202,100%,54%,1)', icon: 'arbitrum' },
TRON: { bg: 'hsla(350,100%,47%,0.20)', color: 'hsla(350,100%,47%,1)', icon: 'tron' },
Polygon: { bg: 'hsla(263,73%,56%,0.25)', color: 'hsla(263,73%,56%,1)', icon: 'polygon' },
Base: { bg: 'hsla(240,100%,61%,0.25)', color: 'hsla(240,100%,61%,1)', icon: 'base' },
OP: { bg: 'hsla(353,99%,51%,0.25)', color: 'hsla(353,99%,51%,1)', icon: 'op' },
HyperEVM: { bg: 'hsla(166,94%,79%,0.20)', color: 'hsla(166,94%,79%,1)', icon: 'hyperevm' },
Plasma: { bg: 'hsla(166,64%,32%,0.25)', color: 'hsla(166,64%,32%,1)', icon: 'plasma' },
Bitcoin: { bg: 'hsla(33,93%,54%,0.20)', color: 'hsla(33,93%,54%,1)', icon: 'bitcoin' },
Binance: { bg: 'hsla(46,91%,49%,0.24)', color: 'hsla(46,91%,49%,1)', icon: 'binance' },
};
const _CHAIN_ALIASES = {
evm:'Ethereum', eth:'Ethereum', ethereum:'Ethereum',
bsc:'BSC', bnb:'BSC',
arbitrum:'Arbitrum', arb:'Arbitrum',
sol:'Solana', solana:'Solana',
tron:'TRON', trx:'TRON',
polygon:'Polygon', matic:'Polygon', pol:'Polygon',
base:'Base',
op:'OP', optimism:'OP',
hyperevm:'HyperEVM', hyperliquid:'HyperEVM', hype:'HyperEVM',
plasma:'Plasma', xpl:'Plasma',
bitcoin:'Bitcoin', btc:'Bitcoin',
binance:'Binance', bnb_chain:'Binance',
};
function _resolveChain(name) {
if (!name) return null;
const k = name.trim().toLowerCase();
const canonical = _CHAIN_ALIASES[k] || Object.keys(_CHAIN_PRESETS).find(c => c.toLowerCase() === k);
return canonical ? { label: canonical, ..._CHAIN_PRESETS[canonical] } : null;
}
function networkTag(network) {
const chain = _resolveChain(network);
if (!chain) return `<span style="font-weight:600;color:var(--card-foreground)">${network}</span>`;
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px 2px 4px;border-radius:99px;background:${chain.bg};color:${chain.color};font-size:12px;font-weight:600;line-height:1.6"><img src="/static/images/${chain.icon}.png" width="14" height="14" style="width:14px;height:14px;border-radius:50%;object-fit:cover;flex-shrink:0" />${chain.label}</span>`;
}
// Initialize Lucide icons
if (typeof lucide !== 'undefined') lucide.createIcons();
// Theme
let currentTheme = localStorage.getItem('theme') || 'dark';
applyTheme(currentTheme);
function applyTheme(t) {
currentTheme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
$('icon-moon').style.display = t === 'light' ? 'block' : 'none';
$('icon-sun').style.display = t === 'dark' ? 'block' : 'none';
}
function toggleTheme(e) {
const next = currentTheme === 'light' ? 'dark' : 'light';
if (!document.startViewTransition) { applyTheme(next); return; }
const x = e ? e.clientX : window.innerWidth / 2;
const y = e ? e.clientY : window.innerHeight / 2;
const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
const transition = document.startViewTransition(() => applyTheme(next));
transition.ready.then(() => {
const clip = [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`];
document.documentElement.animate(
{ clipPath: clip },
{ duration: 420, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)' }
);
});
}
// i18n
const LANGS = {
en: {
scan_title: 'Scan or copy address to pay',
amount_to_pay: 'Amount to pay',
payment_address: 'Payment address',
i_have_transferred: 'I have transferred',
checking_blockchain: 'Checking blockchain…',
expires: 'Expires',
copied: 'Copied to clipboard',
verifying: 'Verifying…',
payment_success: 'Payment Successful',
redirecting: 'Redirecting…',
payment_expired: 'Payment Expired',
expired_sub: 'Please initiate a new payment',
order_id: 'Order ID',
order_amount: 'Order amount',
network_timeout: 'Connection Timeout',
timeout_sub: 'Unable to connect to the payment server',
retry: 'Retry',
back: 'Back',
order_not_found: 'Order Not Found',
not_found_sub: 'The order does not exist or has already expired',
},
zh: {
scan_title: '扫码或复制地址付款',
amount_to_pay: '付款金额',
payment_address: '付款地址',
i_have_transferred: '我已转账',
checking_blockchain: '链上核验中…',
expires: '到期时间',
copied: '已复制到剪贴板',
verifying: '核验中…',
payment_success: '支付成功',
redirecting: '正在跳转…',
payment_expired: '支付已过期',
expired_sub: '请重新发起支付',
order_id: '订单 ID',
order_amount: '订单金额',
network_timeout: '连接超时',
timeout_sub: '无法连接至支付服务器',
retry: '重试',
back: '返回',
order_not_found: '订单不存在',
not_found_sub: '待支付订单不存在或已过期',
},
ja: {
scan_title: 'アドレスをスキャンまたはコピーして支払う',
amount_to_pay: '支払い金額',
payment_address: '支払いアドレス',
i_have_transferred: '送金しました',
checking_blockchain: 'ブロックチェーン確認中…',
expires: '有効期限',
copied: 'コピーしました',
verifying: '確認中…',
payment_success: '支払い完了',
redirecting: 'リダイレクト中…',
payment_expired: '支払い期限切れ',
expired_sub: '新しい支払いを開始してください',
order_id: '注文 ID',
order_amount: '注文金額',
network_timeout: '接続タイムアウト',
timeout_sub: '支払いサーバーに接続できません',
retry: '再試行',
back: '戻る',
order_not_found: '注文が見つかりません',
not_found_sub: '注文が存在しないか、すでに期限切れです',
},
ko: {
scan_title: '주소를 스캔하거나 복사하여 결제',
amount_to_pay: '결제 금액',
payment_address: '결제 주소',
i_have_transferred: '이체 완료',
checking_blockchain: '블록체인 확인 중…',
expires: '만료 시간',
copied: '복사됨',
verifying: '확인 중…',
payment_success: '결제 성공',
redirecting: '리다이렉트 중…',
payment_expired: '결제 만료',
expired_sub: '새로운 결제를 시작하세요',
order_id: '주문 ID',
order_amount: '주문 금액',
network_timeout: '연결 시간 초과',
timeout_sub: '결제 서버에 연결할 수 없습니다',
retry: '다시 시도',
back: '돌아가기',
order_not_found: '주문 없음',
not_found_sub: '주문이 존재하지 않거나 이미 만료되었습니다',
},
'zh-hk': {
scan_title: '掃碼或複製地址付款',
amount_to_pay: '付款金額',
payment_address: '付款地址',
i_have_transferred: '我已轉帳',
checking_blockchain: '鏈上核驗中…',
expires: '到期時間',
copied: '已複製到剪貼簿',
verifying: '核驗中…',
payment_success: '支付成功',
redirecting: '正在跳轉…',
payment_expired: '支付已過期',
expired_sub: '請重新發起支付',
order_id: '訂單 ID',
order_amount: '訂單金額',
network_timeout: '連線逾時',
timeout_sub: '無法連線至支付伺服器',
retry: '重試',
back: '返回',
order_not_found: '訂單不存在',
not_found_sub: '待支付訂單不存在或已過期',
},
ru: {
scan_title: 'Отсканируйте или скопируйте адрес для оплаты',
amount_to_pay: 'Сумма к оплате',
payment_address: 'Адрес для оплаты',
i_have_transferred: 'Я перевёл',
checking_blockchain: 'Проверка блокчейна…',
expires: 'Истекает',
copied: 'Скопировано',
verifying: 'Проверка…',
payment_success: 'Оплата прошла успешно',
redirecting: 'Перенаправление…',
payment_expired: 'Срок оплаты истёк',
expired_sub: 'Пожалуйста, создайте новый платёж',
order_id: 'Заказ ID',
order_amount: 'Сумма заказа',
network_timeout: 'Тайм-аут подключения',
timeout_sub: 'Не удалось подключиться к серверу',
retry: 'Повторить',
back: 'Назад',
order_not_found: 'Заказ не найден',
not_found_sub: 'Заказ не существует или уже истёк',
},
};
const LANG_LABELS = { en: 'EN', zh: '中文', 'zh-hk': '繁體', ja: '日本語', ko: '한국어', ru: 'RU' };
const SUPPORTED_LANGS = Object.keys(LANGS); // ['en','zh','ja','ko','zh-hk','ru']
let currentLang = 'en';
const t = (key) => LANGS[currentLang]?.[key] ?? key;
function detectLang() {
const saved = localStorage.getItem('lang');
if (saved && SUPPORTED_LANGS.includes(saved)) return saved;
for (const nav of (navigator.languages || [navigator.language])) {
const lc = nav.toLowerCase();
if (lc.startsWith('zh-hk') || lc.startsWith('zh-tw') || lc.startsWith('zh-mo')) return 'zh-hk';
if (lc.startsWith('zh')) return 'zh';
if (lc.startsWith('ja')) return 'ja';
if (lc.startsWith('ko')) return 'ko';
if (lc.startsWith('ru')) return 'ru';
if (lc.startsWith('en')) return 'en';
}
return 'en';
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
$$('[data-i18n]').forEach(el => {
const v = t(el.dataset.i18n);
if (v) el.textContent = v;
});
$('lang-label').textContent = LANG_LABELS[lang];
$$('#dd-lang-menu .select-option').forEach(o =>
o.classList.toggle('is-selected', o.dataset.lang === lang));
closeAllSelects();
if (ORDER.tradeId) { renderOrderId(); }
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
requestAnimationFrame(syncStatusCardHeight);
}
// Select dropdown
function toggleSelect(id) {
const wrap = $(id);
const trigger = wrap.querySelector('.select-trigger');
const menu = wrap.querySelector('.select-menu');
const wasOpen = menu.classList.contains('is-open');
closeAllSelects();
if (!wasOpen) {
menu.classList.add('is-open');
trigger.classList.add('is-open');
}
}
function closeAllSelects() {
$$('.select-menu.is-open').forEach(m => {
m.classList.remove('is-open');
m.closest('.select-wrap').querySelector('.select-trigger').classList.remove('is-open');
});
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.select-wrap')) closeAllSelects();
});
// Clipboard fallback (used when ClipboardJS is unavailable)
const CHECK_ICON = '<i data-lucide="check" width="15" height="15" stroke-width="2.5" color="#22c55e"></i>';
function showToast() {
const el = $('toast');
el.textContent = t('copied');
el.style.opacity = '1';
el.style.transform = 'translateX(-50%) translateY(0)';
clearTimeout(el._tid);
el._tid = setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateX(-50%) translateY(10px)';
}, 2000);
}
function flashCheck(btnId) {
const btn = $(btnId);
if (!btn) return;
const orig = btn.innerHTML;
btn.innerHTML = CHECK_ICON;
lucide.createIcons({ nodes: [btn] });
setTimeout(() => { btn.innerHTML = orig; lucide.createIcons({ nodes: [btn] }); }, 1800);
}
// Transfer button
function handleTransfer() {
const btn = $('btn-transferred');
const span = btn.querySelector('span');
span.textContent = t('verifying');
btn.disabled = true;
_pollErrors = 0;
_pollStopped = false;
clearTimeout(_pollTimer);
checkOrderStatus();
setTimeout(() => {
span.textContent = t('i_have_transferred');
btn.disabled = false;
}, 4000);
}
// Order
function truncateAddr(addr) {
return addr.length > 10 ? `${addr.slice(0, 4)}...${addr.slice(-6)}` : addr;
}
function formatAddr(addr) {
if (!addr || addr.length <= 10) return addr;
const pre = addr.slice(0, 4);
const mid = addr.slice(4, -6);
const suf = addr.slice(-6);
return `<span style="font-weight:800;color:var(--primary)">${pre}</span><span style="color:var(--muted-foreground)">${mid}</span><span style="font-weight:800;color:var(--primary)">${suf}</span>`;
}
function renderRow(id, label, value) {
const el = $(id);
if (el) el.innerHTML = `<td style="width:1%;white-space:nowrap;padding-right:0.75em;">${label}</td><td style="color:var(--card-foreground);font-weight:500;word-break:break-all">${value}</td>`;
}
function renderOrderId() {
if (ORDER.tradeId && !ORDER.tradeId.startsWith('{{')) {
renderRow('display-order-id', t('order_id'), ORDER.tradeId);
}
}
function initOrder() {
if (!ORDER.tradeId || ORDER.tradeId.startsWith('{{')) {
showNotFound();
return;
}
$('display-amount').textContent = `${ORDER.actualAmount} ${ORDER.token}`;
$('field-amount').textContent = `${ORDER.actualAmount} ${ORDER.token}`;
$('field-address').innerHTML = formatAddr(ORDER.receiveAddress);
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
}
$('display-network').innerHTML = networkTag(ORDER.network);
renderOrderId();
new QRCode($('qrcode'), {
text: ORDER.receiveAddress,
width: 176,
height: 176,
colorDark: '#111111',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M,
});
initCountdown();
checkOrderStatus();
initClipboard();
}
// Clipboard
let _clipboard = null;
function initClipboard() {
const targets = [
{ id: 'copy-amt-box', text: ORDER.actualAmount, iconId: 'btn-copy-amt2' },
{ id: 'copy-addr-box', text: ORDER.receiveAddress, iconId: 'btn-copy-addr' },
{ id: 'btn-copy-amount', text: ORDER.actualAmount, iconId: 'btn-copy-amount' },
];
if (typeof ClipboardJS !== 'undefined') {
targets.forEach(({ id, text }) => {
const el = $(id);
if (el) el.setAttribute('data-clipboard-text', text);
});
if (_clipboard) _clipboard.destroy();
_clipboard = new ClipboardJS(targets.map(c => '#' + c.id).join(', '));
_clipboard.on('success', e => {
e.clearSelection();
const tgt = targets.find(c => c.id === e.trigger.id);
showToast();
if (tgt) flashCheck(tgt.iconId);
});
}
}
// Countdown color interpolation (green → orange → red by ratio)
function lerpColor(a, b, t) {
return `rgb(${Math.round(a[0]+(b[0]-a[0])*t)},${Math.round(a[1]+(b[1]-a[1])*t)},${Math.round(a[2]+(b[2]-a[2])*t)})`;
}
function ratioColor(ratio) {
if (ratio >= 0.5) return '#22c55e';
if (ratio >= 0.2) return lerpColor([34,197,94], [249,115,22], (0.5 - ratio) / 0.3);
return lerpColor([249,115,22], [239,68,68], (0.2 - ratio) / 0.2);
}
const CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 in iOS SVG ring
let countdownInterval = null;
let totalSeconds = 0;
let _expiresAt = null;
const pad = (n) => String(n).padStart(2, '0');
function initCountdown() {
const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
_expiresAt = parseTime(ORDER.expirationTime);
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
if (remaining <= 0) { showExpired(); return; }
// 用 createdAt 和 expirationTime 推算总有效时长,否则默认 600 秒
const created = parseTime(ORDER.createdAt);
totalSeconds = (!isNaN(created.getTime()) && !isNaN(_expiresAt.getTime()))
? Math.max(1, Math.round((_expiresAt - created) / 1000))
: 600;
$('ring').style.strokeDasharray = CIRCUMFERENCE;
// 让初始渲染就从 remaining-1 开始,避免视觉上停顿一秒
_expiresAt = new Date(_expiresAt.getTime() - 1000);
tickCountdown();
countdownInterval = setInterval(tickCountdown, 1000);
}
function tickCountdown() {
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
const timeStr = h > 0
? `${pad(h)}:${pad(m)}:${pad(s)}`
: `${pad(m)}:${pad(s)}`;
$('countdown').textContent = timeStr;
const inlineEl = $('countdown-inline');
const color = ratioColor(totalSeconds > 0 ? remaining / totalSeconds : 0);
if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
$('ring').style.strokeDashoffset = CIRCUMFERENCE * (1 - (totalSeconds > 0 ? remaining / totalSeconds : 0));
$('countdown').style.color = color;
$('ring').style.stroke = color;
if (remaining <= 0) {
clearInterval(countdownInterval);
showExpired();
}
}
// Polling
let _pollTimer = null;
let _pollStopped = false;
let _pollErrors = 0;
const MAX_POLL_ERRORS = 5;
function checkOrderStatus() {
if (_pollStopped || !ORDER.tradeId || ORDER.tradeId.startsWith('{{')) return;
fetch(`/pay/check-status/${encodeURIComponent(ORDER.tradeId)}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(8000),
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(({ data }) => {
_pollErrors = 0;
if (data?.status === 2) onPaymentSuccess();
else if (data?.status === 3) showExpired();
else scheduleNextPoll();
})
.catch(() => {
if (++_pollErrors >= MAX_POLL_ERRORS) showTimeout();
else scheduleNextPoll();
});
}
const scheduleNextPoll = () => {
if (!_pollStopped) _pollTimer = setTimeout(checkOrderStatus, 5000);
};
function stopPolling() {
_pollStopped = true;
clearTimeout(_pollTimer);
}
// State panels
const SCREENS = ['screen-success', 'screen-expired', 'screen-timeout', 'screen-not-found'];
function syncStatusCardHeight() {
const card = $('status-card');
const inner = card?.querySelector('.status-card-inner');
if (!card || !inner) return;
const activeFace = card.classList.contains('is-flipped')
? card.querySelector('.status-face-back')
: card.querySelector('.status-face-front');
const nextHeight = activeFace?.scrollHeight || 0;
if (nextHeight > 0) {
card.style.height = `${nextHeight}px`;
inner.style.height = `${nextHeight}px`;
}
}
function setActiveScreen(state) {
SCREENS.forEach(id => $(id).classList.toggle('is-active', id === `screen-${state}`));
}
function flipToState(state) {
setActiveScreen(state);
$('status-card').classList.add('is-flipped');
requestAnimationFrame(syncStatusCardHeight);
}
function showStatePanel(state) {
$('order-info').style.display = state === 'not-found' ? 'none' : '';
flipToState(state);
}
function goBack() {
if (document.referrer) {
window.location.href = document.referrer;
} else if (ORDER.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
window.location.href = ORDER.redirectUrl;
} else {
history.back();
}
}
function hideStatePanel() {
$('status-card').classList.remove('is-flipped');
$('order-info').style.display = '';
requestAnimationFrame(syncStatusCardHeight);
}
function onPaymentSuccess() {
stopPolling();
clearInterval(countdownInterval);
showStatePanel('success');
if (ORDER.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
setTimeout(() => { window.location.href = ORDER.redirectUrl; }, 3000);
}
}
function showExpired() {
stopPolling();
clearInterval(countdownInterval);
$('btn-transferred').disabled = true;
showStatePanel('expired');
}
function showTimeout() {
stopPolling();
showStatePanel('timeout');
}
function showNotFound() {
stopPolling();
clearInterval(countdownInterval);
$('btn-transferred').disabled = true;
showStatePanel('not-found');
}
function retryPolling() {
_pollErrors = 0;
_pollStopped = false;
hideStatePanel();
checkOrderStatus();
}
document.addEventListener('DOMContentLoaded', () => {
setLang(detectLang());
initOrder();
syncStatusCardHeight();
window.addEventListener('resize', syncStatusCardHeight);
});
+44
View File
@@ -0,0 +1,44 @@
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;
}
+4 -12
View File
@@ -1,20 +1,12 @@
package task
import (
"github.com/assimon/luuu/util/log"
"github.com/robfig/cron/v3"
)
import "github.com/robfig/cron/v3"
func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
c := cron.New()
// 汇率监听
c.AddJob("@every 60s", UsdtRateJob{})
// trc20钱包监听
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
c.AddJob("@every 10s", ListenTrc20Job{})
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
+3 -10
View File
@@ -1,11 +1,10 @@
package task
import (
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
"sync"
)
type ListenTrc20Job struct {
@@ -16,24 +15,18 @@ var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddress()
if err != nil {
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
log.Sugar.Error(err)
return
}
if len(walletAddress) <= 0 {
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return
}
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup
for _, address := range walletAddress {
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
wg.Add(1)
go service.Trc20CallBack(address.Address, &wg)
go service.Trc20CallBack(address.Token, &wg)
}
wg.Wait()
log.Sugar.Debug("[ListenTrc20Job] Job completed")
}
+62
View File
@@ -0,0 +1,62 @@
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
}
}
}
+16 -77
View File
@@ -2,9 +2,6 @@ package telegram
import (
"fmt"
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/gookit/goutil/mathutil"
@@ -13,52 +10,20 @@ import (
)
const (
ReplayAddWallet = "请发给我一个合法的钱包地址"
pendingWalletAddressTTL = 5 * time.Minute
ReplayAddWallet = "请发给我一个合法的钱包地址"
)
type pendingWalletAddressState struct {
RequestedAt time.Time
}
var pendingWalletAddressUsers sync.Map
func OnTextMessageHandle(c tb.Context) error {
msg := c.Message()
if msg == nil {
return nil
if c.Message().ReplyTo.Text == ReplayAddWallet {
defer bots.Delete(c.Message().ReplyTo)
_, err := data.AddWalletAddress(c.Message().Text)
if err != nil {
return c.Send(err.Error())
}
c.Send(fmt.Sprintf("钱包[%s]添加成功!", c.Message().Text))
return WalletList(c)
}
sender := c.Sender()
senderID := int64(0)
if sender != nil {
senderID = sender.ID
}
isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet
isPendingFlow := isWalletAddressPending(senderID)
if !isReplyFlow && !isPendingFlow {
return nil
}
if isReplyFlow {
defer bots.Delete(msg.ReplyTo)
}
msgText := msg.Text
if !isValidTronAddress(msgText) {
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 TRON 地址", msgText))
return nil
}
_, err := data.AddWalletAddress(msgText)
if err != nil {
return c.Send(err.Error())
}
pendingWalletAddressUsers.Delete(senderID)
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText))
return WalletList(c)
return nil
}
func WalletList(c tb.Context) error {
@@ -66,35 +31,29 @@ func WalletList(c tb.Context) error {
if err != nil {
return err
}
var btnList [][]tb.InlineButton
for _, wallet := range wallets {
status := "已启用✅"
if wallet.Status == mdb.TokenStatusDisable {
status = "已禁用🚫"
}
var temp []tb.InlineButton
btnInfo := tb.InlineButton{
Unique: wallet.Address,
Text: fmt.Sprintf("%s [%s]", wallet.Address, status),
Unique: wallet.Token,
Text: fmt.Sprintf("%s[%s]", wallet.Token, status),
Data: strutil.MustString(wallet.ID),
}
bots.Handle(&btnInfo, WalletInfo)
btnList = append(btnList, []tb.InlineButton{btnInfo})
btnList = append(btnList, append(temp, btnInfo))
}
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error {
if sender := c.Sender(); sender != nil {
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
}
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
ForceReply: true,
})
})
btnList = append(btnList, []tb.InlineButton{addBtn})
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{
InlineKeyboard: btnList,
})
}
@@ -105,7 +64,6 @@ func WalletInfo(c tb.Context) error {
if err != nil {
return c.Send(err.Error())
}
enableBtn := tb.InlineButton{
Text: "启用",
Unique: "enableBtn",
@@ -125,13 +83,11 @@ func WalletInfo(c tb.Context) error {
Text: "返回",
Unique: "WalletList",
}
bots.Handle(&enableBtn, EnableWallet)
bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList)
return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
return c.EditOrReply(tokenInfo.Token, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
{
enableBtn,
disableBtn,
@@ -178,20 +134,3 @@ func DelWallet(c tb.Context) error {
}
return WalletList(c)
}
func isWalletAddressPending(userID int64) bool {
if userID <= 0 {
return false
}
value, ok := pendingWalletAddressUsers.Load(userID)
if !ok {
return false
}
state, ok := value.(pendingWalletAddressState)
if !ok || time.Since(state.RequestedAt) > pendingWalletAddressTTL {
pendingWalletAddressUsers.Delete(userID)
return false
}
return true
}
+3 -11
View File
@@ -14,9 +14,8 @@ var bots *tb.Bot
func BotStart() {
var err error
botSetting := tb.Settings{
Token: config.TgBotToken,
Poller: &tb.LongPoller{Timeout: 50 * time.Second},
Synchronous: true,
Token: config.TgBotToken,
Poller: &tb.LongPoller{Timeout: 10 * time.Second},
}
if config.TgProxy != "" {
botSetting.URL = config.TgProxy
@@ -46,21 +45,14 @@ func RegisterHandle() {
// SendToBot 主动发送消息机器人消息
func SendToBot(msg string) {
go func() {
if bots == nil {
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
return
}
user := tb.User{
ID: config.TgManage,
}
log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
_, err := bots.Send(&user, msg, &tb.SendOptions{
ParseMode: tb.ModeHTML,
})
if err != nil {
log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
} else {
log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
log.Sugar.Error(err)
}
}()
}
-34
View File
@@ -1,34 +0,0 @@
package telegram
import (
"crypto/sha256"
"github.com/btcsuite/btcutil/base58"
)
// 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])
}
+11 -13
View File
@@ -1,18 +1,17 @@
package constant
var Errno = map[int]string{
400: "system error",
401: "signature verification failed",
10001: "wallet address already exists",
10002: "order already exists",
10003: "no available wallet address",
10004: "invalid payment amount",
10005: "no available amount channel",
10006: "rate calculation failed",
10007: "block transaction already processed",
10008: "order does not exist",
10009: "failed to parse request params",
10010: "order status already changed",
400: "系统错误",
401: "签名认证错误",
10001: "钱包地址已存在,请勿重复添加",
10002: "支付交易已存在,请勿重复创建",
10003: "无可用钱包地址,无法发起支付",
10004: "支付金额有误, 无法满足最小支付单位",
10005: "无可用金额通道",
10006: "汇率计算错误",
10007: "订单区块已处理",
10008: "订单不存在",
10009: "无法解析请求参数",
}
var (
@@ -27,7 +26,6 @@ var (
OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009)
OrderStatusConflict = Err(10010)
)
type RspError struct {
+2 -3
View File
@@ -1,9 +1,8 @@
package http_client
import (
"time"
"github.com/go-resty/resty/v2"
"time"
)
// GetHttpClient 获取请求客户端
@@ -14,6 +13,6 @@ func GetHttpClient(proxys ...string) *resty.Client {
proxy := proxys[0]
client.SetProxy(proxy)
}
client.SetTimeout(time.Second * 10)
client.SetTimeout(time.Second * 5)
return client
}
+5 -32
View File
@@ -7,37 +7,27 @@ import (
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"time"
)
var Sugar *zap.SugaredLogger
func Init() {
level := getLogLevel()
core := zapcore.NewTee(
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
)
writeSyncer := getLogWriter()
encoder := getEncoder()
core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel)
logger := zap.New(core, zap.AddCaller())
Sugar = logger.Sugar()
}
func getFileEncoder() zapcore.Encoder {
func getEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
func getConsoleEncoder() zapcore.Encoder {
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return zapcore.NewConsoleEncoder(encoderConfig)
}
func getFileWriter() zapcore.WriteSyncer {
func getLogWriter() zapcore.WriteSyncer {
file := fmt.Sprintf("%s/log_%s.log",
config.LogSavePath,
time.Now().Format("20060102"))
@@ -50,20 +40,3 @@ func getFileWriter() zapcore.WriteSyncer {
}
return zapcore.AddSync(lumberJackLogger)
}
func getConsoleWriter() zapcore.WriteSyncer {
return zapcore.Lock(os.Stdout)
}
func getLogLevel() zapcore.Level {
switch config.LogLevel {
case "debug":
return zapcore.DebugLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
-103
View File
@@ -1,103 +0,0 @@
# 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
@@ -1,104 +0,0 @@
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
-214
View File
@@ -1,214 +0,0 @@
## 写在前面
- 此教程专为有洁癖的宝宝们准备。不使用任何一键安装脚本。面板党可以退散了!!
- 本人测试环境是 Debian 11 其他的没测试。
## 1.下载源代码
```bash
cd /var/www/
mkdir epusdt
chmod 777 -R /var/www/epusdt
cd epusdt
wget https://github.com/assimon/epusdt/releases/download/v0.0.3/epusdt_0.0.3_Linux_x86_64.tar.gz
tar -xzf epusdt_0.0.3_Linux_x86_64.tar.gz
rm epusdt_0.0.3_Linux_x86_64.tar.gz
```
## 2.导入Sql
- 创建sql文件
```bash
nano epusdt.sql
```
然后复制下面的
```sql
-- auto-generated definition
use epusdt;
create table orders
(
id int auto_increment
primary key,
trade_id varchar(32) not null comment 'epusdt订单号',
order_id varchar(32) not null comment '客户交易id',
block_transaction_id varchar(128) null comment '区块唯一编号',
actual_amount decimal(19, 4) not null comment '订单实际需要支付的金额,保留4位小数',
amount decimal(19, 4) not null comment '订单金额,保留4位小数',
token varchar(50) not null comment '所属钱包地址',
status int default 1 not null comment '1:等待支付,2:支付成功,3:已过期',
notify_url varchar(128) not null comment '异步回调地址',
redirect_url varchar(128) null comment '同步回调地址',
callback_num int default 0 null comment '回调次数',
callback_confirm int default 2 null comment '回调是否已确认? 1是 2否',
created_at timestamp null,
updated_at timestamp null,
deleted_at timestamp null,
constraint orders_order_id_uindex
unique (order_id),
constraint orders_trade_id_uindex
unique (trade_id)
);
create index orders_block_transaction_id_index
on orders (block_transaction_id);
-- auto-generated definition
create table wallet_address
(
id int auto_increment
primary key,
token varchar(50) not null comment '钱包token',
status int default 1 not null comment '1:启用 2:禁用',
created_at timestamp null,
updated_at timestamp null,
deleted_at timestamp null
)
comment '钱包表';
create index wallet_address_token_index
on wallet_address (token);
```
`ctrl+x` 退出,按 `Y`保存 再按回车就好了
- 创建数据库
```bash
mysql
```
接下来输入命令
```sql
CREATE DATABASE [] ;
GRANT ALL ON [].* TO '[这里替换为用户名]'@'localhost' IDENTIFIED BY '[这里替换为密码]' WITH GRANT OPTION;
FLUSH PRIVILEGES;
EXIT
```
- 导入sql文件
```bash
mysql -u[用户名] -p[密码] < epusdt.sql
```
## 3.配置反向代理
```bash
nano /etc/nginx/sites-enabled/epusdt
```
你可以参考以下我的配置文件,注意更改域名。
```bash
server {
listen 80;
server_name domain.com;
return 301 https://domain.com$request_uri;
}
server {
listen 443 ssl http2;
server_name domain.com;
ssl_certificate /etc/nginx/sslcert/cert.crt;
ssl_certificate_key /etc/nginx/sslcert/key.key;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://127.0.0.1:8000;
}
}
```
## 4.赋予Epusdt执行权限
`linux`服务器需要赋予`Epust`执行权限方可启动。
执行命令```chmod +x epusdt```赋予权限
## 5、配置Epusdt
执行命令
```bash
mv .env.example .env
nano .env
```
```dotenv
app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://upay.dujiaoka.com
#是否开启debug,默认false
app_debug=false
#http服务监听端口
http_listen=:8000
#静态资源文件目录
static_path=/static
#缓存路径
runtime_root_path=/runtime
#日志配置
log_save_path=/logs
log_max_size=32
log_max_age=7
max_backups=3
# mysql配置
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql账号
mysql_passwd=mysql密码
mysql_database=数据库
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# redis配置
redis_host=127.0.0.1
redis_port=6379
redis_passwd=
redis_db=5
redis_pool_size=5
redis_max_retries=3
redis_idle_timeout=1000
# 消息队列配置
queue_concurrency=10
queue_level_critical=6
queue_level_default=3
queue_level_low=1
#机器人Apitoken
tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy=
#管理员userid
tg_manage=
#api接口认证token(用于发起交易的签名认证,请勿外泄)
api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate=
```
⚠️注意:配置文件里面不认识的不要修改,留空即可,不会改又要瞎改,除非你对项目源代码很熟悉很有信心😁
必填配置项:app_uri、mysql配置、redis配置、api_auth_token
选填配置项:tg_bot_token、tg_manage
## 6、配置supervisor
为了保证`Epusdt`常驻后台运行,我们需要配置`supervisor`来实现进程监听
```bash
nano /etc/supervisor/conf.d/epusdt.conf
```
你可以参考以下我的配置文件,注意更改路径。
```conf
[program:epusdt]
process_name=epusdt
directory=/var/www/epusdt
command=/var/www/epusdt/epusdt http start
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/supervisor/epusdt.log
```
接下来输入命令
```bash
supervisorctl reread
supervisorctl update
supervisorctl start epusdt
supervisorctl tail epusdt
```
出现下图,即为配置成功
```bash
_____ _ _
| ____|_ __ _ _ ___ __| | |_
| _| | '_ \| | | / __|/ _` | __|
| |___| |_) | |_| \__ \ (_| | |_
|_____| .__/ \__,_|___/\__,_|\__|
|_|
Epusdt version(0.0.2) Powered by assimon https://github.com/assimon/epusdt
⇨ http server started on [::]:8000
```
## 其他注意事项
- 1.所有`.env`配置文件有了修改后都需要重启supervisor进程 `supervisorctl restart epusdt`
- 2.教程所示的目录均为参考,请勿1:1照抄,根据自己实际情况来