diff --git a/.github/workflows/docker-alpine.yml b/.github/workflows/docker-alpine.yml index 13fa017..8616180 100644 --- a/.github/workflows/docker-alpine.yml +++ b/.github/workflows/docker-alpine.yml @@ -1,28 +1,46 @@ name: docker-alpine-epusdt -on: - workflow_dispatch: #github页面手动触发 - push: - branches: [ "main" ] -env: - IMAGE_NAME: epusdt #这是您的镜像名 -jobs: - push-docker-hub: +on: + workflow_dispatch: + push: + branches: + - "main" + +env: + IMAGE_NAME: epusdt + +jobs: + push-docker-hub: runs-on: ubuntu-latest env: TZ: Asia/Shanghai + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + + - name: Compute Build Metadata + id: meta + shell: bash + run: | + echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build && Push - uses: docker/build-push-action@v4 + + - name: Build And Push + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true - tags: | + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ steps.meta.outputs.commit }} + DATE=${{ steps.meta.outputs.date }} + tags: | ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c09aeb6 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 88dddcc..cb03dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .idea/ src/runtime/ src/.env +src/*.db +src/*.db-shm +src/*.db-wal .DS_Store -.env \ No newline at end of file +.env diff --git a/DEVELOPMENT_STANDARD.md b/DEVELOPMENT_STANDARD.md new file mode 100644 index 0000000..553001f --- /dev/null +++ b/DEVELOPMENT_STANDARD.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 0b4fa96..eca1c12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,13 +2,15 @@ FROM golang:alpine AS builder RUN apk add --no-cache --update git build-base ENV CGO_ENABLED=1 +ARG VERSION=0.0.0-dev +ARG COMMIT=none +ARG DATE=unknown WORKDIR /app COPY ./src/go.mod ./src/go.sum ./ RUN go mod download COPY ./src . -RUN go mod tidy \ - && go build -ldflags "-s -w" \ +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}" \ -o epusdt . FROM alpine:latest AS runner diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..64cf7f8 --- /dev/null +++ b/RELEASING.md @@ -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/`. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..8f066fc --- /dev/null +++ b/REVIEW.md @@ -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. diff --git a/src/.env.example b/src/.env.example index 2748862..dbaf8fa 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,80 +1,63 @@ app_name=epusdt -#下面配置你的域名,收银台会需要 app_uri=https://dujiaoka.com -#是否开启debug,默认false -app_debug=false -#http服务监听端口 +log_level=info +http_access_log=false +sql_debug=false http_listen=:8000 -#静态资源文件目录 static_path=/static -#缓存路径 runtime_root_path=/runtime -#日志配置 log_save_path=/logs log_max_size=32 log_max_age=7 max_backups=3 -#数据库类型,默认为mysql类型,可配置项: postgres,mysql,sqlite +# supported values: postgres,mysql,sqlite db_type=sqlite -# sqlite配置 +# sqlite primary database config sqlite_database_filename= sqlite_table_prefix= -# postgres配置 +# postgres config postgres_host=127.0.0.1 postgres_port=3306 -postgres_user=mysql账号 -postgres_passwd=mysql密码 -postgres_database=数据库 +postgres_user=mysql_user +postgres_passwd=mysql_password +postgres_database=database_name postgres_table_prefix= postgres_max_idle_conns=10 postgres_max_open_conns=100 postgres_max_life_time=6 -# mysql配置 +# mysql config mysql_host=127.0.0.1 mysql_port=3306 -mysql_user=mysql账号 -mysql_passwd=mysql密码 -mysql_database=数据库 +mysql_user=mysql_user +mysql_passwd=mysql_password +mysql_database=database_name mysql_table_prefix= mysql_max_idle_conns=10 mysql_max_open_conns=100 mysql_max_life_time=6 +# sqlite runtime store config +runtime_sqlite_filename=epusdt-runtime.db -# redis配置 -redis_host=127.0.0.1 -redis_port=6379 -redis_passwd= -redis_db=5 -redis_pool_size=5 -redis_max_retries=3 -redis_idle_timeout=1000 - -# 消息队列配置 +# background scheduler config queue_concurrency=10 -queue_level_critical=6 -queue_level_default=3 -queue_level_low=1 +queue_poll_interval_ms=1000 +callback_retry_base_seconds=5 -#机器人Apitoken tg_bot_token= -#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写 tg_proxy= -#管理员userid tg_manage= -#api接口认证token api_auth_token= -#订单过期时间(单位分钟) order_expiration_time=10 -#订单回调失败最大重试次数 order_notice_max_retry=0 -#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4) -forced_usdt_rate= \ No newline at end of file +forced_usdt_rate= +api_rate_url= +tron_grid_api_key= diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index 205f477..8ca3083 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -1,44 +1,72 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com +version: 2 + project_name: epusdt + before: hooks: - # You may remove this if you don't use go modules. - #- go mod tidy - # you may remove this if you don't need go generate - #- go generate ./... + - go test ./... + builds: - - env: + - id: epusdt + main: . + binary: epusdt + env: - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }} goos: - - darwin - linux + - darwin - windows goarch: - amd64 - - arm - - 386 + - arm64 + archives: - - replacements: - darwin: Darwin - linux: Linux - windows: Windows - 386: i386 - amd64: x86_64 - id: epusdt - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" - format: tar.gz + - id: epusdt + ids: + - epusdt + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - .env.example - - static/* - - runtime/* + - static/** + format_overrides: + - goos: windows + formats: + - zip + checksum: - name_template: 'checksums.txt' + name_template: "{{ .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: name_template: "{{ incpatch .Version }}-next" + +release: + github: + owner: GMwalletApp + name: epusdt + changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" diff --git a/src/bootstrap/bootstrap.go b/src/bootstrap/bootstrap.go index 2c8fae0..5452ee3 100644 --- a/src/bootstrap/bootstrap.go +++ b/src/bootstrap/bootstrap.go @@ -1,7 +1,8 @@ package bootstrap import ( - "github.com/assimon/luuu/command" + "sync" + "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/mq" @@ -10,26 +11,15 @@ import ( "github.com/assimon/luuu/util/log" ) -// Start 服务启动 -func Start() { - // 配置加载 - config.Init() - // 日志加载 - log.Init() - // // Mysql启动 - // dao.MysqlInit() - // dao.MdbTableInit() - // // redis启动 - // dao.RedisInit() - dao.Init() - // 队列启动 - mq.Start() - // telegram机器人启动 - go telegram.BotStart() - // 定时任务 - go task.Start() - err := command.Execute() - if err != nil { - panic(err) - } +var initOnce sync.Once + +func InitApp() { + initOnce.Do(func() { + config.Init() + log.Init() + dao.Init() + mq.Start() + go telegram.BotStart() + go task.Start() + }) } diff --git a/src/command/banner.go b/src/command/banner.go new file mode 100644 index 0000000..bbbf8d7 --- /dev/null +++ b/src/command/banner.go @@ -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", + ) +} diff --git a/src/command/http_server.go b/src/command/http_server.go index f9e4dae..411b06e 100644 --- a/src/command/http_server.go +++ b/src/command/http_server.go @@ -2,6 +2,12 @@ package command import ( "context" + "net/http" + "os" + "os/signal" + "time" + + "github.com/assimon/luuu/bootstrap" "github.com/assimon/luuu/config" "github.com/assimon/luuu/middleware" "github.com/assimon/luuu/route" @@ -12,16 +18,12 @@ import ( echoMiddleware "github.com/labstack/echo/v4/middleware" "github.com/spf13/cobra" "github.com/spf13/viper" - "net/http" - "os" - "os/signal" - "time" ) var httpCmd = &cobra.Command{ Use: "http", - Short: "http服务", - Long: "http服务相关命令", + Short: "http service", + Long: "http service commands", Run: func(cmd *cobra.Command, args []string) { }, } @@ -32,9 +34,11 @@ func init() { var startCmd = &cobra.Command{ Use: "start", - Short: "启动", - Long: "启动http服务", + Short: "start", + Long: "start http service", Run: func(cmd *cobra.Command, args []string) { + bootstrap.InitApp() + printBanner() HttpServerStart() }, } @@ -44,23 +48,22 @@ func HttpServerStart() { e := echo.New() e.HideBanner = true e.HTTPErrorHandler = customHTTPErrorHandler - // 中间件注册 + MiddlewareRegister(e) - // 路由注册 route.RegisterRoute(e) - // 静态目录注册 - e.Static(config.StaticPath, "static") + e.Static(config.StaticPath, config.StaticFilePath) + httpListen := viper.GetString("http_listen") go func() { if err = e.Start(httpListen); err != http.ErrServerClosed { log.Sugar.Error(err) } }() - // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. - // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, os.Kill) <-quit + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err = e.Shutdown(ctx); err != nil { @@ -68,16 +71,13 @@ func HttpServerStart() { } } -// MiddlewareRegister 中间件注册 func MiddlewareRegister(e *echo.Echo) { - if config.AppDebug { - e.Debug = true + if config.HTTPAccessLog { e.Use(echoMiddleware.Logger()) } e.Use(middleware.RequestUUID()) } -// customHTTPErrorHandler 默认消息提示 func customHTTPErrorHandler(err error, e echo.Context) { code := http.StatusInternalServerError msg := "server error" @@ -95,5 +95,4 @@ func customHTTPErrorHandler(err error, e echo.Context) { resp.Message = he.Msg } _ = e.JSON(http.StatusOK, resp) - return } diff --git a/src/command/root.go b/src/command/root.go index 7d73bfa..3502187 100644 --- a/src/command/root.go +++ b/src/command/root.go @@ -1,6 +1,11 @@ package command -import "github.com/spf13/cobra" +import ( + "github.com/assimon/luuu/config" + "github.com/spf13/cobra" +) + +var configPath string var rootCmd = &cobra.Command{} @@ -9,5 +14,9 @@ func Execute() error { } func init() { + rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env") + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + config.SetConfigPath(configPath) + } rootCmd.AddCommand(httpCmd) } diff --git a/src/command/version.go b/src/command/version.go new file mode 100644 index 0000000..6394222 --- /dev/null +++ b/src/command/version.go @@ -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) +} diff --git a/src/config/config.go b/src/config/config.go index 0708260..fb221d1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -1,61 +1,169 @@ package config import ( + "errors" "fmt" - "github.com/spf13/viper" + "log" "net/url" "os" + "path/filepath" + "strings" "time" + + "github.com/assimon/luuu/util/http_client" + "github.com/spf13/viper" + "github.com/tidwall/gjson" ) var ( - AppDebug bool - MysqlDns string - RuntimePath string - LogSavePath string - StaticPath string - TgBotToken string - TgProxy string - TgManage int64 - UsdtRate float64 + HTTPAccessLog bool + SQLDebug bool + LogLevel string + MysqlDns string + RuntimePath string + LogSavePath string + StaticPath string + StaticFilePath string + TgBotToken string + TgProxy string + TgManage int64 + UsdtRate float64 + RateApiUrl string + TRON_GRID_API_KEY string + BuildVersion = "0.0.0-dev" + BuildCommit = "none" + BuildDate = "unknown" + configRootPath string + explicitConfigPath string ) +func SetConfigPath(path string) { + explicitConfigPath = strings.TrimSpace(path) +} + func Init() { - viper.AddConfigPath("./") - viper.SetConfigFile(".env") - err := viper.ReadInConfig() + configPath, err := resolveConfigFilePath() if err != nil { panic(err) } - gwd, err := os.Getwd() + configRootPath = filepath.Dir(configPath) + + viper.SetConfigFile(configPath) + err = viper.ReadInConfig() if err != nil { panic(err) } - AppDebug = viper.GetBool("app_debug") - StaticPath = viper.GetString("static_path") - RuntimePath = fmt.Sprintf( - "%s%s", - gwd, - viper.GetString("runtime_root_path")) - LogSavePath = fmt.Sprintf( - "%s%s", - RuntimePath, - viper.GetString("log_save_path")) + HTTPAccessLog = viper.GetBool("http_access_log") + SQLDebug = viper.GetBool("sql_debug") + LogLevel = normalizeLogLevel(viper.GetString("log_level")) + StaticPath = normalizeStaticURLPath(viper.GetString("static_path")) + StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/")) + RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime")) + LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs")) + mustMkdir(RuntimePath) + mustMkdir(LogSavePath) MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", url.QueryEscape(viper.GetString("mysql_user")), url.QueryEscape(viper.GetString("mysql_passwd")), - fmt.Sprintf( - "%s:%s", - viper.GetString("mysql_host"), - viper.GetString("mysql_port")), + fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")), viper.GetString("mysql_database")) TgBotToken = viper.GetString("tg_bot_token") TgProxy = viper.GetString("tg_proxy") TgManage = viper.GetInt64("tg_manage") + + RateApiUrl = GetRateApiUrl() + TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key") +} + +func mustMkdir(path string) { + if err := os.MkdirAll(path, 0o755); err != nil { + panic(err) + } +} + +func normalizeLogLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug", "info", "warn", "error": + return strings.ToLower(strings.TrimSpace(level)) + default: + return "info" + } +} + +func normalizeStaticURLPath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return "/static" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func resolvePathFromBase(basePath string, path string, fallback string) string { + path = strings.TrimSpace(path) + if path == "" { + return fallback + } + if filepath.IsAbs(path) { + return path + } + path = strings.TrimPrefix(path, "/") + path = strings.TrimPrefix(path, "\\") + return filepath.Join(basePath, filepath.FromSlash(path)) +} + +func resolveConfigFilePath() (string, error) { + if explicitConfigPath != "" { + return normalizeConfiguredPath(explicitConfigPath) + } + if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" { + return normalizeConfiguredPath(envPath) + } + return normalizeConfiguredPath(".env") +} + +func normalizeConfiguredPath(input string) (string, error) { + path := strings.TrimSpace(input) + if path == "" { + path = ".env" + } + if !filepath.IsAbs(path) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + path = filepath.Join(cwd, path) + } + + info, err := os.Stat(path) + if err == nil && info.IsDir() { + path = filepath.Join(path, ".env") + info, err = os.Stat(path) + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("config file not found: %s", path) + } + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("config path must point to a file, got directory: %s", path) + } + return path, nil } func GetAppVersion() string { - return "0.0.2" + return BuildVersion +} + +func GetBuildCommit() string { + return BuildCommit +} + +func GetBuildDate() string { + return BuildDate } func GetAppName() string { @@ -74,6 +182,73 @@ func GetApiAuthToken() string { return viper.GetString("api_auth_token") } +func GetRateApiUrl() string { + rateURL := viper.GetString("api_rate_url") + if rateURL == "" { + rateURL = os.Getenv("API_RATE_URL") + } + if rateURL == "" { + log.Println("api_rate_url is empty") + } + RateApiUrl = rateURL + return rateURL +} + +func GetRateForCoin(coin string, base string) float64 { + coin = strings.ToLower(strings.TrimSpace(coin)) + base = strings.ToLower(strings.TrimSpace(base)) + if coin == "" || base == "" { + return 0 + } + if coin == base { + return 1 + } + if coin == "usdt" { + switch base { + case "usd": + return 1 + case "cny": + usdtRate := GetUsdtRate() + if usdtRate > 0 { + return 1 / usdtRate + } + } + } + + baseURL := RateApiUrl + if baseURL == "" { + baseURL = GetRateApiUrl() + } + if baseURL == "" { + log.Printf("rate api url is empty") + return 0.0 + } + if baseURL[len(baseURL)-1] != '/' { + baseURL += "/" + } + + client := http_client.GetHttpClient() + resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base)) + if err != nil { + log.Printf("call rate api error: %s", err.Error()) + return 0.0 + } + if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { + log.Printf("call rate api unexpected status: %s", resp.Status()) + return 0.0 + } + + targetRate := 0.0 + gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool { + if key.String() == coin { + targetRate = value.Float() + return false + } + return true + }) + return targetRate +} + func GetUsdtRate() float64 { forcedUsdtRate := viper.GetFloat64("forced_usdt_rate") if forcedUsdtRate > 0 { @@ -97,3 +272,59 @@ func GetOrderExpirationTimeDuration() time.Duration { timer := GetOrderExpirationTime() return time.Minute * time.Duration(timer) } + +func GetRuntimeSqlitePath() string { + filename := viper.GetString("runtime_sqlite_filename") + if filename == "" { + filename = "epusdt-runtime.db" + } + if filepath.IsAbs(filename) { + return filename + } + filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\") + return filepath.Join(RuntimePath, filepath.FromSlash(filename)) +} + +func GetPrimarySqlitePath() string { + filename := strings.TrimSpace(viper.GetString("sqlite_database_filename")) + if filename == "" { + return filepath.Join(configRootPath, "epusdt.db") + } + if filepath.IsAbs(filename) { + return filename + } + filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\") + return filepath.Join(configRootPath, filepath.FromSlash(filename)) +} + +func GetQueueConcurrency() int { + concurrency := viper.GetInt("queue_concurrency") + if concurrency <= 0 { + return 10 + } + return concurrency +} + +func GetQueuePollInterval() time.Duration { + interval := viper.GetInt("queue_poll_interval_ms") + if interval <= 0 { + interval = 1000 + } + return time.Duration(interval) * time.Millisecond +} + +func GetOrderNoticeMaxRetry() int { + retry := viper.GetInt("order_notice_max_retry") + if retry < 0 { + return 0 + } + return retry +} + +func GetCallbackRetryBaseDuration() time.Duration { + seconds := viper.GetInt("callback_retry_base_seconds") + if seconds <= 0 { + seconds = 5 + } + return time.Duration(seconds) * time.Second +} diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..00b65b5 --- /dev/null +++ b/src/config/config_test.go @@ -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) + } +} diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 258dffa..328ca20 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -1,13 +1,13 @@ package comm import ( - "fmt" "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/service" "github.com/labstack/echo/v4" "html/template" "net/http" + "path/filepath" ) // CheckoutCounter 收银台 @@ -17,7 +17,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { if err != nil { 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 { return ctx.String(http.StatusOK, err.Error()) } diff --git a/src/crypto/tron.go b/src/crypto/tron.go new file mode 100644 index 0000000..abf36f3 --- /dev/null +++ b/src/crypto/tron.go @@ -0,0 +1,30 @@ +package crypto + +import ( + "crypto/sha256" + + "github.com/shengdoushi/base58" +) + +const AddressLength = 20 +const PrefixMainnet = 0x41 + +// Encode returns the Base58 encoding of input using the Bitcoin alphabet. +func Encode(input []byte) string { + return base58.Encode(input, base58.BitcoinAlphabet) +} + +// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum). +func EncodeCheck(input []byte) string { + h256h0 := sha256.New() + h256h0.Write(input) + h0 := h256h0.Sum(nil) + + h256h1 := sha256.New() + h256h1.Write(h0) + h1 := h256h1.Sum(nil) + + inputCheck := append(append([]byte(nil), input...), h1[:4]...) + + return Encode(inputCheck) +} diff --git a/src/go.mod b/src/go.mod index 5a5ae95..2331914 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,42 +1,42 @@ module github.com/assimon/luuu -go 1.24.0 +go 1.25.0 require ( github.com/btcsuite/btcutil v1.0.2 github.com/dromara/carbon/v2 v2.6.15 - github.com/go-redis/redis/v8 v8.11.5 github.com/go-resty/resty/v2 v2.11.0 github.com/gookit/color v1.5.0 github.com/gookit/goutil v0.4.6 github.com/gookit/validate v1.3.1 github.com/json-iterator/go v1.1.12 github.com/labstack/echo/v4 v4.6.0 + github.com/libtnb/sqlite v1.0.1 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/robfig/cron/v3 v3.0.1 github.com/satori/go.uuid v1.2.0 + github.com/shengdoushi/base58 v1.0.0 github.com/shopspring/decimal v1.3.1 github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.9.0 + github.com/tidwall/gjson v1.18.0 go.uber.org/zap v1.17.0 gopkg.in/telebot.v3 v3.0.0 gorm.io/driver/mysql v1.5.1 gorm.io/driver/postgres v1.5.2 - gorm.io/driver/sqlite v1.5.6 - gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/filter v1.1.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hibiken/asynq v0.25.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -46,19 +46,22 @@ require ( github.com/labstack/gommon v0.3.0 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.4.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // 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/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect @@ -66,11 +69,14 @@ require ( go.uber.org/multierr v1.6.0 // indirect golang.org/x/crypto v0.14.0 // indirect golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.63.2 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect ) diff --git a/src/go.sum b/src/go.sum index fd5455d..e62c8e7 100644 --- a/src/go.sum +++ b/src/go.sum @@ -66,13 +66,7 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -88,10 +82,10 @@ 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -104,8 +98,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= @@ -118,11 +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/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-redis/redis/v8 v8.11.2/go.mod h1:DLomh7y2e3ggQXQLd1YgmvIfecPJoFl7WU5SOQ/r06M= -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.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= -github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= 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-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= @@ -161,7 +150,6 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -178,7 +166,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -198,11 +187,10 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -240,7 +228,10 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -250,10 +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/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hibiken/asynq v0.23.0 h1:kmKkNFgqiXBatC8oz94Mer6uvKoGn4STlIVDV5wnKyE= -github.com/hibiken/asynq v0.23.0/go.mod h1:K70jPVx+CAmmQrXot7Dru0D52EO7ob4BIun3ri5z1Qw= -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/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= @@ -284,15 +271,19 @@ github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.6.0 h1:vsYEeeYy077cB5yMpgI+ubA7iVRZEtrzHhcvRhd27gA= github.com/labstack/echo/v4 v4.6.0/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/libtnb/sqlite v1.0.1 h1:H4kdU3LBOjdxfQy8s02/wG1rNmp8GMiqzgel0axewtI= +github.com/libtnb/sqlite v1.0.1/go.mod h1:zuagCP0nvIJwr4e0kdzq9Yc2Pw5HlVOoLT6ZmzmjOes= github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -309,8 +300,9 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -337,18 +329,11 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +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/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.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= 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.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= @@ -361,20 +346,22 @@ 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.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= +github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= @@ -385,8 +372,6 @@ github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= @@ -407,8 +392,17 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= @@ -435,9 +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.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4= -go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 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/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= @@ -455,8 +446,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -495,6 +485,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -521,7 +513,6 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= @@ -529,7 +520,6 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -537,11 +527,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= @@ -574,6 +561,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -588,14 +577,12 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -618,7 +605,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -641,12 +627,11 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -663,15 +648,14 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -723,7 +707,6 @@ golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82u golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= @@ -734,10 +717,11 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -865,14 +849,11 @@ 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/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -882,28 +863,26 @@ 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/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo= 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/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.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= -gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= -gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg= -gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -911,6 +890,34 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go new file mode 100644 index 0000000..7c68ea2 --- /dev/null +++ b/src/internal/testutil/testdb.go @@ -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) + } +} diff --git a/src/main.go b/src/main.go index c661bc3..522e672 100644 --- a/src/main.go +++ b/src/main.go @@ -1,8 +1,7 @@ package main import ( - "github.com/assimon/luuu/bootstrap" - "github.com/assimon/luuu/config" + "github.com/assimon/luuu/command" "github.com/gookit/color" ) @@ -12,7 +11,7 @@ func main() { color.Error.Println("[Start Server Err!!!] ", err) } }() - color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ") - color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "assimon", "https://github.com/assimon/epusdt") - bootstrap.Start() + if err := command.Execute(); err != nil { + panic(err) + } } diff --git a/src/model/dao/init.go b/src/model/dao/init.go index 920b6cb..d016736 100644 --- a/src/model/dao/init.go +++ b/src/model/dao/init.go @@ -9,7 +9,7 @@ func Init() { log.Fatalf("DBInit err: %v", err) } - if err := RedisInit(); err != nil { - log.Fatalf("RedisInit err: %v", err) + if err := RuntimeInit(); err != nil { + log.Fatalf("RuntimeInit err: %v", err) } } diff --git a/src/model/dao/mdb_mysql.go b/src/model/dao/mdb_mysql.go index 3e27f37..a522971 100644 --- a/src/model/dao/mdb_mysql.go +++ b/src/model/dao/mdb_mysql.go @@ -29,7 +29,7 @@ func MysqlInit() error { // panic(err) return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } sqlDB, err := Mdb.DB() diff --git a/src/model/dao/mdb_postgres.go b/src/model/dao/mdb_postgres.go index d14c65a..98a9ea1 100644 --- a/src/model/dao/mdb_postgres.go +++ b/src/model/dao/mdb_postgres.go @@ -40,7 +40,7 @@ func PostgreSQLInit() error { return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } sqlDB, err := Mdb.DB() diff --git a/src/model/dao/mdb_sqlite.go b/src/model/dao/mdb_sqlite.go index de0e227..9fc48f6 100644 --- a/src/model/dao/mdb_sqlite.go +++ b/src/model/dao/mdb_sqlite.go @@ -1,6 +1,7 @@ package dao import ( + "os" "path/filepath" "github.com/assimon/luuu/config" @@ -8,8 +9,6 @@ import ( "github.com/gookit/color" "github.com/spf13/viper" - // "github.com/glebarez/sqlite" - "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" @@ -18,12 +17,13 @@ import ( // SqliteInit 数据库初始化 func SqliteInit() error { var err error - dbFilename := "./conf/.db" - if dbfile := viper.GetString("sqlite_database_filename"); len(dbfile) > 0 { - dbFilename = filepath.Base(dbfile) + dbFilename := config.GetPrimarySqlitePath() + if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil { + color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err) + return err } color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename) - Mdb, err = gorm.Open(sqlite.Open(dbFilename), &gorm.Config{ + Mdb, err = openDB(dbFilename, &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: viper.GetString("sqlite_table_prefix"), SingularTable: true, @@ -35,22 +35,11 @@ func SqliteInit() error { // panic(err) return err } - if config.AppDebug { + if config.SQLDebug { Mdb = Mdb.Debug() } - sqlDB, err := Mdb.DB() - if err != nil { - color.Red.Printf("[store_db] sqlite get DB,err=%s\n", err) - // panic(err) - return err - } - // sqlDB.SetMaxIdleConns(viper.GetInt("sqlite_max_idle_conns")) - // sqlDB.SetMaxOpenConns(viper.GetInt("sqlite_max_open_conns")) - // sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("sqlite_max_life_time"))) - err = sqlDB.Ping() - if err != nil { + if _, err = configureSQLite(Mdb, 1); err != nil { color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error()) - // panic(err) return err } log.Sugar.Debug("[store_db] sqlite connDB success") diff --git a/src/model/dao/mdb_sqlite_cgo.go b/src/model/dao/mdb_sqlite_cgo.go new file mode 100644 index 0000000..da9e05d --- /dev/null +++ b/src/model/dao/mdb_sqlite_cgo.go @@ -0,0 +1,16 @@ +//go:build sqlite_cgo + +package dao + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg) + if err != nil { + return nil, err + } + return db, nil +} diff --git a/src/model/dao/mdb_sqlite_pure.go b/src/model/dao/mdb_sqlite_pure.go new file mode 100644 index 0000000..2f1e9ec --- /dev/null +++ b/src/model/dao/mdb_sqlite_pure.go @@ -0,0 +1,16 @@ +//go:build !sqlite_cgo + +package dao + +import ( + "github.com/libtnb/sqlite" + "gorm.io/gorm" +) + +func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg) + if err != nil { + return nil, err + } + return db, nil +} diff --git a/src/model/dao/rdb.go b/src/model/dao/rdb.go deleted file mode 100644 index 0bda4c5..0000000 --- a/src/model/dao/rdb.go +++ /dev/null @@ -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 -} diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go new file mode 100644 index 0000000..08a942b --- /dev/null +++ b/src/model/dao/runtime_sqlite.go @@ -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 +} diff --git a/src/model/dao/sqlite_config.go b/src/model/dao/sqlite_config.go new file mode 100644 index 0000000..9669600 --- /dev/null +++ b/src/model/dao/sqlite_config.go @@ -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 +} diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index d66c5b1..daa03c8 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -1,109 +1,184 @@ package data import ( - "context" - "fmt" + "errors" + "strings" + "time" + "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" - "github.com/go-redis/redis/v8" + "github.com/dromara/carbon/v2" + "github.com/shopspring/decimal" "gorm.io/gorm" - "time" + "gorm.io/gorm/clause" ) -var ( - CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号 -) +var ErrTransactionLocked = errors.New("transaction amount is already locked") -// GetOrderInfoByOrderId 通过客户订单号查询订单 +type PendingCallbackOrder struct { + TradeId string `gorm:"column:trade_id"` + CallbackNum int `gorm:"column:callback_num"` + CallBackConfirm int `gorm:"column:callback_confirm"` + UpdatedAt carbon.Time `gorm:"column:updated_at"` +} + +func normalizeLockAmount(amount float64) (int64, string) { + value := decimal.NewFromFloat(amount).Round(2) + return value.Shift(2).IntPart(), value.StringFixed(2) +} + +func normalizeLockToken(token string) string { + return strings.ToUpper(strings.TrimSpace(token)) +} + +// GetOrderInfoByOrderId fetches an order by merchant order id. func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { order := new(mdb.Orders) err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error return order, err } -// GetOrderInfoByTradeId 通过交易号查询订单 +// GetOrderInfoByTradeId fetches an order by epusdt trade id. func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { order := new(mdb.Orders) err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error return order, err } -// CreateOrderWithTransaction 事务创建订单 +// CreateOrderWithTransaction creates an order in the active database transaction. func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { - err := tx.Model(order).Create(order).Error - return err + return tx.Model(order).Create(order).Error } -// GetOrderByBlockIdWithTransaction 通过区块获取订单 -func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { +// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id. +func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) { order := new(mdb.Orders) - err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error + err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error return order, err } -// OrderSuccessWithTransaction 事务支付成功 -func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error { - err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{ - "block_transaction_id": req.BlockTransactionId, - "status": mdb.StatusPaySuccess, - "callback_confirm": mdb.CallBackConfirmNo, - }).Error - return err +// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment. +func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) { + result := tx.Model(&mdb.Orders{}). + Where("trade_id = ?", req.TradeId). + Where("status = ?", mdb.StatusWaitPay). + Updates(map[string]interface{}{ + "block_transaction_id": req.BlockTransactionId, + "status": mdb.StatusPaySuccess, + "callback_confirm": mdb.CallBackConfirmNo, + }) + return result.RowsAffected > 0, result.Error } -// GetPendingCallbackOrders 查询出等待回调的订单 -func GetPendingCallbackOrders() ([]mdb.Orders, error) { - var orders []mdb.Orders - err := dao.Mdb.Model(orders). - Where("callback_num < ?", 5). +// GetPendingCallbackOrders returns the minimal callback scheduling state. +func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) { + var orders []PendingCallbackOrder + query := dao.Mdb.Model(&mdb.Orders{}). + Select("trade_id", "callback_num", "callback_confirm", "updated_at"). + Where("callback_num <= ?", maxRetry). Where("callback_confirm = ?", mdb.CallBackConfirmNo). Where("status = ?", mdb.StatusPaySuccess). - Find(&orders).Error + Order("updated_at asc") + if limit > 0 { + query = query.Limit(limit) + } + err := query.Find(&orders).Error return orders, err } -// SaveCallBackOrdersResp 保存订单回调结果 +// SaveCallBackOrdersResp persists a callback attempt result. func SaveCallBackOrdersResp(order *mdb.Orders) error { - err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{ - "callback_num": gorm.Expr("callback_num + ?", 1), - "callback_confirm": order.CallBackConfirm, - }).Error - return err + return dao.Mdb.Model(order). + Where("id = ?", order.ID). + Where("callback_confirm = ?", mdb.CallBackConfirmNo). + Updates(map[string]interface{}{ + "callback_num": gorm.Expr("callback_num + ?", 1), + "callback_confirm": order.CallBackConfirm, + }).Error } -// UpdateOrderIsExpirationById 通过id设置订单过期 -func UpdateOrderIsExpirationById(id uint64) error { - err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error - return err +// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out. +func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) { + result := dao.Mdb.Model(mdb.Orders{}). + Where("id = ?", id). + Where("status = ?", mdb.StatusWaitPay). + Where("created_at <= ?", expirationCutoff). + Update("status", mdb.StatusExpired) + return result.RowsAffected > 0, result.Error } -// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号 -func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - result, err := dao.Rdb.Get(ctx, cacheKey).Result() - if err == redis.Nil { - return "", nil - } +// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount. +func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) { + scaledAmount, _ := normalizeLockAmount(amount) + var lock mdb.TransactionLock + err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). + Where("address = ?", address). + Where("token = ?", normalizeLockToken(token)). + Where("amount_scaled = ?", scaledAmount). + Where("expires_at > ?", time.Now()). + Limit(1). + Find(&lock).Error if err != nil { return "", err } - return result, nil + if lock.ID <= 0 { + return "", nil + } + return lock.TradeId, nil } -// LockTransaction 锁定交易 -func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err() - return err +// LockTransaction reserves an address+token+amount pair in sqlite until expiration. +func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error { + scaledAmount, amountText := normalizeLockAmount(amount) + normalizedToken := normalizeLockToken(token) + now := time.Now() + lock := &mdb.TransactionLock{ + Address: address, + Token: normalizedToken, + AmountScaled: scaledAmount, + AmountText: amountText, + TradeId: tradeID, + ExpiresAt: now.Add(expirationTime), + } + + return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("address = ?", address). + Where("token = ?", normalizedToken). + Where("amount_scaled = ?", scaledAmount). + Where("expires_at <= ?", now). + Delete(&mdb.TransactionLock{}).Error; err != nil { + return err + } + if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil { + return err + } + + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrTransactionLocked + } + return nil + }) } -// UnLockTransaction 解锁交易 -func UnLockTransaction(token string, amount float64) error { - ctx := context.Background() - cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount) - err := dao.Rdb.Del(ctx, cacheKey).Err() - return err +// UnLockTransaction releases the reservation for address+token+amount. +func UnLockTransaction(address string, token string, amount float64) error { + scaledAmount, _ := normalizeLockAmount(amount) + return dao.RuntimeDB. + Where("address = ?", address). + Where("token = ?", normalizeLockToken(token)). + Where("amount_scaled = ?", scaledAmount). + Delete(&mdb.TransactionLock{}).Error +} + +func UnLockTransactionByTradeId(tradeID string) error { + return dao.RuntimeDB.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error +} + +func CleanupExpiredTransactionLocks() error { + return dao.RuntimeDB.Where("expires_at <= ?", time.Now()).Delete(&mdb.TransactionLock{}).Error } diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index eea107f..40d9b11 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -7,8 +7,8 @@ import ( ) // AddWalletAddress 创建钱包 -func AddWalletAddress(token string) (*mdb.WalletAddress, error) { - exist, err := GetWalletAddressByToken(token) +func AddWalletAddress(address string) (*mdb.WalletAddress, error) { + exist, err := GetWalletAddressByToken(address) if err != nil { return nil, err } @@ -16,17 +16,17 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) { return nil, constant.WalletAddressAlreadyExists } walletAddress := &mdb.WalletAddress{ - Token: token, - Status: mdb.TokenStatusEnable, + Address: address, + Status: mdb.TokenStatusEnable, } err = dao.Mdb.Create(walletAddress).Error return walletAddress, err } -// GetWalletAddressByToken 通过钱包地址获取token -func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) { +// GetWalletAddressByToken 通过钱包地址获取address +func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) { walletAddress := new(mdb.WalletAddress) - err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error + err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error return walletAddress, err } diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 6b54047..6ed6335 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -9,21 +9,22 @@ const ( ) type Orders struct { - TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号 - OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id - BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` // 区块id - Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数 - ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `gorm:"column:token" json:"token"` // 所属钱包地址 - Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期 - NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 - RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 - CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数 - CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否 + TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` + OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` + BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` + Amount float64 `gorm:"column:amount" json:"amount"` + Currency string `gorm:"column:currency" json:"currency"` + ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` + ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"` + Token string `gorm:"column:token" json:"token"` + Status int `gorm:"column:status;default:1" json:"status"` + NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` + RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` + CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` + CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` BaseModel } -// TableName sets the insert table name for this struct type func (o *Orders) TableName() string { return "orders" } diff --git a/src/model/mdb/transaction_lock_mdb.go b/src/model/mdb/transaction_lock_mdb.go new file mode 100644 index 0000000..d94266e --- /dev/null +++ b/src/model/mdb/transaction_lock_mdb.go @@ -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" +} diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index d12d767..84c84b4 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -5,14 +5,12 @@ const ( TokenStatusDisable = 2 ) -// WalletAddress 钱包表 type WalletAddress struct { - Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token - Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用 + Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"` + Status int64 `gorm:"column:status;default:1" json:"status"` BaseModel } -// TableName sets the insert table name for this struct type func (w *WalletAddress) TableName() string { return "wallet_address" } diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index 3fdf049..1123ef2 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -5,6 +5,8 @@ import "github.com/gookit/validate" // CreateTransactionRequest 创建交易请求 type CreateTransactionRequest struct { OrderId string `json:"order_id" validate:"required|maxLen:32"` + Currency string `json:"currency" validate:"required"` + Token string `json:"token" validate:"required"` Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"` NotifyUrl string `json:"notify_url" validate:"required"` Signature string `json:"signature" validate:"required"` @@ -14,6 +16,8 @@ type CreateTransactionRequest struct { func (r CreateTransactionRequest) Translates() map[string]string { return validate.MS{ "OrderId": "订单号", + "Currency": "货币", + "Token": "币种", "Amount": "支付金额", "NotifyUrl": "异步回调网址", "Signature": "签名", @@ -22,6 +26,8 @@ func (r CreateTransactionRequest) Translates() map[string]string { // OrderProcessingRequest 订单处理 type OrderProcessingRequest struct { + ReceiveAddress string + Currency string Token string Amount float64 TradeId string diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index 0bb18c9..a04902f 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -5,8 +5,10 @@ type CreateTransactionResponse struct { TradeId string `json:"trade_id"` // epusdt订单号 OrderId string `json:"order_id"` // 客户交易id Amount float64 `json:"amount"` // 订单金额,保留4位小数 + Currency string `json:"currency"` // 订单货币类型 CNY USD...... ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 PaymentUrl string `json:"payment_url"` // 收银台地址 } @@ -17,7 +19,8 @@ type OrderNotifyResponse struct { OrderId string `json:"order_id"` // 客户交易id Amount float64 `json:"amount"` // 订单金额,保留4位小数 ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... BlockTransactionId string `json:"block_transaction_id"` // 区块id Signature string `json:"signature"` // 签名 Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 7d12e56..7aa8990 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -3,7 +3,8 @@ package response type CheckoutCounterResponse struct { TradeId string `json:"trade_id"` // epusdt订单号 ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 RedirectUrl string `json:"redirect_url"` } diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 3173924..3b81773 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -1,8 +1,10 @@ package service import ( + "errors" "fmt" "math/rand" + "strings" "sync" "time" @@ -12,42 +14,45 @@ import ( "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/response" - "github.com/assimon/luuu/mq" - "github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/util/constant" + "github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/math" "github.com/dromara/carbon/v2" - "github.com/hibiken/asynq" "github.com/shopspring/decimal" ) const ( - CnyMinimumPaymentAmount = 0.01 // cny最低支付金额 - UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额 - UsdtAmountPerIncrement = 0.01 // usdt每次递增金额 - IncrementalMaximumNumber = 100 // 最大递增次数 + CnyMinimumPaymentAmount = 0.01 + UsdtMinimumPaymentAmount = 0.01 + UsdtAmountPerIncrement = 0.01 + IncrementalMaximumNumber = 100 ) var gCreateTransactionLock sync.Mutex +var gOrderProcessingLock sync.Mutex -// CreateTransaction 创建订单 +// CreateTransaction creates a new payment order. func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) { gCreateTransactionLock.Lock() defer gCreateTransactionLock.Unlock() + + token := strings.ToUpper(strings.TrimSpace(req.Token)) + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) payAmount := math.MustParsePrecFloat64(req.Amount, 2) - // 按照汇率转化USDT + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) + if rate <= 0 { + return nil, constant.RateAmountErr + } + decimalPayAmount := decimal.NewFromFloat(payAmount) - decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) - decimalUsdt := decimalPayAmount.Div(decimalRate) - // cny 是否可以满足最低支付金额 + decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate)) if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } - // Usdt是否可以满足最低支付金额 - if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { + if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } - // 已经存在了的交易 + exist, err := data.GetOrderInfoByOrderId(req.OrderId) if err != nil { return nil, err @@ -55,7 +60,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT if exist.ID > 0 { return nil, constant.OrderAlreadyExists } - // 有无可用钱包 + walletAddress, err := data.GetAvailableWalletAddress() if err != nil { return nil, err @@ -63,128 +68,136 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT if len(walletAddress) <= 0 { return nil, constant.NotAvailableWalletAddress } - amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) - availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress) + + tradeID := GenerateCode() + amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress) if err != nil { return nil, err } - if availableToken == "" { + if availableAddress == "" { return nil, constant.NotAvailableAmountErr } + tx := dao.Mdb.Begin() order := &mdb.Orders{ - TradeId: GenerateCode(), - OrderId: req.OrderId, - Amount: req.Amount, - ActualAmount: availableAmount, - Token: availableToken, - Status: mdb.StatusWaitPay, - NotifyUrl: req.NotifyUrl, - RedirectUrl: req.RedirectUrl, + TradeId: tradeID, + OrderId: req.OrderId, + Amount: req.Amount, + Currency: currency, + ActualAmount: availableAmount, + ReceiveAddress: availableAddress, + Token: token, + Status: mdb.StatusWaitPay, + NotifyUrl: req.NotifyUrl, + RedirectUrl: req.RedirectUrl, } - err = data.CreateOrderWithTransaction(tx, order) - if err != nil { + if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() + _ = data.UnLockTransactionByTradeId(tradeID) return nil, err } - // 锁定支付池 - err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration()) - if err != nil { + if err = tx.Commit().Error; err != nil { tx.Rollback() + _ = data.UnLockTransactionByTradeId(tradeID) return nil, err } - tx.Commit() - // 超时过期消息队列 - orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId) - mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()), - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ) - ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp() + + expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp() resp := &response.CreateTransactionResponse{ TradeId: order.TradeId, OrderId: order.OrderId, Amount: order.Amount, + Currency: order.Currency, ActualAmount: order.ActualAmount, + ReceiveAddress: order.ReceiveAddress, Token: order.Token, - ExpirationTime: ExpirationTime, + ExpirationTime: expirationTime, PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), } return resp, nil } -// OrderProcessing 成功处理订单 +// OrderProcessing marks an order as paid and releases its sqlite reservation. func OrderProcessing(req *request.OrderProcessingRequest) error { + gOrderProcessingLock.Lock() + defer gOrderProcessingLock.Unlock() + tx := dao.Mdb.Begin() exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId) if err != nil { + tx.Rollback() return err } if exist.ID > 0 { tx.Rollback() return constant.OrderBlockAlreadyProcess } - // 标记订单成功 - err = data.OrderSuccessWithTransaction(tx, req) + + updated, err := data.OrderSuccessWithTransaction(tx, req) if err != nil { tx.Rollback() return err } - // 解锁交易 - err = data.UnLockTransaction(req.Token, req.Amount) - if err != nil { + if !updated { + tx.Rollback() + return constant.OrderStatusConflict + } + if err = tx.Commit().Error; err != nil { tx.Rollback() return err } - tx.Commit() + + if err = data.UnLockTransaction(req.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 } -// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 -func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { - availableToken := "" +// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair. +func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { + availableAddress := "" availableAmount := amount - calculateAvailableWalletFunc := func(amount float64) (string, error) { - availableWallet := "" + + tryLockWalletFunc := func(targetAmount float64) (string, error) { for _, address := range walletAddress { - token := address.Token - result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) - if err != nil { - return "", err + err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration()) + if err == nil { + return address.Address, nil } - if result == "" { - availableWallet = token - break + if errors.Is(err, data.ErrTransactionLocked) { + continue } + return "", err } - return availableWallet, nil + return "", nil } + for i := 0; i < IncrementalMaximumNumber; i++ { - token, err := calculateAvailableWalletFunc(availableAmount) + address, err := tryLockWalletFunc(availableAmount) if err != nil { return "", 0, err } - // 拿不到可用钱包就累加金额 - if token == "" { + if address == "" { decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() continue } - availableToken = token + availableAddress = address break } - return availableToken, availableAmount, nil + return availableAddress, availableAmount, nil } -// GenerateCode 订单号生成 +// GenerateCode creates a unique trade id. func GenerateCode() string { date := time.Now().Format("20060102") r := rand.Intn(1000) - code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) - return code + return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r) } -// GetOrderInfoByTradeId 通过交易号获取订单 +// GetOrderInfoByTradeId returns a validated order. func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { order, err := data.GetOrderInfoByTradeId(tradeId) if err != nil { diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go new file mode 100644 index 0000000..3aad2e0 --- /dev/null +++ b/src/model/service/order_service_test.go @@ -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) + } +} diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index 902220f..0be38f7 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -9,18 +9,20 @@ import ( "github.com/assimon/luuu/model/response" ) -// GetCheckoutCounterByTradeId 获取收银台详情,通过订单 +// GetCheckoutCounterByTradeId returns checkout info for a pending order. func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) { orderInfo, err := data.GetOrderInfoByTradeId(tradeId) if err != nil { return nil, err } if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { - return nil, errors.New("不存在待支付订单或已过期!") + return nil, errors.New("不存在待支付订单或已过期") } + resp := &response.CheckoutCounterResponse{ TradeId: orderInfo.TradeId, ActualAmount: orderInfo.ActualAmount, + ReceiveAddress: orderInfo.ReceiveAddress, Token: orderInfo.Token, ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), RedirectUrl: orderInfo.RedirectUrl, diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index f5c8b17..a0bd8bc 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -1,161 +1,281 @@ package service import ( + "encoding/hex" + "errors" "fmt" "net/http" + "strings" "sync" "github.com/assimon/luuu/config" - "github.com/spf13/viper" - + tron "github.com/assimon/luuu/crypto" "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/request" - "github.com/assimon/luuu/mq" - "github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/telegram" + "github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/http_client" - "github.com/assimon/luuu/util/json" "github.com/assimon/luuu/util/log" + "github.com/assimon/luuu/util/math" "github.com/dromara/carbon/v2" "github.com/gookit/goutil/stdutil" - "github.com/hibiken/asynq" "github.com/shopspring/decimal" + "github.com/tidwall/gjson" ) -const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20" +const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" -type UsdtTrc20Resp struct { - PageSize int `json:"page_size"` - Code int `json:"code"` - Data []Data `json:"data"` -} - -type TokenInfo struct { - TokenID string `json:"tokenId"` - TokenAbbr string `json:"tokenAbbr"` - TokenName string `json:"tokenName"` - TokenDecimal int `json:"tokenDecimal"` - TokenCanShow int `json:"tokenCanShow"` - TokenType string `json:"tokenType"` - TokenLogo string `json:"tokenLogo"` - TokenLevel string `json:"tokenLevel"` - IssuerAddr string `json:"issuerAddr"` - Vip bool `json:"vip"` -} - -type Data struct { - Amount string `json:"amount"` - ApprovalAmount string `json:"approval_amount"` - BlockTimestamp int64 `json:"block_timestamp"` - Block int `json:"block"` - From string `json:"from"` - To string `json:"to"` - Hash string `json:"hash"` - Confirmed int `json:"confirmed"` - ContractType string `json:"contract_type"` - ContracTType int `json:"contractType"` - Revert int `json:"revert"` - ContractRet string `json:"contract_ret"` - EventType string `json:"event_type"` - IssueAddress string `json:"issue_address"` - Decimals int `json:"decimals"` - TokenName string `json:"token_name"` - ID string `json:"id"` - Direction int `json:"direction"` -} - -// Trc20CallBack trc20回调 -func Trc20CallBack(token string, wg *sync.WaitGroup) { +func Trc20CallBack(address string, wg *sync.WaitGroup) { defer wg.Done() defer func() { if err := recover(); err != nil { log.Sugar.Error(err) } }() + + var innerWg sync.WaitGroup + innerWg.Add(2) + go checkTrxTransfers(address, &innerWg) + go checkTrc20Transfers(address, &innerWg) + innerWg.Wait() +} + +func checkTrxTransfers(address string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err) + } + }() + client := http_client.GetHttpClient() startTime := carbon.Now().AddHours(-24).TimestampMilli() endTime := carbon.Now().TimestampMilli() + url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address) + resp, err := client.R().SetQueryParams(map[string]string{ - "sort": "-timestamp", - "limit": "50", - "start": "0", - "direction": "2", - "db_version": "1", - "trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "address": token, - "start_timestamp": stdutil.ToString(startTime), - "end_timestamp": stdutil.ToString(endTime), - }).Get(UsdtTrc20ApiUri) + "order_by": "block_timestamp,desc", + "limit": "100", + "only_to": "true", + "min_timestamp": stdutil.ToString(startTime), + "max_timestamp": stdutil.ToString(endTime), + }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url) if err != nil { panic(err) } if resp.StatusCode() != http.StatusOK { - panic(err) + panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode())) } - var trc20Resp UsdtTrc20Resp - err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp) - if err != nil { - panic(err) + + success := gjson.GetBytes(resp.Body(), "success").Bool() + if !success { + panic("TRX API response indicates failure") } - if trc20Resp.PageSize <= 0 { + + transfers := gjson.GetBytes(resp.Body(), "data").Array() + if len(transfers) == 0 { + log.Sugar.Debugf("[TRX][%s] no transfer records found", address) return } - for _, transfer := range trc20Resp.Data { - if transfer.To != token || transfer.ContractRet != "SUCCESS" { + log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers)) + + for i, transfer := range transfers { + if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" { continue } - decimalQuant, err := decimal.NewFromString(transfer.Amount) - if err != nil { - panic(err) - } - decimalDivisor := decimal.NewFromFloat(1000000) - amount := decimalQuant.Div(decimalDivisor).InexactFloat64() - tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) - if err != nil { - panic(err) - } - if tradeId == "" { + if transfer.Get("ret.0.contractRet").String() != "SUCCESS" { continue } - order, err := data.GetOrderInfoByTradeId(tradeId) + + toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String() + toBytes, err := hex.DecodeString(toAddressHex) + if err != nil { + log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err) + continue + } + if tron.EncodeCheck(toBytes) != address { + continue + } + + rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String() + decimalQuant, err := decimal.NewFromString(rawAmount) + if err != nil { + log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err) + continue + } + amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2) + if amount <= 0 { + continue + } + + txID := transfer.Get("txID").String() + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount) if err != nil { panic(err) } - // 区块的确认时间必须在订单创建时间之后 + if tradeID == "" { + log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount) + continue + } + log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount) + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + panic(err) + } + blockTimestamp := transfer.Get("block_timestamp").Int() createTime := order.CreatedAt.TimestampMilli() - if transfer.BlockTimestamp < createTime { - panic("Orders cannot actually be matched") + if blockTimestamp < createTime { + log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime) + continue } - // 到这一步就完全算是支付成功了 + req := &request.OrderProcessingRequest{ - Token: token, - TradeId: tradeId, + ReceiveAddress: address, + Token: "TRX", + TradeId: tradeID, Amount: amount, - BlockTransactionId: transfer.Hash, + BlockTransactionId: txID, } err = OrderProcessing(req) if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err) + continue + } panic(err) } - // 回调队列 - orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) - orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry") - mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry), - asynq.Retention(config.GetOrderExpirationTimeDuration()), - ) - // mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5)) - // 发送机器人消息 - msgTpl := ` -📢📢有新的交易支付成功! -
交易号:%s-
订单号:%s-
请求支付金额:%f cny-
实际支付金额:%f usdt-
钱包地址:%s-
订单创建时间:%s-
支付成功时间:%s-` - msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString()) - telegram.SendToBot(msg) + + sendPaymentNotification(order) + log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID) } } + +func checkTrc20Transfers(address string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err) + } + }() + + client := http_client.GetHttpClient() + startTime := carbon.Now().AddHours(-24).TimestampMilli() + endTime := carbon.Now().TimestampMilli() + url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address) + + resp, err := client.R().SetQueryParams(map[string]string{ + "order_by": "block_timestamp,desc", + "limit": "100", + "only_to": "true", + "min_timestamp": stdutil.ToString(startTime), + "max_timestamp": stdutil.ToString(endTime), + }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url) + if err != nil { + panic(err) + } + if resp.StatusCode() != http.StatusOK { + panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode())) + } + + success := gjson.GetBytes(resp.Body(), "success").Bool() + if !success { + panic("TRC20 API response indicates failure") + } + + transfers := gjson.GetBytes(resp.Body(), "data").Array() + if len(transfers) == 0 { + log.Sugar.Debugf("[TRC20][%s] no transfer records found", address) + return + } + log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers)) + + for i, transfer := range transfers { + if transfer.Get("token_info.address").String() != TRC20_USDT_ID { + continue + } + if transfer.Get("to").String() != address { + continue + } + + valueStr := transfer.Get("value").String() + decimalQuant, err := decimal.NewFromString(valueStr) + if err != nil { + log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err) + continue + } + tokenDecimals := transfer.Get("token_info.decimals").Int() + amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2) + if amount <= 0 { + continue + } + + txID := transfer.Get("transaction_id").String() + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount) + if err != nil { + panic(err) + } + if tradeID == "" { + log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount) + continue + } + log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount) + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + panic(err) + } + blockTimestamp := transfer.Get("block_timestamp").Int() + createTime := order.CreatedAt.TimestampMilli() + if blockTimestamp < createTime { + log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime) + continue + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: address, + Token: "USDT", + TradeId: tradeID, + Amount: amount, + BlockTransactionId: txID, + } + err = OrderProcessing(req) + if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err) + continue + } + panic(err) + } + + sendPaymentNotification(order) + log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID) + } +} + +func sendPaymentNotification(order *mdb.Orders) { + msg := fmt.Sprintf( + "🎉 收款成功通知\n\n"+ + "💰 金额信息\n"+ + "├ 订单金额:
%.2f %s\n"+
+ "└ 实际到账:%.2f %s\n\n"+
+ "📋 订单信息\n"+
+ "├ 交易号:%s\n"+
+ "├ 订单号:%s\n"+
+ "└ 钱包地址:%s\n\n"+
+ "⏰ 时间信息\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)
+}
diff --git a/src/mq/handle/callback_queue.go b/src/mq/handle/callback_queue.go
deleted file mode 100644
index 10b67f8..0000000
--- a/src/mq/handle/callback_queue.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package handle
-
-import (
- "context"
- "errors"
-
- "github.com/assimon/luuu/config"
- "github.com/assimon/luuu/model/data"
- "github.com/assimon/luuu/model/mdb"
- "github.com/assimon/luuu/model/response"
- "github.com/assimon/luuu/util/http_client"
- "github.com/assimon/luuu/util/json"
- "github.com/assimon/luuu/util/log"
- "github.com/assimon/luuu/util/sign"
- "github.com/hibiken/asynq"
-)
-
-const QueueOrderCallback = "order:callback"
-
-func NewOrderCallbackQueue(order *mdb.Orders) (*asynq.Task, error) {
- payload, err := json.Cjson.Marshal(order)
- if err != nil {
- return nil, err
- }
- return asynq.NewTask(QueueOrderCallback, payload,
- asynq.Retention(config.GetOrderExpirationTimeDuration()),
- ), nil
-}
-
-func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error {
- var order mdb.Orders
- err := json.Cjson.Unmarshal(t.Payload(), &order)
- if err != nil {
- return err
- }
- defer func() {
- if err := recover(); err != nil {
- log.Sugar.Error(err)
- }
- }()
- defer func() {
- data.SaveCallBackOrdersResp(&order)
- }()
- client := http_client.GetHttpClient()
- orderResp := response.OrderNotifyResponse{
- TradeId: order.TradeId,
- OrderId: order.OrderId,
- Amount: order.Amount,
- ActualAmount: order.ActualAmount,
- Token: order.Token,
- BlockTransactionId: order.BlockTransactionId,
- Status: mdb.StatusPaySuccess,
- }
- signature, err := sign.Get(orderResp, config.GetApiAuthToken())
- if err != nil {
- return err
- }
- orderResp.Signature = signature
- resp, err := client.R().SetHeader("powered-by", "Epusdt(https://github.com/assimon/epusdt)").SetBody(orderResp).Post(order.NotifyUrl)
- if err != nil {
- return err
- }
- body := string(resp.Body())
- if body != "ok" {
- order.CallBackConfirm = mdb.CallBackConfirmNo
- return errors.New("not ok")
- }
- order.CallBackConfirm = mdb.CallBackConfirmOk
- return nil
-}
diff --git a/src/mq/handle/order_expiration_queue.go b/src/mq/handle/order_expiration_queue.go
deleted file mode 100644
index 7d57887..0000000
--- a/src/mq/handle/order_expiration_queue.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package handle
-
-import (
- "context"
-
- "github.com/assimon/luuu/config"
- "github.com/assimon/luuu/model/data"
- "github.com/assimon/luuu/model/mdb"
- "github.com/hibiken/asynq"
-)
-
-const QueueOrderExpiration = "order:expiration"
-
-func NewOrderExpirationQueue(tradeId string) (*asynq.Task, error) {
- return asynq.NewTask(QueueOrderExpiration, []byte(tradeId),
- asynq.Retention(config.GetOrderExpirationTimeDuration()),
- ), nil
-}
-
-// OrderExpirationHandle 设置订单过期
-func OrderExpirationHandle(ctx context.Context, t *asynq.Task) error {
- tradeId := string(t.Payload())
- orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
- if err != nil {
- return err
- }
- if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
- return nil
- }
- err = data.UpdateOrderIsExpirationById(orderInfo.ID)
- if err != nil {
- return err
- }
- err = data.UnLockTransaction(orderInfo.Token, orderInfo.ActualAmount)
- if err != nil {
- return err
- }
- return nil
-}
diff --git a/src/mq/queue.go b/src/mq/queue.go
index 68db830..8d350e5 100644
--- a/src/mq/queue.go
+++ b/src/mq/queue.go
@@ -1,52 +1,22 @@
package mq
import (
- "fmt"
+ "sync"
- "github.com/assimon/luuu/mq/handle"
- "github.com/assimon/luuu/util/log"
- "github.com/hibiken/asynq"
- "github.com/spf13/viper"
+ "github.com/assimon/luuu/config"
)
-var MClient *asynq.Client
+var (
+ startOnce sync.Once
+ callbackLimiter chan struct{}
+ callbackInflight sync.Map
+)
func Start() {
- redis := asynq.RedisClientOpt{
- Addr: fmt.Sprintf(
- "%s:%s",
- viper.GetString("redis_host"),
- viper.GetString("redis_port")),
- DB: viper.GetInt("redis_db"),
- Password: viper.GetString("redis_passwd"),
- }
- initClient(redis)
- go initListen(redis)
-}
-
-func initClient(redis asynq.RedisClientOpt) {
- MClient = asynq.NewClient(redis)
-}
-
-func initListen(redis asynq.RedisClientOpt) {
- srv := asynq.NewServer(
- redis,
- asynq.Config{
- // Specify how many concurrent workers to use
- Concurrency: viper.GetInt("queue_concurrency"),
- // Optionally specify multiple queues with different priority.
- Queues: map[string]int{
- "critical": viper.GetInt("queue_level_critical"),
- "default": viper.GetInt("queue_level_default"),
- "low": viper.GetInt("queue_level_low"),
- },
- Logger: log.Sugar,
- },
- )
- mux := asynq.NewServeMux()
- mux.HandleFunc(handle.QueueOrderExpiration, handle.OrderExpirationHandle)
- mux.HandleFunc(handle.QueueOrderCallback, handle.OrderCallbackHandle)
- if err := srv.Run(mux); err != nil {
- log.Sugar.Fatalf("[queue] could not run server: %v", err)
- }
+ startOnce.Do(func() {
+ callbackLimiter = make(chan struct{}, config.GetQueueConcurrency())
+ go runOrderExpirationLoop()
+ go runOrderCallbackLoop()
+ go runTransactionLockCleanupLoop()
+ })
}
diff --git a/src/mq/worker.go b/src/mq/worker.go
new file mode 100644
index 0000000..c18d763
--- /dev/null
+++ b/src/mq/worker.go
@@ -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
+}
diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go
new file mode 100644
index 0000000..b67804e
--- /dev/null
+++ b/src/mq/worker_test.go
@@ -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")
+}
diff --git a/src/route/router.go b/src/route/router.go
index 0be1056..a544a20 100644
--- a/src/route/router.go
+++ b/src/route/router.go
@@ -1,16 +1,17 @@
package route
import (
+ "net/http"
+
"github.com/assimon/luuu/controller/comm"
"github.com/assimon/luuu/middleware"
"github.com/labstack/echo/v4"
- "net/http"
)
// RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error {
- return c.String(http.StatusOK, "hello epusdt, https://github.com/assimon/epusdt")
+ return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
})
// ==== 支付相关=====
payRoute := e.Group("/pay")
diff --git a/src/task/listen.go b/src/task/listen.go
index 46991eb..3eb96ca 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -1,12 +1,20 @@
package task
-import "github.com/robfig/cron/v3"
+import (
+ "github.com/assimon/luuu/util/log"
+ "github.com/robfig/cron/v3"
+)
func Start() {
+ log.Sugar.Info("[task] Starting task scheduler...")
c := cron.New()
- // 汇率监听
- c.AddJob("@every 60s", UsdtRateJob{})
// trc20钱包监听
- c.AddJob("@every 5s", ListenTrc20Job{})
+ _, err := c.AddJob("@every 5s", ListenTrc20Job{})
+ if err != nil {
+ log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
+ return
+ }
+ log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
c.Start()
+ log.Sugar.Info("[task] Task scheduler started")
}
diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go
index c4a180e..f35cc86 100644
--- a/src/task/listen_trc20_job.go
+++ b/src/task/listen_trc20_job.go
@@ -1,10 +1,11 @@
package task
import (
+ "sync"
+
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
- "sync"
)
type ListenTrc20Job struct {
@@ -15,18 +16,24 @@ var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
+ log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddress()
if err != nil {
- log.Sugar.Error(err)
+ log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return
}
if len(walletAddress) <= 0 {
+ log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return
}
+ log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup
for _, address := range walletAddress {
+ log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
+
wg.Add(1)
- go service.Trc20CallBack(address.Token, &wg)
+ go service.Trc20CallBack(address.Address, &wg)
}
wg.Wait()
+ log.Sugar.Debug("[ListenTrc20Job] Job completed")
}
diff --git a/src/task/usdt_rate_job.go b/src/task/usdt_rate_job.go
deleted file mode 100644
index 90a8891..0000000
--- a/src/task/usdt_rate_job.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package task
-
-import (
- "github.com/assimon/luuu/config"
- "github.com/assimon/luuu/util/http_client"
- "github.com/assimon/luuu/util/json"
- "github.com/assimon/luuu/util/log"
- "github.com/assimon/luuu/util/math"
- "time"
-)
-
-const UsdtRateApiUri = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail/chart"
-
-type UsdtRateJob struct {
-}
-
-type UsdtRateResp struct {
- Data Data `json:"data"`
- Status Status `json:"status"`
-}
-
-type Status struct {
- Timestamp time.Time `json:"timestamp"`
- ErrorCode string `json:"error_code"`
- ErrorMessage string `json:"error_message"`
- Elapsed string `json:"elapsed"`
- CreditCount int `json:"credit_count"`
-}
-
-type Data struct {
- Points map[string]Points `json:"points"`
-}
-
-type Points struct {
- V []float64 `json:"v"`
- C []float64 `json:"c"`
-}
-
-func (r UsdtRateJob) Run() {
- client := http_client.GetHttpClient()
- resp, err := client.R().SetQueryString("id=825&range=1H&convertId=2787").SetHeader("Accept", "application/json").Get(UsdtRateApiUri)
- if err != nil {
- log.Sugar.Error("usdt rate get err:", err.Error())
- return
- }
- var usdtResp UsdtRateResp
- err = json.Cjson.Unmarshal(resp.Body(), &usdtResp)
- if err != nil {
- log.Sugar.Error("Unmarshal usdt resp err:", err.Error())
- return
- }
- if usdtResp.Status.ErrorCode != "0" {
- log.Sugar.Error("usdt resp err:", usdtResp.Status.ErrorMessage)
- return
- }
- for _, points := range usdtResp.Data.Points {
- if len(points.C) > 0 && points.C[0] > 0 {
- config.UsdtRate = math.MustParsePrecFloat64(points.C[0], 2)
- return
- }
- }
-}
diff --git a/src/telegram/handle.go b/src/telegram/handle.go
index 8b4a3ef..72658e7 100644
--- a/src/telegram/handle.go
+++ b/src/telegram/handle.go
@@ -2,6 +2,8 @@ package telegram
import (
"fmt"
+ "sync"
+ "time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
@@ -11,25 +13,52 @@ import (
)
const (
- ReplayAddWallet = "请发给我一个合法的钱包地址"
+ ReplayAddWallet = "请发给我一个合法的钱包地址"
+ pendingWalletAddressTTL = 5 * time.Minute
)
+type pendingWalletAddressState struct {
+ RequestedAt time.Time
+}
+
+var pendingWalletAddressUsers sync.Map
+
func OnTextMessageHandle(c tb.Context) error {
- if c.Message().ReplyTo.Text == ReplayAddWallet {
- defer bots.Delete(c.Message().ReplyTo)
- msgText := c.Message().Text
- if !isValidTronAddress(msgText) {
- _ = c.Send(fmt.Sprintf("钱包[%s]添加失败: 非Tron地址!", msgText))
- return nil
- }
- _, err := data.AddWalletAddress(msgText)
- if err != nil {
- return c.Send(err.Error())
- }
- _ = c.Send(fmt.Sprintf("钱包[%s]添加成功!", msgText))
- return WalletList(c)
+ msg := c.Message()
+ if msg == nil {
+ return nil
}
- 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 {
@@ -37,29 +66,35 @@ func WalletList(c tb.Context) error {
if err != nil {
return err
}
+
var btnList [][]tb.InlineButton
for _, wallet := range wallets {
status := "已启用✅"
if wallet.Status == mdb.TokenStatusDisable {
status = "已禁用🚫"
}
- var temp []tb.InlineButton
+
btnInfo := tb.InlineButton{
- Unique: wallet.Token,
- Text: fmt.Sprintf("%s[%s]", wallet.Token, status),
+ Unique: wallet.Address,
+ Text: fmt.Sprintf("%s [%s]", wallet.Address, status),
Data: strutil.MustString(wallet.ID),
}
bots.Handle(&btnInfo, WalletInfo)
- btnList = append(btnList, append(temp, btnInfo))
+ btnList = append(btnList, []tb.InlineButton{btnInfo})
}
+
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error {
+ if sender := c.Sender(); sender != nil {
+ pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()})
+ }
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{
ForceReply: true,
})
})
btnList = append(btnList, []tb.InlineButton{addBtn})
- return c.EditOrSend("请点击钱包继续操作", &tb.ReplyMarkup{
+
+ return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
InlineKeyboard: btnList,
})
}
@@ -70,6 +105,7 @@ func WalletInfo(c tb.Context) error {
if err != nil {
return c.Send(err.Error())
}
+
enableBtn := tb.InlineButton{
Text: "启用",
Unique: "enableBtn",
@@ -89,11 +125,13 @@ func WalletInfo(c tb.Context) error {
Text: "返回",
Unique: "WalletList",
}
+
bots.Handle(&enableBtn, EnableWallet)
bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList)
- return c.EditOrReply(tokenInfo.Token, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
+
+ return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
{
enableBtn,
disableBtn,
@@ -140,3 +178,20 @@ func DelWallet(c tb.Context) error {
}
return WalletList(c)
}
+
+func isWalletAddressPending(userID int64) bool {
+ if userID <= 0 {
+ return false
+ }
+ value, ok := pendingWalletAddressUsers.Load(userID)
+ if !ok {
+ return false
+ }
+
+ state, ok := value.(pendingWalletAddressState)
+ if !ok || time.Since(state.RequestedAt) > pendingWalletAddressTTL {
+ pendingWalletAddressUsers.Delete(userID)
+ return false
+ }
+ return true
+}
diff --git a/src/telegram/telegram.go b/src/telegram/telegram.go
index b29bf63..7410a55 100644
--- a/src/telegram/telegram.go
+++ b/src/telegram/telegram.go
@@ -46,14 +46,21 @@ func RegisterHandle() {
// SendToBot 主动发送消息机器人消息
func SendToBot(msg string) {
go func() {
+ if bots == nil {
+ log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
+ return
+ }
user := tb.User{
ID: config.TgManage,
}
+ log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
_, err := bots.Send(&user, msg, &tb.SendOptions{
ParseMode: tb.ModeHTML,
})
if err != nil {
- log.Sugar.Error(err)
+ log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
+ } else {
+ log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
}
}()
}
diff --git a/src/util/constant/errno.go b/src/util/constant/errno.go
index ff8df5c..372f676 100644
--- a/src/util/constant/errno.go
+++ b/src/util/constant/errno.go
@@ -1,17 +1,18 @@
package constant
var Errno = map[int]string{
- 400: "系统错误",
- 401: "签名认证错误",
- 10001: "钱包地址已存在,请勿重复添加",
- 10002: "支付交易已存在,请勿重复创建",
- 10003: "无可用钱包地址,无法发起支付",
- 10004: "支付金额有误, 无法满足最小支付单位",
- 10005: "无可用金额通道",
- 10006: "汇率计算错误",
- 10007: "订单区块已处理",
- 10008: "订单不存在",
- 10009: "无法解析请求参数",
+ 400: "system error",
+ 401: "signature verification failed",
+ 10001: "wallet address already exists",
+ 10002: "order already exists",
+ 10003: "no available wallet address",
+ 10004: "invalid payment amount",
+ 10005: "no available amount channel",
+ 10006: "rate calculation failed",
+ 10007: "block transaction already processed",
+ 10008: "order does not exist",
+ 10009: "failed to parse request params",
+ 10010: "order status already changed",
}
var (
@@ -26,6 +27,7 @@ var (
OrderBlockAlreadyProcess = Err(10007)
OrderNotExists = Err(10008)
ParamsMarshalErr = Err(10009)
+ OrderStatusConflict = Err(10010)
)
type RspError struct {
diff --git a/src/util/http_client/client.go b/src/util/http_client/client.go
index bee9bc6..af440bc 100644
--- a/src/util/http_client/client.go
+++ b/src/util/http_client/client.go
@@ -1,8 +1,9 @@
package http_client
import (
- "github.com/go-resty/resty/v2"
"time"
+
+ "github.com/go-resty/resty/v2"
)
// GetHttpClient 获取请求客户端
@@ -13,6 +14,6 @@ func GetHttpClient(proxys ...string) *resty.Client {
proxy := proxys[0]
client.SetProxy(proxy)
}
- client.SetTimeout(time.Second * 5)
+ client.SetTimeout(time.Second * 10)
return client
}
diff --git a/src/util/log/log.go b/src/util/log/log.go
index f30e893..58e148d 100644
--- a/src/util/log/log.go
+++ b/src/util/log/log.go
@@ -7,27 +7,37 @@ import (
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
+ "os"
"time"
)
var Sugar *zap.SugaredLogger
func Init() {
- writeSyncer := getLogWriter()
- encoder := getEncoder()
- core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel)
+ level := getLogLevel()
+ core := zapcore.NewTee(
+ zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
+ zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
+ )
logger := zap.New(core, zap.AddCaller())
Sugar = logger.Sugar()
}
-func getEncoder() zapcore.Encoder {
+func getFileEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
-func getLogWriter() zapcore.WriteSyncer {
+func getConsoleEncoder() zapcore.Encoder {
+ encoderConfig := zap.NewDevelopmentEncoderConfig()
+ encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
+ return zapcore.NewConsoleEncoder(encoderConfig)
+}
+
+func getFileWriter() zapcore.WriteSyncer {
file := fmt.Sprintf("%s/log_%s.log",
config.LogSavePath,
time.Now().Format("20060102"))
@@ -40,3 +50,20 @@ func getLogWriter() zapcore.WriteSyncer {
}
return zapcore.AddSync(lumberJackLogger)
}
+
+func getConsoleWriter() zapcore.WriteSyncer {
+ return zapcore.Lock(os.Stdout)
+}
+
+func getLogLevel() zapcore.Level {
+ switch config.LogLevel {
+ case "debug":
+ return zapcore.DebugLevel
+ case "warn":
+ return zapcore.WarnLevel
+ case "error":
+ return zapcore.ErrorLevel
+ default:
+ return zapcore.InfoLevel
+ }
+}