Merge pull request #17 from GMwalletApp/dev-runtime

refactor: harden sqlite runtime and packaged startup flow
This commit is contained in:
alphago9
2026-03-31 14:22:26 +08:00
committed by GitHub
45 changed files with 2534 additions and 771 deletions
+25 -7
View File
@@ -1,28 +1,46 @@
name: docker-alpine-epusdt name: docker-alpine-epusdt
on: on:
workflow_dispatch: #github页面手动触发 workflow_dispatch:
push: push:
branches: [ "main" ] branches:
- "main"
env: env:
IMAGE_NAME: epusdt #这是您的镜像名 IMAGE_NAME: epusdt
jobs: jobs:
push-docker-hub: push-docker-hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
TZ: Asia/Shanghai TZ: Asia/Shanghai
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v6
- name: Compute Build Metadata
id: meta
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login - name: Login
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build && Push
uses: docker/build-push-action@v4 - name: Build And Push
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ steps.meta.outputs.commit }}
DATE=${{ steps.meta.outputs.date }}
tags: | tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
+62
View File
@@ -0,0 +1,62 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
attestations: 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: Install Cosign
uses: sigstore/cosign-installer@v4.1.1
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0.24.0
- 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 Checksums
uses: actions/attest@v4
with:
subject-checksums: src/dist/epusdt_${{ github.ref_name }}_checksums.txt
+3
View File
@@ -1,5 +1,8 @@
.idea/ .idea/
src/runtime/ src/runtime/
src/.env src/.env
src/*.db
src/*.db-shm
src/*.db-wal
.DS_Store .DS_Store
.env .env
+157
View File
@@ -0,0 +1,157 @@
# Development Standard
## Purpose
This document defines the default development goals, review criteria, and testing constraints for changes in this repository.
Before starting any implementation, use this document as a checklist.
## Development Goals
Every change should be judged against all of the following goals:
1. Functional completeness
The target feature must actually work end to end, not just compile.
2. No accidental teammate regression
Do not accidentally remove or override teammate capabilities that already exist on the base branch.
3. Robustness
The code should handle normal failure cases, retries, state conflicts, and restart scenarios as reasonably as possible.
4. Elegance
Prefer clear responsibilities, minimal coupling, and a small number of well-named moving parts.
5. Maintainability
Avoid hidden behavior, confusing configuration, or implementation details that make future debugging harder.
6. No dirty output
Never commit local runtime artifacts, local databases, caches, logs, or other development byproducts.
7. No mojibake
Source files, messages, and docs should not contain broken encoding text.
## Multi-Dimensional Self Review
Each meaningful change should be evaluated across these dimensions:
- functional completeness
- engineering implementation quality
- robustness
- elegance
- maintainability
- regression risk
- commit readiness
- test coverage quality
Scoring should be honest. High scores require both working behavior and clean delivery quality.
## Code Quality Rules
### Redundancy
Avoid redundant code unless duplication is clearly cheaper and safer than abstraction.
The following are considered bad redundancy:
- duplicate business logic with only tiny naming differences
- duplicate retry or state logic in multiple places
- helper functions that exist only to wrap one line without improving clarity
The following are acceptable:
- small data shapes for read optimization
- isolated helpers that remove repeated infrastructure setup
- compatibility shims with clear removal intent
### Elegance
Code is considered elegant when:
- responsibilities are split cleanly
- names explain intent
- state transitions are explicit
- configuration is understandable
- behavior is observable through useful logs
- failure handling does not hide important problems
### Robustness
Code is considered robust when:
- state changes are guarded by conditions where needed
- retries are controlled, not infinite
- restart recovery is possible where it matters
- local concurrency does not easily corrupt behavior
- failure in one side task does not silently break the main flow
## Bug Standard
Ask these questions before treating a change as finished:
- Is there any blocking functional bug?
- Is there any likely edge-case bug?
- Is there any operational bug under realistic local concurrency?
- Is there any user-visible regression?
If a bug is known, it must be called out explicitly in review notes.
## Testing Rules
### Required Principle
Tests must validate behavior, not re-implement the code under test.
### Forbidden Test Types
Do not write:
- invalid tests
- mirror tests
- tests that only repeat implementation branches line by line
- tests that only verify mocks interacted in the same order as the code was written
- tests that lock onto incidental internals instead of real behavior
### Preferred Test Types
Prefer:
- end-to-end behavior tests
- state transition tests
- concurrency conflict tests
- retry and recovery tests
- expiration and timing behavior tests
- persistence and restart-oriented tests where relevant
### Test Coverage Expectations
Coverage quality matters more than raw coverage count.
For core flows, tests should cover:
- normal success path
- important failure path
- state conflict or idempotency path
- retry or delayed processing path where relevant
## Review Checklist
Before considering a change ready, confirm:
- functionality is complete
- teammate capability was not accidentally lost
- no local artifact will be committed
- no mojibake exists
- no obvious redundant code exists
- no known blocking bug remains
- robustness is acceptable for the target deployment model
- tests are behavior-based and not mirror tests
- review notes clearly explain intentional semantic changes
## Current Project Direction
For this repository specifically, current review should assume:
- single-instance deployment is the primary target unless stated otherwise
- runtime semantics may intentionally differ from older Redis and asynq behavior when the new SQLite design is deliberate
- any such semantic difference must be explained in merge or review notes
+4 -2
View File
@@ -2,13 +2,15 @@ FROM golang:alpine AS builder
RUN apk add --no-cache --update git build-base RUN apk add --no-cache --update git build-base
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
ARG VERSION=0.0.0-dev
ARG COMMIT=none
ARG DATE=unknown
WORKDIR /app WORKDIR /app
COPY ./src/go.mod ./src/go.sum ./ COPY ./src/go.mod ./src/go.sum ./
RUN go mod download RUN go mod download
COPY ./src . COPY ./src .
RUN go mod tidy \ RUN go build -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}" \
&& go build -ldflags "-s -w" \
-o epusdt . -o epusdt .
FROM alpine:latest AS runner FROM alpine:latest AS runner
+74
View File
@@ -0,0 +1,74 @@
# 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 checksum file
- a Sigstore keyless signature bundle for the checksum file
- archive SBOM documents generated by Syft
- 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 checksum signature with Cosign:
```bash
cosign verify-blob \
--bundle epusdt_v1.0.0_checksums.txt.sigstore.json \
epusdt_v1.0.0_checksums.txt
```
Verify the GitHub build provenance attestation:
```bash
gh attestation verify epusdt_v1.0.0_checksums.txt -R GMwalletApp/epusdt
```
## Local validation
Install `cosign` and `syft`, then 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/`.
+208
View File
@@ -0,0 +1,208 @@
# Review
## Scope
This review checks `dev-runtime` against `dev` with the following standards:
- Do not accidentally remove or override teammate payment capabilities already merged into `dev`.
- Do not submit local test artifacts or dirty files.
- Confirm Redis -> SQLite replacement is functionally complete for the intended single-instance target.
- Judge code quality, elegance, robustness, and maintainability.
- Check for real bugs, mojibake, and redundant code.
- Compare current quality with the previous `dev` implementation.
These standards are also the default development constraints for future work:
- no invalid tests
- no mirror tests
- prefer behavior-based tests over implementation-shaped tests
- explain intentional semantic changes in review notes
- verify dirty local artifacts are excluded before commit
- think about teammate-regression risk before coding, not only after coding
## Files That Must Not Be Committed
These local SQLite artifacts must never be committed:
- `src/epusdt.db`
- `src/epusdt.db-shm`
- `src/epusdt.db-wal`
They are now excluded by `.gitignore`.
## Teammate Capabilities Still Preserved
The following teammate payment capabilities are still present:
- multi-currency order model: `src/model/mdb/orders_mdb.go`
- wallet address model: `src/model/mdb/wallet_address_mdb.go`
- native `TRX` transfer detection: `src/model/service/task_service.go`
- `TRC20 USDT` detection: `src/model/service/task_service.go`
- dynamic exchange-rate path: `src/model/service/order_service.go`, `src/config/config.go`
- `TRON_GRID_API_KEY` support: `src/config/config.go`
Conclusion: teammate core payment capabilities were not accidentally removed.
## Intentional Architecture Changes
These are intentional refactors, not accidental overrides:
- Redis runtime removed: `src/model/dao/rdb.go`
- asynq handlers removed: `src/mq/handle/callback_queue.go`, `src/mq/handle/order_expiration_queue.go`
- SQLite runtime lock introduced: `src/model/mdb/transaction_lock_mdb.go`
- SQLite scheduler introduced: `src/mq/queue.go`, `src/mq/worker.go`
- reservation moved from Redis key to SQLite unique constraint: `src/model/data/order_data.go`
Conclusion: this branch keeps payment capability but changes the runtime implementation.
## Previously Regressed Parts That Are Now Restored
The following regressions have been restored in the current working tree:
- rich Telegram payment notification template: `src/model/service/task_service.go`
- key chain-scan observability logs: `src/model/service/task_service.go`
- Chinese checkout error message: `src/model/service/pay_service.go`
- more reliable Telegram add-wallet flow: `src/telegram/handle.go`
## Functional Completion
For the single-instance target, the Redis -> SQLite replacement is complete across:
- order creation
- amount reservation
- chain scan matching
- payment success processing
- order expiration
- callback scheduling
- Telegram notification
Conclusion: feature work is complete for the intended single-instance model.
## Mojibake Check
No real mojibake was found in the source files checked during review.
Files spot-checked:
- `src/telegram/handle.go`
- `src/model/service/task_service.go`
- `src/model/service/pay_service.go`
Note: some terminals may render UTF-8 Chinese poorly, but the source content itself is fine.
## Redundant Code Check
No obvious redundant business code was found.
Recent helper additions are justified:
- `PendingCallbackOrder`
- `expirableOrder`
- `configureSQLite`
Conclusion: no clear "fix by duplication" smell was found.
## Bug Assessment
No confirmed blocking business bug is currently visible in the reviewed code path.
Important risk that still needs attention:
- SQLite main DB can still hit `SQLITE_BUSY` under concurrent polling and writes.
This risk is now being mitigated by:
- `src/model/dao/sqlite_config.go`
- `src/model/dao/mdb_sqlite.go`
- `src/model/dao/runtime_sqlite.go`
- lightweight busy retry in `src/mq/worker.go`
Conclusion: no confirmed blocking functional bug, but SQLite concurrency remains a real operational boundary that should still be watched in runtime.
## Robustness Assessment
Strengths:
- conditional state transitions prevent reviving expired orders
- reservation is now a persistent unique constraint instead of a temporary Redis key
- callback state is durable across process restarts
- behavior tests exist for key paths:
- `src/model/service/order_service_test.go`
- `src/mq/worker_test.go`
- callback polling now has a small targeted retry for transient SQLite busy errors
Remaining limits:
- robust for single-instance deployment
- not a final design for multi-instance deployment
- SQLite contention should still be watched in real runtime
Conclusion: robust enough for single-instance use, not a direct multi-instance design.
## Elegance Assessment
Strengths:
- runtime responsibility is cleaner with SQLite reservation + scheduler
- logging responsibility is split more clearly:
- `log_level`
- `http_access_log`
- `sql_debug`
- Telegram input flow is more practical for real clients
- SQLite tuning is extracted into one helper
Tradeoffs:
- callback semantics are no longer the same as the old immediate-asynq enqueue path
- current branch still has local uncommitted fixes that must be organized into a clean commit set
Conclusion: elegant for the chosen single-instance direction, but must be explained clearly when merging.
## Quality Compared With Previous Code
Overall judgment: quality did not go down; it improved in the single-instance direction.
Improvements:
- stronger state-machine constraints
- better log control
- more durable runtime reservation
- better Telegram interaction resilience
- added behavior tests
- added restart-oriented callback recovery coverage
- reduced scheduler fragility under transient SQLite busy errors
Changed semantics rather than degradation:
- merchant callback is now polled by SQLite scheduler instead of immediate asynq enqueue
Conclusion: quality is not lower, but runtime semantics changed and must be stated explicitly.
## Self Score
- functional completeness: 9.1/10
- engineering implementation: 8.9/10
- robustness: 8.7/10
- elegance: 9.0/10
- commit readiness: 8.8/10
Overall self score: 8.9/10
Main deductions:
- current uncommitted fixes still need to be organized into a clean commit
- SQLite single-file concurrency boundary still exists
- callback semantics differ from `dev-payment` and require clear communication
## Final Conclusion
This code can be submitted, but only if all of the following are done:
1. Do not commit local SQLite artifacts.
2. Include the current uncommitted fixes in a clean, intentional commit set.
3. Explain clearly in merge notes:
- what teammate capabilities were preserved
- what runtime behavior was intentionally refactored
- what semantics changed and why
If those three conditions are met, this is not a hidden override of teammate code. It is a reviewed and documented refactor.
+18 -38
View File
@@ -1,83 +1,63 @@
app_name=epusdt app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com app_uri=https://dujiaoka.com
#是否开启debug,默认false log_level=info
app_debug=false http_access_log=false
#http服务监听端口 sql_debug=false
http_listen=:8000 http_listen=:8000
#静态资源文件目录
static_path=/static static_path=/static
#缓存路径
runtime_root_path=/runtime runtime_root_path=/runtime
#日志配置
log_save_path=/logs log_save_path=/logs
log_max_size=32 log_max_size=32
log_max_age=7 log_max_age=7
max_backups=3 max_backups=3
#数据库类型,默认为mysql类型,可配置项: postgres,mysql,sqlite # supported values: postgres,mysql,sqlite
db_type=sqlite db_type=sqlite
# sqlite配置 # sqlite primary database config
sqlite_database_filename= sqlite_database_filename=
sqlite_table_prefix= sqlite_table_prefix=
# postgres配置 # postgres config
postgres_host=127.0.0.1 postgres_host=127.0.0.1
postgres_port=3306 postgres_port=3306
postgres_user=mysql账号 postgres_user=mysql_user
postgres_passwd=mysql密码 postgres_passwd=mysql_password
postgres_database=数据库 postgres_database=database_name
postgres_table_prefix= postgres_table_prefix=
postgres_max_idle_conns=10 postgres_max_idle_conns=10
postgres_max_open_conns=100 postgres_max_open_conns=100
postgres_max_life_time=6 postgres_max_life_time=6
# mysql配置 # mysql config
mysql_host=127.0.0.1 mysql_host=127.0.0.1
mysql_port=3306 mysql_port=3306
mysql_user=mysql账号 mysql_user=mysql_user
mysql_passwd=mysql密码 mysql_passwd=mysql_password
mysql_database=数据库 mysql_database=database_name
mysql_table_prefix= mysql_table_prefix=
mysql_max_idle_conns=10 mysql_max_idle_conns=10
mysql_max_open_conns=100 mysql_max_open_conns=100
mysql_max_life_time=6 mysql_max_life_time=6
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# redis配置 # background scheduler config
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_concurrency=10
queue_level_critical=6 queue_poll_interval_ms=1000
queue_level_default=3 callback_retry_base_seconds=5
queue_level_low=1
#机器人Apitoken
tg_bot_token= tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy= tg_proxy=
#管理员userid
tg_manage= tg_manage=
#api接口认证token
api_auth_token= api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10 order_expiration_time=10
#订单回调失败最大重试次数
order_notice_max_retry=0 order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate= forced_usdt_rate=
# 汇率接口url
api_rate_url= api_rate_url=
tron_grid_api_key= tron_grid_api_key=
+52 -24
View File
@@ -1,44 +1,72 @@
# This is an example .goreleaser.yml file with some sensible defaults. version: 2
# Make sure to check the documentation at https://goreleaser.com
project_name: epusdt project_name: epusdt
before: before:
hooks: hooks:
# You may remove this if you don't use go modules. - go test ./...
#- go mod tidy
# you may remove this if you don't need go generate
#- go generate ./...
builds: builds:
- env: - id: epusdt
main: .
binary: epusdt
env:
- CGO_ENABLED=0 - CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
goos: goos:
- darwin
- linux - linux
- darwin
- windows - windows
goarch: goarch:
- amd64 - amd64
- arm - arm64
- 386
archives: archives:
- replacements: - id: epusdt
darwin: Darwin ids:
linux: Linux - epusdt
windows: Windows name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
386: i386
amd64: x86_64
id: epusdt
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format: tar.gz
files: files:
- .env.example - .env.example
- static/* - static/**
- runtime/* format_overrides:
- goos: windows
formats:
- zip
checksum: checksum:
name_template: 'checksums.txt' name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- "sign-blob"
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
sboms:
- id: archives
artifacts: archive
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-next" name_template: "{{ incpatch .Version }}-next"
release:
github:
owner: GMwalletApp
name: epusdt
changelog: changelog:
sort: asc sort: asc
filters: filters:
exclude: exclude:
- '^docs:' - "^docs:"
- '^test:' - "^test:"
- "^chore:"
- "^ci:"
+13 -23
View File
@@ -1,7 +1,8 @@
package bootstrap package bootstrap
import ( import (
"github.com/assimon/luuu/command" "sync"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/mq" "github.com/assimon/luuu/mq"
@@ -10,26 +11,15 @@ import (
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
) )
// Start 服务启动 var initOnce sync.Once
func Start() {
// 配置加载 func InitApp() {
config.Init() initOnce.Do(func() {
// 日志加载 config.Init()
log.Init() log.Init()
// // Mysql启动 dao.Init()
// dao.MysqlInit() mq.Start()
// dao.MdbTableInit() go telegram.BotStart()
// // redis启动 go task.Start()
// dao.RedisInit() })
dao.Init()
// 队列启动
mq.Start()
// telegram机器人启动
go telegram.BotStart()
// 定时任务
go task.Start()
err := command.Execute()
if err != nil {
panic(err)
}
} }
+18
View File
@@ -0,0 +1,18 @@
package command
import (
"github.com/assimon/luuu/config"
"github.com/gookit/color"
)
func printBanner() {
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
color.Infof(
"Epusdt version(%s) commit(%s) built(%s) Powered by %s %s \n",
config.GetAppVersion(),
config.GetBuildCommit(),
config.GetBuildDate(),
"GMwalletApp",
"https://github.com/GMwalletApp/epusdt",
)
}
+18 -19
View File
@@ -2,6 +2,12 @@ package command
import ( import (
"context" "context"
"net/http"
"os"
"os/signal"
"time"
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/middleware" "github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route" "github.com/assimon/luuu/route"
@@ -12,16 +18,12 @@ import (
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"net/http"
"os"
"os/signal"
"time"
) )
var httpCmd = &cobra.Command{ var httpCmd = &cobra.Command{
Use: "http", Use: "http",
Short: "http服务", Short: "http service",
Long: "http服务相关命令", Long: "http service commands",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
}, },
} }
@@ -32,9 +34,11 @@ func init() {
var startCmd = &cobra.Command{ var startCmd = &cobra.Command{
Use: "start", Use: "start",
Short: "启动", Short: "start",
Long: "启动http服务", Long: "start http service",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
bootstrap.InitApp()
printBanner()
HttpServerStart() HttpServerStart()
}, },
} }
@@ -44,23 +48,22 @@ func HttpServerStart() {
e := echo.New() e := echo.New()
e.HideBanner = true e.HideBanner = true
e.HTTPErrorHandler = customHTTPErrorHandler e.HTTPErrorHandler = customHTTPErrorHandler
// 中间件注册
MiddlewareRegister(e) MiddlewareRegister(e)
// 路由注册
route.RegisterRoute(e) route.RegisterRoute(e)
// 静态目录注册 e.Static(config.StaticPath, config.StaticFilePath)
e.Static(config.StaticPath, "static")
httpListen := viper.GetString("http_listen") httpListen := viper.GetString("http_listen")
go func() { go func() {
if err = e.Start(httpListen); err != http.ErrServerClosed { if err = e.Start(httpListen); err != http.ErrServerClosed {
log.Sugar.Error(err) log.Sugar.Error(err)
} }
}() }()
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, os.Kill) signal.Notify(quit, os.Interrupt, os.Kill)
<-quit <-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err = e.Shutdown(ctx); err != nil { if err = e.Shutdown(ctx); err != nil {
@@ -68,16 +71,13 @@ func HttpServerStart() {
} }
} }
// MiddlewareRegister 中间件注册
func MiddlewareRegister(e *echo.Echo) { func MiddlewareRegister(e *echo.Echo) {
if config.AppDebug { if config.HTTPAccessLog {
e.Debug = true
e.Use(echoMiddleware.Logger()) e.Use(echoMiddleware.Logger())
} }
e.Use(middleware.RequestUUID()) e.Use(middleware.RequestUUID())
} }
// customHTTPErrorHandler 默认消息提示
func customHTTPErrorHandler(err error, e echo.Context) { func customHTTPErrorHandler(err error, e echo.Context) {
code := http.StatusInternalServerError code := http.StatusInternalServerError
msg := "server error" msg := "server error"
@@ -95,5 +95,4 @@ func customHTTPErrorHandler(err error, e echo.Context) {
resp.Message = he.Msg resp.Message = he.Msg
} }
_ = e.JSON(http.StatusOK, resp) _ = e.JSON(http.StatusOK, resp)
return
} }
+10 -1
View File
@@ -1,6 +1,11 @@
package command package command
import "github.com/spf13/cobra" import (
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var configPath string
var rootCmd = &cobra.Command{} var rootCmd = &cobra.Command{}
@@ -9,5 +14,9 @@ func Execute() error {
} }
func init() { func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env")
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
config.SetConfigPath(configPath)
}
rootCmd.AddCommand(httpCmd) rootCmd.AddCommand(httpCmd)
} }
+26
View File
@@ -0,0 +1,26 @@
package command
import (
"fmt"
"github.com/assimon/luuu/config"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print build version information",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(),
"version: %s\ncommit: %s\nbuilt: %s\n",
config.GetAppVersion(),
config.GetBuildCommit(),
config.GetBuildDate(),
)
return err
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
+225 -49
View File
@@ -1,10 +1,13 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"github.com/assimon/luuu/util/http_client" "github.com/assimon/luuu/util/http_client"
@@ -13,58 +16,154 @@ import (
) )
var ( var (
AppDebug bool HTTPAccessLog bool
MysqlDns string SQLDebug bool
RuntimePath string LogLevel string
LogSavePath string MysqlDns string
StaticPath string RuntimePath string
TgBotToken string LogSavePath string
TgProxy string StaticPath string
TgManage int64 StaticFilePath string
UsdtRate float64 TgBotToken string
RateApiUrl string TgProxy string
TRON_GRID_API_KEY string TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
configRootPath string
explicitConfigPath string
) )
func SetConfigPath(path string) {
explicitConfigPath = strings.TrimSpace(path)
}
func Init() { func Init() {
viper.AddConfigPath("./") configPath, err := resolveConfigFilePath()
viper.SetConfigFile(".env")
err := viper.ReadInConfig()
if err != nil { if err != nil {
panic(err) panic(err)
} }
gwd, err := os.Getwd() configRootPath = filepath.Dir(configPath)
viper.SetConfigFile(configPath)
err = viper.ReadInConfig()
if err != nil { if err != nil {
panic(err) panic(err)
} }
AppDebug = viper.GetBool("app_debug") HTTPAccessLog = viper.GetBool("http_access_log")
StaticPath = viper.GetString("static_path") SQLDebug = viper.GetBool("sql_debug")
RuntimePath = fmt.Sprintf( LogLevel = normalizeLogLevel(viper.GetString("log_level"))
"%s%s", StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
gwd, StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
viper.GetString("runtime_root_path")) RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = fmt.Sprintf( LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
"%s%s", mustMkdir(RuntimePath)
RuntimePath, mustMkdir(LogSavePath)
viper.GetString("log_save_path"))
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
url.QueryEscape(viper.GetString("mysql_user")), url.QueryEscape(viper.GetString("mysql_user")),
url.QueryEscape(viper.GetString("mysql_passwd")), url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf( fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
viper.GetString("mysql_database")) viper.GetString("mysql_database"))
TgBotToken = viper.GetString("tg_bot_token") TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy") TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
GetRateApiUrl() RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key") TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
} }
func mustMkdir(path string) {
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
}
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error":
return strings.ToLower(strings.TrimSpace(level))
default:
return "info"
}
}
func normalizeStaticURLPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/static"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func resolvePathFromBase(basePath string, path string, fallback string) string {
path = strings.TrimSpace(path)
if path == "" {
return fallback
}
if filepath.IsAbs(path) {
return path
}
path = strings.TrimPrefix(path, "/")
path = strings.TrimPrefix(path, "\\")
return filepath.Join(basePath, filepath.FromSlash(path))
}
func resolveConfigFilePath() (string, error) {
if explicitConfigPath != "" {
return normalizeConfiguredPath(explicitConfigPath)
}
if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" {
return normalizeConfiguredPath(envPath)
}
return normalizeConfiguredPath(".env")
}
func normalizeConfiguredPath(input string) (string, error) {
path := strings.TrimSpace(input)
if path == "" {
path = ".env"
}
if !filepath.IsAbs(path) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
path = filepath.Join(cwd, path)
}
info, err := os.Stat(path)
if err == nil && info.IsDir() {
path = filepath.Join(path, ".env")
info, err = os.Stat(path)
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("config file not found: %s", path)
}
return "", err
}
if info.IsDir() {
return "", fmt.Errorf("config path must point to a file, got directory: %s", path)
}
return path, nil
}
func GetAppVersion() string { func GetAppVersion() string {
return "0.0.2" return BuildVersion
}
func GetBuildCommit() string {
return BuildCommit
}
func GetBuildDate() string {
return BuildDate
} }
func GetAppName() string { func GetAppName() string {
@@ -84,40 +183,62 @@ func GetApiAuthToken() string {
} }
func GetRateApiUrl() string { func GetRateApiUrl() string {
url := viper.GetString("api_rate_url") rateURL := viper.GetString("api_rate_url")
urlFromEnv := os.Getenv("API_RATE_URL") if rateURL == "" {
if url == "" && urlFromEnv != "" { rateURL = os.Getenv("API_RATE_URL")
url = urlFromEnv
} }
if url == "" { if rateURL == "" {
log.Println("api_rate_url is empty") log.Println("api_rate_url is empty")
} }
RateApiUrl = url RateApiUrl = rateURL
return url return rateURL
} }
func GetRateForCoin(coin string, base string) float64 { func GetRateForCoin(coin string, base string) float64 {
client := http_client.GetHttpClient() coin = strings.ToLower(strings.TrimSpace(coin))
baseUrl := RateApiUrl base = strings.ToLower(strings.TrimSpace(base))
if baseUrl == "" { 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") log.Printf("rate api url is empty")
return 0.0 return 0.0
} }
if baseUrl[len(baseUrl)-1] != '/' { if baseURL[len(baseURL)-1] != '/' {
baseUrl += "/" baseURL += "/"
} }
url := baseUrl + fmt.Sprintf("%s.json", base)
fmt.Println("call rate api url:", url) client := http_client.GetHttpClient()
resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base))
resp, err := client.R().Get(url)
if err != nil { if err != nil {
log.Printf("call rate api error: %s", err.Error()) log.Printf("call rate api error: %s", err.Error())
return 0.0 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 targetRate := 0.0
gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool { gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool {
if key.String() == coin { if key.String() == coin {
targetRate = value.Float() targetRate = value.Float()
@@ -126,7 +247,6 @@ func GetRateForCoin(coin string, base string) float64 {
return true return true
}) })
return targetRate return targetRate
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
@@ -152,3 +272,59 @@ func GetOrderExpirationTimeDuration() time.Duration {
timer := GetOrderExpirationTime() timer := GetOrderExpirationTime()
return time.Minute * time.Duration(timer) return time.Minute * time.Duration(timer)
} }
func GetRuntimeSqlitePath() string {
filename := viper.GetString("runtime_sqlite_filename")
if filename == "" {
filename = "epusdt-runtime.db"
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(RuntimePath, filepath.FromSlash(filename))
}
func GetPrimarySqlitePath() string {
filename := strings.TrimSpace(viper.GetString("sqlite_database_filename"))
if filename == "" {
return filepath.Join(configRootPath, "epusdt.db")
}
if filepath.IsAbs(filename) {
return filename
}
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
return filepath.Join(configRootPath, filepath.FromSlash(filename))
}
func GetQueueConcurrency() int {
concurrency := viper.GetInt("queue_concurrency")
if concurrency <= 0 {
return 10
}
return concurrency
}
func GetQueuePollInterval() time.Duration {
interval := viper.GetInt("queue_poll_interval_ms")
if interval <= 0 {
interval = 1000
}
return time.Duration(interval) * time.Millisecond
}
func GetOrderNoticeMaxRetry() int {
retry := viper.GetInt("order_notice_max_retry")
if retry < 0 {
return 0
}
return retry
}
func GetCallbackRetryBaseDuration() time.Duration {
seconds := viper.GetInt("callback_retry_base_seconds")
if seconds <= 0 {
seconds = 5
}
return time.Duration(seconds) * time.Second
}
+118
View File
@@ -0,0 +1,118 @@
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)
}
}
+2 -2
View File
@@ -1,13 +1,13 @@
package comm package comm
import ( import (
"fmt"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/model/service" "github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"html/template" "html/template"
"net/http" "net/http"
"path/filepath"
) )
// CheckoutCounter 收银台 // CheckoutCounter 收银台
@@ -17,7 +17,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
if err != nil { if err != nil {
return ctx.String(http.StatusOK, err.Error()) return ctx.String(http.StatusOK, err.Error())
} }
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html")) tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil { if err != nil {
return ctx.String(http.StatusOK, err.Error()) return ctx.String(http.StatusOK, err.Error())
} }
+1 -6
View File
@@ -5,12 +5,10 @@ go 1.25.0
require ( require (
github.com/btcsuite/btcutil v1.0.2 github.com/btcsuite/btcutil v1.0.2
github.com/dromara/carbon/v2 v2.6.15 github.com/dromara/carbon/v2 v2.6.15
github.com/go-redis/redis/v8 v8.11.5
github.com/go-resty/resty/v2 v2.11.0 github.com/go-resty/resty/v2 v2.11.0
github.com/gookit/color v1.5.0 github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6 github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1 github.com/gookit/validate v1.3.1
github.com/hibiken/asynq v0.25.1
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0 github.com/labstack/echo/v4 v4.6.0
github.com/libtnb/sqlite v1.0.1 github.com/libtnb/sqlite v1.0.1
@@ -31,12 +29,11 @@ require (
) )
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.1.2 // indirect github.com/gookit/filter v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
@@ -57,7 +54,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect github.com/pelletier/go-toml v1.9.4 // indirect
github.com/redis/go-redis/v9 v9.17.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/afero v1.6.0 // indirect github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
@@ -76,7 +72,6 @@ require (
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.63.2 // indirect gopkg.in/ini.v1 v1.63.2 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
-25
View File
@@ -55,10 +55,6 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
@@ -71,8 +67,6 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -88,8 +82,6 @@ github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dromara/carbon/v2 v2.6.15 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI= github.com/dromara/carbon/v2 v2.6.15 h1:3HuC3XcWczIHUTbg/f0CSVydtKEdM+P0GM1sdsbwXmI=
github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -120,8 +112,6 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
@@ -251,8 +241,6 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -343,15 +331,9 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
@@ -364,8 +346,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -449,8 +429,6 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U=
@@ -871,8 +849,6 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -887,7 +863,6 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXL
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo= gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo=
gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg= gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+96
View File
@@ -0,0 +1,96 @@
package testutil
import (
"path/filepath"
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
appLog "github.com/assimon/luuu/util/log"
"github.com/libtnb/sqlite"
"github.com/spf13/viper"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func SetupTestDatabases(t testing.TB) func() {
t.Helper()
viper.Reset()
viper.Set("forced_usdt_rate", 1.0)
viper.Set("app_uri", "https://example.com")
viper.Set("order_expiration_time", 10)
viper.Set("order_notice_max_retry", 2)
viper.Set("callback_retry_base_seconds", 1)
viper.Set("queue_concurrency", 4)
viper.Set("queue_poll_interval_ms", 50)
viper.Set("api_auth_token", "test-token")
config.HTTPAccessLog = false
config.SQLDebug = false
config.LogLevel = "error"
config.UsdtRate = 0
appLog.Sugar = zap.NewNop().Sugar()
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
mustMigrate(t, mainDB, &mdb.Orders{}, &mdb.WalletAddress{})
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB
return func() {
closeDB(t, runtimeDB)
closeDB(t, mainDB)
dao.Mdb = nil
dao.RuntimeDB = nil
viper.Reset()
}
}
func mustOpenSQLite(t testing.TB, path string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
NamingStrategy: schema.NamingStrategy{SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite %s: %v", path, err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle for %s: %v", path, err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
return db
}
func mustMigrate(t testing.TB, db *gorm.DB, models ...interface{}) {
t.Helper()
if err := db.AutoMigrate(models...); err != nil {
t.Fatalf("automigrate: %v", err)
}
}
func closeDB(t testing.TB, db *gorm.DB) {
t.Helper()
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle close: %v", err)
}
if err = sqlDB.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
}
+4 -5
View File
@@ -1,8 +1,7 @@
package main package main
import ( import (
"github.com/assimon/luuu/bootstrap" "github.com/assimon/luuu/command"
"github.com/assimon/luuu/config"
"github.com/gookit/color" "github.com/gookit/color"
) )
@@ -12,7 +11,7 @@ func main() {
color.Error.Println("[Start Server Err!!!] ", err) color.Error.Println("[Start Server Err!!!] ", err)
} }
}() }()
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ") if err := command.Execute(); err != nil {
color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "GMwalletApp", "https://github.com/GMwalletApp/epusdt") panic(err)
bootstrap.Start() }
} }
+2 -2
View File
@@ -9,7 +9,7 @@ func Init() {
log.Fatalf("DBInit err: %v", err) log.Fatalf("DBInit err: %v", err)
} }
if err := RedisInit(); err != nil { if err := RuntimeInit(); err != nil {
log.Fatalf("RedisInit err: %v", err) log.Fatalf("RuntimeInit err: %v", err)
} }
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func MysqlInit() error {
// panic(err) // panic(err)
return err return err
} }
if config.AppDebug { if config.SQLDebug {
Mdb = Mdb.Debug() Mdb = Mdb.Debug()
} }
sqlDB, err := Mdb.DB() sqlDB, err := Mdb.DB()
+1 -1
View File
@@ -40,7 +40,7 @@ func PostgreSQLInit() error {
return err return err
} }
if config.AppDebug { if config.SQLDebug {
Mdb = Mdb.Debug() Mdb = Mdb.Debug()
} }
sqlDB, err := Mdb.DB() sqlDB, err := Mdb.DB()
+7 -16
View File
@@ -1,6 +1,7 @@
package dao package dao
import ( import (
"os"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
@@ -16,9 +17,10 @@ import (
// SqliteInit 数据库初始化 // SqliteInit 数据库初始化
func SqliteInit() error { func SqliteInit() error {
var err error var err error
dbFilename := "./conf/.db" dbFilename := config.GetPrimarySqlitePath()
if dbfile := viper.GetString("sqlite_database_filename"); len(dbfile) > 0 { if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil {
dbFilename = filepath.Base(dbfile) color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err)
return err
} }
color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename) color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename)
Mdb, err = openDB(dbFilename, &gorm.Config{ Mdb, err = openDB(dbFilename, &gorm.Config{
@@ -33,22 +35,11 @@ func SqliteInit() error {
// panic(err) // panic(err)
return err return err
} }
if config.AppDebug { if config.SQLDebug {
Mdb = Mdb.Debug() Mdb = Mdb.Debug()
} }
sqlDB, err := Mdb.DB() if _, err = configureSQLite(Mdb, 1); err != nil {
if err != nil {
color.Red.Printf("[store_db] sqlite get DB,err=%s\n", err)
// panic(err)
return err
}
// sqlDB.SetMaxIdleConns(viper.GetInt("sqlite_max_idle_conns"))
// sqlDB.SetMaxOpenConns(viper.GetInt("sqlite_max_open_conns"))
// sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("sqlite_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error()) color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error())
// panic(err)
return err return err
} }
log.Sugar.Debug("[store_db] sqlite connDB success") log.Sugar.Debug("[store_db] sqlite connDB success")
-46
View File
@@ -1,46 +0,0 @@
package dao
import (
"context"
"fmt"
"time"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"github.com/gookit/color"
"github.com/spf13/viper"
)
var Rdb *redis.Client
func RedisInit() error {
options := redis.Options{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")), // Redis地址
DB: viper.GetInt("redis_db"), // Redis库
PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小
MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数
IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间
}
if viper.GetString("redis_passwd") != "" {
options.Password = viper.GetString("redis_passwd")
}
Rdb = redis.NewClient(&options)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pong, err := Rdb.Ping(ctx).Result()
if err == redis.Nil {
log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.")
} else if err != nil {
color.Red.Printf("[store_redis] redis connRdb err,err=%s", err)
// panic(err)
// time.Sleep(10 * time.Second)
// RedisInit()
return err
} else {
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,35 @@
package dao
import (
"database/sql"
"gorm.io/gorm"
)
func configureSQLite(db *gorm.DB, maxOpenConns int) (*sql.DB, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
if maxOpenConns <= 0 {
maxOpenConns = 1
}
sqlDB.SetMaxOpenConns(maxOpenConns)
sqlDB.SetMaxIdleConns(1)
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
return nil, err
}
if err := sqlDB.Ping(); err != nil {
return nil, err
}
return sqlDB, nil
}
+130 -59
View File
@@ -1,113 +1,184 @@
package data package data
import ( import (
"context" "errors"
"fmt"
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/log" "github.com/dromara/carbon/v2"
"github.com/go-redis/redis/v8" "github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var ( var ErrTransactionLocked = errors.New("transaction amount is already locked")
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%s_%v" // 钱包_币种_待支付金额 : 交易号
)
// GetOrderInfoByOrderId 通过客户订单号查询订单 type PendingCallbackOrder struct {
TradeId string `gorm:"column:trade_id"`
CallbackNum int `gorm:"column:callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm"`
UpdatedAt carbon.Time `gorm:"column:updated_at"`
}
func normalizeLockAmount(amount float64) (int64, string) {
value := decimal.NewFromFloat(amount).Round(2)
return value.Shift(2).IntPart(), value.StringFixed(2)
}
func normalizeLockToken(token string) string {
return strings.ToUpper(strings.TrimSpace(token))
}
// GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err return order, err
} }
// GetOrderInfoByTradeId 通过交易号查询订单 // GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err return order, err
} }
// CreateOrderWithTransaction 事务创建订单 // CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error return tx.Model(order).Create(order).Error
return err
} }
// GetOrderByBlockIdWithTransaction 通过区块获取订单 // GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
return order, err return order, err
} }
// OrderSuccessWithTransaction 事务支付成功 // OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error { func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{ result := tx.Model(&mdb.Orders{}).
"block_transaction_id": req.BlockTransactionId, Where("trade_id = ?", req.TradeId).
"status": mdb.StatusPaySuccess, Where("status = ?", mdb.StatusWaitPay).
"callback_confirm": mdb.CallBackConfirmNo, Updates(map[string]interface{}{
}).Error "block_transaction_id": req.BlockTransactionId,
return err "status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
})
return result.RowsAffected > 0, result.Error
} }
// GetPendingCallbackOrders 查询出等待回调的订单 // GetPendingCallbackOrders returns the minimal callback scheduling state.
func GetPendingCallbackOrders() ([]mdb.Orders, error) { func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
var orders []mdb.Orders var orders []PendingCallbackOrder
err := dao.Mdb.Model(orders). query := dao.Mdb.Model(&mdb.Orders{}).
Where("callback_num < ?", 5). Select("trade_id", "callback_num", "callback_confirm", "updated_at").
Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess). Where("status = ?", mdb.StatusPaySuccess).
Find(&orders).Error Order("updated_at asc")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&orders).Error
return orders, err return orders, err
} }
// SaveCallBackOrdersResp 保存订单回调结果 // SaveCallBackOrdersResp persists a callback attempt result.
func SaveCallBackOrdersResp(order *mdb.Orders) error { func SaveCallBackOrdersResp(order *mdb.Orders) error {
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{ return dao.Mdb.Model(order).
"callback_num": gorm.Expr("callback_num + ?", 1), Where("id = ?", order.ID).
"callback_confirm": order.CallBackConfirm, Where("callback_confirm = ?", mdb.CallBackConfirmNo).
}).Error Updates(map[string]interface{}{
return err "callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
} }
// UpdateOrderIsExpirationById 通过id设置订单过期 // UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
func UpdateOrderIsExpirationById(id uint64) error { func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error result := dao.Mdb.Model(mdb.Orders{}).
return err Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
} }
// GetTradeIdByWalletAddressAndAmountAndToken 通过钱包地址、币种、支付金额获取交易号 // GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) { func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
ctx := context.Background() scaledAmount, _ := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount) var lock mdb.TransactionLock
result, err := dao.Rdb.Get(ctx, cacheKey).Result() err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
if err == redis.Nil { Where("address = ?", address).
return "", nil Where("token = ?", normalizeLockToken(token)).
} Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()).
Limit(1).
Find(&lock).Error
if err != nil { if err != nil {
return "", err return "", err
} }
return result, nil if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
} }
// LockTransaction 锁定交易 // LockTransaction reserves an address+token+amount pair in sqlite until expiration.
func LockTransaction(address, token, tradeId string, amount float64, expirationTime time.Duration) error { func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
ctx := context.Background() scaledAmount, amountText := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount) normalizedToken := normalizeLockToken(token)
log.Sugar.Infof("LockTransaction: cacheKey=%s, tradeId=%s, expirationTime=%v", cacheKey, tradeId, expirationTime) now := time.Now()
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err() lock := &mdb.TransactionLock{
return err 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
})
} }
// UnLockTransaction 解锁交易 // UnLockTransaction releases the reservation for address+token+amount.
func UnLockTransaction(address string, token string, amount float64) error { func UnLockTransaction(address string, token string, amount float64) error {
ctx := context.Background() scaledAmount, _ := normalizeLockAmount(amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount) return dao.RuntimeDB.
err := dao.Rdb.Del(ctx, cacheKey).Err() Where("address = ?", address).
return err 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
} }
+13 -14
View File
@@ -9,23 +9,22 @@ const (
) )
type Orders struct { type Orders struct {
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号 TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` // 区块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"` // 订单金额,保留4位小数 Amount float64 `gorm:"column:amount" json:"amount"`
Currency string `gorm:"column:currency" json:"currency"` // 订单货币类型 CNY USD...... Currency string `gorm:"column:currency" json:"currency"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"` // 所属钱包地址 ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
Token string `gorm:"column:token" json:"token"` // 所属币种 TRX USDT...... Token string `gorm:"column:token" json:"token"`
Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期 Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数 CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否 CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
BaseModel BaseModel
} }
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string { func (o *Orders) TableName() string {
return "orders" return "orders"
} }
+19
View File
@@ -0,0 +1,19 @@
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"
}
+2 -4
View File
@@ -5,14 +5,12 @@ const (
TokenStatusDisable = 2 TokenStatusDisable = 2
) )
// WalletAddress 钱包表
type WalletAddress struct { type WalletAddress struct {
Address string `gorm:"index:wallet_address_index;column:address" json:"address"` // 钱包地址 Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"`
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用 Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel BaseModel
} }
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string { func (w *WalletAddress) TableName() string {
return "wallet_address" return "wallet_address"
} }
+70 -66
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
@@ -13,46 +14,45 @@ import (
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math" "github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
const ( const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额 CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额 UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额 UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100 // 最大递增次数 IncrementalMaximumNumber = 100
) )
var gCreateTransactionLock sync.Mutex var gCreateTransactionLock sync.Mutex
var gOrderProcessingLock sync.Mutex
// CreateTransaction 创建订单 // CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
payAmount := math.MustParsePrecFloat64(req.Amount, 2) payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化接收货币 rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
// coin if rate <= 0 {
coin := strings.ToLower(req.Token) return nil, constant.RateAmountErr
currency := strings.ToLower(req.Currency) }
coinRateBaseCurrency := config.GetRateForCoin(coin, currency)
decimalPayAmount := decimal.NewFromFloat(payAmount) decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(coinRateBaseCurrency) decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
decimalUsdt := decimalPayAmount.Mul(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// Usdt是否可以满足最低支付金额 if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId) exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -60,7 +60,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 { if exist.ID > 0 {
return nil, constant.OrderAlreadyExists return nil, constant.OrderAlreadyExists
} }
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress() walletAddress, err := data.GetAvailableWalletAddress()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -68,45 +68,42 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if len(walletAddress) <= 0 { if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress return nil, constant.NotAvailableWalletAddress
} }
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableAddress, availableAmount, err := CalculateAvailableWalletAndAmount(amount, req.Token, walletAddress) tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if availableAddress == "" { if availableAddress == "" {
return nil, constant.NotAvailableAmountErr return nil, constant.NotAvailableAmountErr
} }
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: GenerateCode(), TradeId: tradeID,
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: req.Amount,
Currency: req.Currency, Currency: currency,
ActualAmount: availableAmount, ActualAmount: availableAmount,
ReceiveAddress: availableAddress, ReceiveAddress: availableAddress,
Token: req.Token, Token: token,
Status: mdb.StatusWaitPay, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl, NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
} }
err = data.CreateOrderWithTransaction(tx, order) if err = data.CreateOrderWithTransaction(tx, order); err != nil {
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
// 锁定支付池 if err = tx.Commit().Error; err != nil {
err = data.LockTransaction(availableAddress, req.Token, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
tx.Commit()
// 超时过期消息队列 expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{ resp := &response.CreateTransactionResponse{
TradeId: order.TradeId, TradeId: order.TradeId,
OrderId: order.OrderId, OrderId: order.OrderId,
@@ -115,85 +112,92 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
ActualAmount: order.ActualAmount, ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress, ReceiveAddress: order.ReceiveAddress,
Token: order.Token, Token: order.Token,
ExpirationTime: ExpirationTime, ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
} }
return resp, nil return resp, nil
} }
// OrderProcessing 成功处理订单 // OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error { func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId) exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil { if err != nil {
tx.Rollback()
return err return err
} }
if exist.ID > 0 { if exist.ID > 0 {
tx.Rollback() tx.Rollback()
return constant.OrderBlockAlreadyProcess return constant.OrderBlockAlreadyProcess
} }
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req) updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
// 解锁交易 if !updated {
err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount) tx.Rollback()
if err != nil { return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
tx.Commit()
if err = data.UnLockTransaction(req.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)
}
return nil return nil
} }
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 // ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
func CalculateAvailableWalletAndAmount(amount float64, token string, walletAddress []mdb.WalletAddress) (string, float64, error) { func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := "" availableAddress := ""
availableAmount := amount availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := "" tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, addr := range walletAddress { for _, address := range walletAddress {
walletAddr := addr.Address err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
result, err := data.GetTradeIdByWalletAddressAndAmountAndToken(walletAddr, token, amount) if err == nil {
if err != nil { return address.Address, nil
return "", err
} }
if result == "" { if errors.Is(err, data.ErrTransactionLocked) {
availableWallet = walletAddr continue
break
} }
return "", err
} }
return availableWallet, nil return "", nil
} }
for i := 0; i < IncrementalMaximumNumber; i++ { for i := 0; i < IncrementalMaximumNumber; i++ {
wallet, err := calculateAvailableWalletFunc(availableAmount) address, err := tryLockWalletFunc(availableAmount)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
// 拿不到可用钱包就累加金额 if address == "" {
if wallet == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue continue
} }
availableAddress = wallet availableAddress = address
break break
} }
return availableAddress, availableAmount, nil return availableAddress, availableAmount, nil
} }
// GenerateCode 订单号生成 // GenerateCode creates a unique trade id.
func GenerateCode() string { func GenerateCode() string {
date := time.Now().Format("20060102") date := time.Now().Format("20060102")
r := rand.Intn(1000) r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
} }
// GetOrderInfoByTradeId 通过交易号获取订单 // GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId) order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
+300
View File
@@ -0,0 +1,300 @@
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 -2
View File
@@ -9,15 +9,16 @@ import (
"github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/response"
) )
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单 // GetCheckoutCounterByTradeId returns checkout info for a pending order.
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) { func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId) orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, errors.New("不存在待支付订单或已过期") return nil, errors.New("不存在待支付订单或已过期")
} }
resp := &response.CheckoutCounterResponse{ resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId, TradeId: orderInfo.TradeId,
ActualAmount: orderInfo.ActualAmount, ActualAmount: orderInfo.ActualAmount,
+96 -162
View File
@@ -2,34 +2,30 @@ package service
import ( import (
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
tron "github.com/assimon/luuu/crypto"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
"github.com/assimon/luuu/config" "github.com/assimon/luuu/config"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "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/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client" "github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math" "github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/gookit/goutil/stdutil" "github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
) )
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
// Trc20CallBack trc20回调
func Trc20CallBack(address string, wg *sync.WaitGroup) { func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
@@ -45,7 +41,6 @@ func Trc20CallBack(address string, wg *sync.WaitGroup) {
innerWg.Wait() innerWg.Wait()
} }
// checkTrxTransfers 查询 TRX 原生转账
func checkTrxTransfers(address string, wg *sync.WaitGroup) { func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
@@ -57,14 +52,11 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli() endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address) url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
log.Sugar.Debugf("checkTrxTransfers URL: %s, from %d to %d", url, startTime, endTime)
resp, err := client.R().SetQueryParams(map[string]string{ resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc", "order_by": "block_timestamp,desc",
"limit": "100", "limit": "100",
// "only_confirmed": "true",
"only_to": "true", "only_to": "true",
"min_timestamp": stdutil.ToString(startTime), "min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime), "max_timestamp": stdutil.ToString(endTime),
@@ -76,131 +68,90 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode())) panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
} }
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
success := gjson.GetBytes(resp.Body(), "success").Bool() success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success { if !success {
panic("TRX API response indicates failure") panic("TRX API response indicates failure")
} }
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
log.Sugar.Infof("[TRX][%s] API返回 %d 条交易记录", address, len(dataArray)) transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(dataArray) == 0 { if len(transfers) == 0 {
log.Sugar.Infof("[TRX][%s] 没有找到任何交易记录,跳过", address) log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
return return
} }
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range dataArray { for i, transfer := range transfers {
transferType := transfer.Get("raw_data.contract.0.type").String() if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
if transferType != "TransferContract" {
log.Sugar.Debugf("[TRX][%s] 第%d条: 类型=%s, 非TransferContract, 跳过", address, i, transferType)
continue continue
} }
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
contractRet := transfer.Get("ret.0.contractRet").String()
if contractRet != "SUCCESS" {
log.Sugar.Infof("[TRX][%s] 第%d条: contractRet=%s, 非SUCCESS, 跳过", address, i, contractRet)
continue continue
} }
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String() toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex) toBytes, err := hex.DecodeString(toAddressHex)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] 第%d条: 解码地址失败: %v", address, i, err) log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue continue
} }
toAddress := tron.EncodeCheck(toBytes) if tron.EncodeCheck(toBytes) != address {
if toAddress != address {
log.Sugar.Debugf("[TRX][%s] 第%d条: 目标地址=%s, 不匹配, 跳过", address, i, toAddress)
continue continue
} }
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String() rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount) decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] 第%d条: 解析金额失败: %v", address, i, err) log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue continue
} }
divisor := decimal.NewFromInt(1000000) amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
txID := transfer.Get("txID").String()
log.Sugar.Infof("[TRX][%s] 第%d条: txID=%s, rawAmount=%s, 解析金额=%.2f", address, i, txID, rawAmount, amount)
if amount <= 0 { if amount <= 0 {
log.Sugar.Infof("[TRX][%s] 第%d条: 金额<=0, 跳过", address, i)
continue continue
} }
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "TRX", amount) txID := transfer.Get("txID").String()
log.Sugar.Infof("[TRX][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey) tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if tradeId == "" { if tradeID == "" {
log.Sugar.Infof("[TRX][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount) log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue continue
} }
log.Sugar.Infof("[TRX][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount) log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeId)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { if err != nil {
panic(err) panic(err)
} }
log.Sugar.Infof("[TRX][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
createTime := order.CreatedAt.TimestampMilli()
blockTimestamp := transfer.Get("block_timestamp").Int() blockTimestamp := transfer.Get("block_timestamp").Int()
log.Sugar.Infof("[TRX][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime) createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime { if blockTimestamp < createTime {
log.Sugar.Errorf("[TRX][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime) log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
panic("Orders cannot actually be matched") continue
} }
transferHash := transfer.Get("txID").String()
log.Sugar.Infof("[TRX][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: address, ReceiveAddress: address,
Token: "TRX", Token: "TRX",
TradeId: tradeId, TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: transferHash, BlockTransactionId: txID,
} }
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err) if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err) panic(err)
} }
log.Sugar.Infof("[TRX][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) sendPaymentNotification(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry") log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
log.Sugar.Infof("[TRX][%s] 回调队列已入队: tradeId=%s", address, tradeId)
msgTpl := `
🎉 <b>收款成功通知</b>
💰 <b>金额信息</b>
├ 订单金额:<code>%.2f %s</code>
└ 实际到账:<code>%.2f %s</code>
📋 <b>订单信息</b>
├ 交易号:<code>%s</code>
├ 订单号:<code>%s</code>
└ 钱包地址:<code>%s</code>
⏰ <b>时间信息</b>
├ 创建时间:%s
└ 支付时间:%s
`
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
log.Sugar.Infof("[TRX][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
telegram.SendToBot(msg)
} }
} }
// checkTrc20Transfers 查询 TRC20 (USDT) 转账
func checkTrc20Transfers(address string, wg *sync.WaitGroup) { func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
@@ -212,14 +163,11 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
client := http_client.GetHttpClient() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli() endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address) url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
log.Sugar.Debugf("checkTrc20Transfers URL: %s, from %d to %d", url, startTime, endTime)
resp, err := client.R().SetQueryParams(map[string]string{ resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc", "order_by": "block_timestamp,desc",
"limit": "100", "limit": "100",
// "only_confirmed": "true",
"only_to": "true", "only_to": "true",
"min_timestamp": stdutil.ToString(startTime), "min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime), "max_timestamp": stdutil.ToString(endTime),
@@ -231,117 +179,103 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode())) panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
} }
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
success := gjson.GetBytes(resp.Body(), "success").Bool() success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success { if !success {
panic("TRC20 API response indicates failure") panic("TRC20 API response indicates failure")
} }
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
log.Sugar.Infof("[TRC20][%s] API返回 %d 条交易记录", address, len(dataArray)) transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(dataArray) == 0 { if len(transfers) == 0 {
log.Sugar.Infof("[TRC20][%s] 没有找到任何交易记录,跳过", address) log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
return return
} }
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range dataArray { for i, transfer := range transfers {
// 只处理 USDT if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
tokenAddress := transfer.Get("token_info.address").String() continue
if tokenAddress != TRC20_USDT_ID { }
log.Sugar.Debugf("[TRC20][%s] 第%d条: tokenAddress=%s, 非USDT, 跳过", address, i, tokenAddress) if transfer.Get("to").String() != address {
continue continue
} }
to := transfer.Get("to").String()
if to != address {
log.Sugar.Debugf("[TRC20][%s] 第%d条: to=%s, 不匹配, 跳过", address, i, to)
continue
}
// 解析金额: value / 10^decimals
valueStr := transfer.Get("value").String() valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr) decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRC20][%s] 第%d条: 解析value失败: %v", address, i, err) log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue continue
} }
tokenDecimals := transfer.Get("token_info.decimals").Int() tokenDecimals := transfer.Get("token_info.decimals").Int()
divisor := decimal.New(1, int32(tokenDecimals)) // 10^decimals amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
txID := transfer.Get("transaction_id").String()
log.Sugar.Infof("[TRC20][%s] 第%d条: txID=%s, value=%s, decimals=%d, 解析金额=%.2f", address, i, txID, valueStr, tokenDecimals, amount)
if amount <= 0 { if amount <= 0 {
log.Sugar.Infof("[TRC20][%s] 第%d条: 金额<=0, 跳过", address, i)
continue continue
} }
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "USDT", amount) txID := transfer.Get("transaction_id").String()
log.Sugar.Infof("[TRC20][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey) tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if tradeId == "" { if tradeID == "" {
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount) log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue continue
} }
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount) log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeId)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { if err != nil {
panic(err) panic(err)
} }
log.Sugar.Infof("[TRC20][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
createTime := order.CreatedAt.TimestampMilli()
blockTimestamp := transfer.Get("block_timestamp").Int() blockTimestamp := transfer.Get("block_timestamp").Int()
log.Sugar.Infof("[TRC20][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime) createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime { if blockTimestamp < createTime {
log.Sugar.Errorf("[TRC20][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime) log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
panic("Orders cannot actually be matched") continue
} }
transferHash := transfer.Get("transaction_id").String()
log.Sugar.Infof("[TRC20][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: address, ReceiveAddress: address,
Token: "USDT", Token: "USDT",
TradeId: tradeId, TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: transferHash, BlockTransactionId: txID,
} }
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRC20][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err) 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) panic(err)
} }
log.Sugar.Infof("[TRC20][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) sendPaymentNotification(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry") log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
log.Sugar.Infof("[TRC20][%s] 回调队列已入队: tradeId=%s", address, tradeId)
msgTpl := `
🎉 <b>收款成功通知</b>
💰 <b>金额信息</b>
├ 订单金额:<code>%.2f %s</code>
└ 实际到账:<code>%.2f %s</code>
📋 <b>订单信息</b>
├ 交易号:<code>%s</code>
├ 订单号:<code>%s</code>
└ 钱包地址:<code>%s</code>
⏰ <b>时间信息</b>
├ 创建时间:%s
└ 支付时间:%s
`
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
log.Sugar.Infof("[TRC20][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
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)
}
-71
View File
@@ -1,71 +0,0 @@
package handle
import (
"context"
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/sign"
"github.com/hibiken/asynq"
)
const QueueOrderCallback = "order:callback"
func NewOrderCallbackQueue(order *mdb.Orders) (*asynq.Task, error) {
payload, err := json.Cjson.Marshal(order)
if err != nil {
return nil, err
}
return asynq.NewTask(QueueOrderCallback, payload,
asynq.Retention(config.GetOrderExpirationTimeDuration()),
), nil
}
func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error {
var order mdb.Orders
err := json.Cjson.Unmarshal(t.Payload(), &order)
if err != nil {
return err
}
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
defer func() {
data.SaveCallBackOrdersResp(&order)
}()
client := http_client.GetHttpClient()
orderResp := response.OrderNotifyResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
ActualAmount: order.ActualAmount,
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
}
body := string(resp.Body())
if body != "ok" {
order.CallBackConfirm = mdb.CallBackConfirmNo
return errors.New("not ok")
}
order.CallBackConfirm = mdb.CallBackConfirmOk
return nil
}
-39
View File
@@ -1,39 +0,0 @@
package handle
import (
"context"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/hibiken/asynq"
)
const QueueOrderExpiration = "order:expiration"
func NewOrderExpirationQueue(tradeId string) (*asynq.Task, error) {
return asynq.NewTask(QueueOrderExpiration, []byte(tradeId),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
), nil
}
// OrderExpirationHandle 设置订单过期
func OrderExpirationHandle(ctx context.Context, t *asynq.Task) error {
tradeId := string(t.Payload())
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil
}
err = data.UpdateOrderIsExpirationById(orderInfo.ID)
if err != nil {
return err
}
err = data.UnLockTransaction(orderInfo.ReceiveAddress, orderInfo.Token, orderInfo.ActualAmount)
if err != nil {
return err
}
return nil
}
+13 -43
View File
@@ -1,52 +1,22 @@
package mq package mq
import ( import (
"fmt" "sync"
"github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/hibiken/asynq"
"github.com/spf13/viper"
) )
var MClient *asynq.Client var (
startOnce sync.Once
callbackLimiter chan struct{}
callbackInflight sync.Map
)
func Start() { func Start() {
redis := asynq.RedisClientOpt{ startOnce.Do(func() {
Addr: fmt.Sprintf( callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
"%s:%s", go runOrderExpirationLoop()
viper.GetString("redis_host"), go runOrderCallbackLoop()
viper.GetString("redis_port")), go runTransactionLockCleanupLoop()
DB: viper.GetInt("redis_db"), })
Password: viper.GetString("redis_passwd"),
}
initClient(redis)
go initListen(redis)
}
func initClient(redis asynq.RedisClientOpt) {
MClient = asynq.NewClient(redis)
}
func initListen(redis asynq.RedisClientOpt) {
srv := asynq.NewServer(
redis,
asynq.Config{
// Specify how many concurrent workers to use
Concurrency: viper.GetInt("queue_concurrency"),
// Optionally specify multiple queues with different priority.
Queues: map[string]int{
"critical": viper.GetInt("queue_level_critical"),
"default": viper.GetInt("queue_level_default"),
"low": viper.GetInt("queue_level_low"),
},
Logger: log.Sugar,
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(handle.QueueOrderExpiration, handle.OrderExpirationHandle)
mux.HandleFunc(handle.QueueOrderCallback, handle.OrderCallbackHandle)
if err := srv.Run(mux); err != nil {
log.Sugar.Fatalf("[queue] could not run server: %v", err)
}
} }
+257
View File
@@ -0,0 +1,257 @@
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
@@ -0,0 +1,271 @@
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")
}
+74 -19
View File
@@ -2,6 +2,8 @@ package telegram
import ( import (
"fmt" "fmt"
"sync"
"time"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
@@ -11,25 +13,52 @@ import (
) )
const ( const (
ReplayAddWallet = "请发给我一个合法的钱包地址" ReplayAddWallet = "请发给我一个合法的钱包地址"
pendingWalletAddressTTL = 5 * time.Minute
) )
type pendingWalletAddressState struct {
RequestedAt time.Time
}
var pendingWalletAddressUsers sync.Map
func OnTextMessageHandle(c tb.Context) error { func OnTextMessageHandle(c tb.Context) error {
if c.Message().ReplyTo.Text == ReplayAddWallet { msg := c.Message()
defer bots.Delete(c.Message().ReplyTo) if msg == nil {
msgText := c.Message().Text return nil
if !isValidTronAddress(msgText) {
_ = c.Send(fmt.Sprintf("钱包[%s]添加失败: 非Tron地址!", msgText))
return nil
}
_, err := data.AddWalletAddress(msgText)
if err != nil {
return c.Send(err.Error())
}
_ = c.Send(fmt.Sprintf("钱包[%s]添加成功!", msgText))
return WalletList(c)
} }
return nil
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)
} }
func WalletList(c tb.Context) error { func WalletList(c tb.Context) error {
@@ -37,29 +66,35 @@ func WalletList(c tb.Context) error {
if err != nil { if err != nil {
return err return err
} }
var btnList [][]tb.InlineButton var btnList [][]tb.InlineButton
for _, wallet := range wallets { for _, wallet := range wallets {
status := "已启用✅" status := "已启用✅"
if wallet.Status == mdb.TokenStatusDisable { if wallet.Status == mdb.TokenStatusDisable {
status = "已禁用🚫" status = "已禁用🚫"
} }
var temp []tb.InlineButton
btnInfo := tb.InlineButton{ btnInfo := tb.InlineButton{
Unique: wallet.Address, Unique: wallet.Address,
Text: fmt.Sprintf("%s[%s]", wallet.Address, status), Text: fmt.Sprintf("%s [%s]", wallet.Address, status),
Data: strutil.MustString(wallet.ID), Data: strutil.MustString(wallet.ID),
} }
bots.Handle(&btnInfo, WalletInfo) bots.Handle(&btnInfo, WalletInfo)
btnList = append(btnList, append(temp, btnInfo)) btnList = append(btnList, []tb.InlineButton{btnInfo})
} }
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"} addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error { bots.Handle(&addBtn, func(c tb.Context) error {
if sender := c.Sender(); sender != nil {
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
}
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{ return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
ForceReply: true, ForceReply: true,
}) })
}) })
btnList = append(btnList, []tb.InlineButton{addBtn}) btnList = append(btnList, []tb.InlineButton{addBtn})
return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
InlineKeyboard: btnList, InlineKeyboard: btnList,
}) })
} }
@@ -70,6 +105,7 @@ func WalletInfo(c tb.Context) error {
if err != nil { if err != nil {
return c.Send(err.Error()) return c.Send(err.Error())
} }
enableBtn := tb.InlineButton{ enableBtn := tb.InlineButton{
Text: "启用", Text: "启用",
Unique: "enableBtn", Unique: "enableBtn",
@@ -89,10 +125,12 @@ func WalletInfo(c tb.Context) error {
Text: "返回", Text: "返回",
Unique: "WalletList", Unique: "WalletList",
} }
bots.Handle(&enableBtn, EnableWallet) bots.Handle(&enableBtn, EnableWallet)
bots.Handle(&disableBtn, DisableWallet) bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet) bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList) bots.Handle(&backBtn, WalletList)
return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{ return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
{ {
enableBtn, enableBtn,
@@ -140,3 +178,20 @@ func DelWallet(c tb.Context) error {
} }
return WalletList(c) return WalletList(c)
} }
func isWalletAddressPending(userID int64) bool {
if userID <= 0 {
return false
}
value, ok := pendingWalletAddressUsers.Load(userID)
if !ok {
return false
}
state, ok := value.(pendingWalletAddressState)
if !ok || time.Since(state.RequestedAt) > pendingWalletAddressTTL {
pendingWalletAddressUsers.Delete(userID)
return false
}
return true
}
+13 -11
View File
@@ -1,17 +1,18 @@
package constant package constant
var Errno = map[int]string{ var Errno = map[int]string{
400: "系统错误", 400: "system error",
401: "签名认证错误", 401: "signature verification failed",
10001: "钱包地址已存在,请勿重复添加", 10001: "wallet address already exists",
10002: "支付交易已存在,请勿重复创建", 10002: "order already exists",
10003: "无可用钱包地址,无法发起支付", 10003: "no available wallet address",
10004: "支付金额有误, 无法满足最小支付单位", 10004: "invalid payment amount",
10005: "无可用金额通道", 10005: "no available amount channel",
10006: "汇率计算错误", 10006: "rate calculation failed",
10007: "订单区块已处理", 10007: "block transaction already processed",
10008: "订单不存在", 10008: "order does not exist",
10009: "无法解析请求参数", 10009: "failed to parse request params",
10010: "order status already changed",
} }
var ( var (
@@ -26,6 +27,7 @@ var (
OrderBlockAlreadyProcess = Err(10007) OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008) OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009) ParamsMarshalErr = Err(10009)
OrderStatusConflict = Err(10010)
) )
type RspError struct { type RspError struct {
+3 -2
View File
@@ -1,8 +1,9 @@
package http_client package http_client
import ( import (
"github.com/go-resty/resty/v2"
"time" "time"
"github.com/go-resty/resty/v2"
) )
// GetHttpClient 获取请求客户端 // GetHttpClient 获取请求客户端
@@ -13,6 +14,6 @@ func GetHttpClient(proxys ...string) *resty.Client {
proxy := proxys[0] proxy := proxys[0]
client.SetProxy(proxy) client.SetProxy(proxy)
} }
client.SetTimeout(time.Second * 5) client.SetTimeout(time.Second * 10)
return client return client
} }
+32 -5
View File
@@ -7,27 +7,37 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"os"
"time" "time"
) )
var Sugar *zap.SugaredLogger var Sugar *zap.SugaredLogger
func Init() { func Init() {
writeSyncer := getLogWriter() level := getLogLevel()
encoder := getEncoder() core := zapcore.NewTee(
core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
)
logger := zap.New(core, zap.AddCaller()) logger := zap.New(core, zap.AddCaller())
Sugar = logger.Sugar() Sugar = logger.Sugar()
} }
func getEncoder() zapcore.Encoder { func getFileEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig() encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig) return zapcore.NewJSONEncoder(encoderConfig)
} }
func getLogWriter() zapcore.WriteSyncer { func getConsoleEncoder() zapcore.Encoder {
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return zapcore.NewConsoleEncoder(encoderConfig)
}
func getFileWriter() zapcore.WriteSyncer {
file := fmt.Sprintf("%s/log_%s.log", file := fmt.Sprintf("%s/log_%s.log",
config.LogSavePath, config.LogSavePath,
time.Now().Format("20060102")) time.Now().Format("20060102"))
@@ -40,3 +50,20 @@ func getLogWriter() zapcore.WriteSyncer {
} }
return zapcore.AddSync(lumberJackLogger) return zapcore.AddSync(lumberJackLogger)
} }
func getConsoleWriter() zapcore.WriteSyncer {
return zapcore.Lock(os.Stdout)
}
func getLogLevel() zapcore.Level {
switch config.LogLevel {
case "debug":
return zapcore.DebugLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}