144 Commits

Author SHA1 Message Date
line-6000 ff157c33d9 Remove database config for PostgreSQL and MySQL
Removed PostgreSQL and MySQL configuration settings from the example environment file.
2026-05-09 21:46:44 +08:00
line-6000 51ad344b36 Merge pull request #62 from GMWalletApp/pay 2026-05-09 21:28:42 +08:00
mcmc 3623a543bb feat: expose site config in public/admin config responses 2026-05-09 19:01:53 +08:00
mcmc 377176a0f2 feat: integrate okpay switch-network flow and notify handling 2026-05-09 18:06:27 +08:00
line-6000 a4022df7b4 Change logo source
Updated logo source URL in README.
2026-05-07 17:27:39 +08:00
line-6000 32aa1b66f5 Clean up Dockerfile by removing static file copies
Remove redundant COPY commands for static files.
2026-05-02 01:51:40 +08:00
Astra3456 53e66bad35 Update disclaimer for clarity and completeness
Clarify the disclaimer regarding the use of the project and its risks.
2026-04-29 17:15:56 +10:00
line-6000 ff68279b0f Merge pull request #59 from GMWalletApp/dev
feat(pay): switch checkout counter to SPA redirect + JSON API
2026-04-29 00:36:12 +08:00
line-6000 9c533a7ff8 feat(pay): switch checkout counter to SPA redirect + JSON API
- redirect `/pay/checkout-counter/:trade_id` to `/cashier/:trade_id`
- add `/pay/checkout-counter-resp/:trade_id` for checkout JSON payload
- remove embedded `static` release and keep `www` as the web bundle source
- regenerate PWA/www assets and move chain icons to `/www/images/chains`
2026-04-29 00:34:58 +08:00
line-6000 785a162f88 Merge pull request #58 from GMWalletApp/hot_fix_0427
Hot fix 0427
2026-04-28 19:14:58 +08:00
Ginta 06933451c5 actions 2026-04-28 16:28:01 +08:00
line-6000 d17d212c24 change tron scan logic 2026-04-28 15:28:00 +08:00
Ginta 51a2accd9f 🐛 fix link 2026-04-27 15:57:26 +08:00
Ginta 96ea7481bc Revert "Revert "new trc (#54)""
This reverts commit 74c3a3a08c.
2026-04-27 15:52:50 +08:00
Ginta de9c45a944 static 2026-04-27 15:52:12 +08:00
Ginta 9e885d8b2f order notify 2026-04-27 15:43:35 +08:00
Ginta 74c3a3a08c Revert "new trc (#54)"
This reverts commit 0a19854602.
2026-04-27 00:04:05 +08:00
Ginta 0a19854602 new trc (#54)
* new trct
2026-04-26 23:32:43 +08:00
line-6000 67263da0f1 Merge pull request #51 from GMWalletApp/dev 2026-04-25 18:49:41 +08:00
line-6000 95879e33d4 refactor(routes):
- keep install-mode root redirect to /install and add tests covering install root redirect and install page serving
- change root backend handler from Any("/") to POST("/") so GET / can consistently serve the SPA after install

Co-authored-by: Copilot <copilot@github.com>
2026-04-25 18:48:28 +08:00
line-6000 0c059e8a28 Merge pull request #50 from GMWalletApp/dev 2026-04-24 19:18:45 +08:00
line-6000 485059f60e - Removed manifest link from index.html.
- Updated script and module preload links in index.html for optimized loading.
- Modified service worker (sw.js) to reflect new asset revisions and improve caching strategy.
- Updated configuration documentation in BT_RUN.md, docker-RUN.md, and manual_RUN.md to clarify currency rate settings.
2026-04-24 19:10:11 +08:00
root 143bc84ac3 docs: sync docker examples to latest image 2026-04-23 07:25:56 +00:00
Ginta 06856e5caa release to group 2026-04-23 03:15:56 +08:00
Ginta b9bde0f95b release to group 2026-04-23 02:49:58 +08:00
line-6000 1b92ec1905 Merge pull request #47 from GMWalletApp/dev
Dev
2026-04-23 02:26:18 +08:00
line-6000 89be1fc967 Refactor project structure and update import paths to GMWalletApp/epusdt 2026-04-23 02:17:21 +08:00
line-6000 91936d580f refactor(rpc): streamline RPC node selection and remove hardcoded defaults 2026-04-23 02:08:18 +08:00
line-6000 09514a6946 fix(http): block /install page after setup is complete
- skip SPA fallback for `/install` and `/install/*` in main server
- keep install wizard accessible only during `RunInstallServer` phase
- prevent post-install access to install UI
2026-04-23 00:39:22 +08:00
Ginta b6cd4325b8 release action 2026-04-22 22:46:14 +08:00
Ginta 27d5e5bcfe add .env file into docker image 2026-04-22 22:00:51 +08:00
Ginta b625bac1e3 add .env file into docker image 2026-04-22 21:03:10 +08:00
Ginta bc5bbf72f7 add .env file into docker image 2026-04-22 20:46:11 +08:00
line-6000 3a3a830d93 fix(server): prevent SPA fallback from swallowing API routes
- add ShouldSkipSPAFallback() deny prefixes: /api, /admin/api, /payments, /pay
- apply StaticWithConfig Skipper in both main http server and install server
- add unit tests for fallback skip matching behavior
2026-04-22 20:27:34 +08:00
Ginta 59aa71209c action 2026-04-22 18:06:59 +08:00
Ginta cdf274599e build 2026-04-22 16:43:47 +08:00
line-6000 916394337b Merge pull request #37 from GMWalletApp/dev-runtime
Dev runtime
2026-04-22 04:20:43 +08:00
line-6000 6ea5637f6b update www static files 2026-04-22 03:39:11 +08:00
line-6000 b499bc01cf Change install flag to true in .env.example 2026-04-22 03:12:34 +08:00
line-6000 5edc9dc842 feat: sync Telegram notification channels with settings updates 2026-04-22 03:04:15 +08:00
line-6000 6bb47d4b00 feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets,
  orders, RPC nodes, settings, dashboard stats, notifications
- Add JWT-based admin authentication and API key auth middleware
- Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket
- Add RPC node health check job with automatic failover
- Add Telegram notification channel for order events
- Add installer for first-run database initialization
- Add new DB models: admin_user, api_key, chain, chain_token,
  rpc_node, settings, notification_channel
- Add order statistics aggregation (daily/monthly/by-chain)
- Serve built admin SPA from embedded www/ assets
- Add comprehensive test coverage for all new features
2026-04-22 02:28:31 +08:00
Ginta efc8f29af6 issue template 2026-04-21 13:42:07 +08:00
Astra3456 9eaf27298c Add Solana support to token list in README 2026-04-17 13:00:52 +08:00
line-6000 097c716714 Merge pull request #33 from GMwalletApp/dev-payment
- Enable polygon,plasma supports
2026-04-15 18:43:46 +08:00
line-6000 f7c5f678c4 - Enable polygon,plasma supports
- Fix network select
- Fix store evm address logic
2026-04-15 18:36:50 +08:00
Astra3456 3e88d7a40c Enhance README with links to supported systems
Updated supported systems with links and improved clarity.
2026-04-15 05:16:44 +08:00
Astra3456 18dcb96822 Revise Telegram community joining instructions
Updated the description for joining the Telegram community.
2026-04-15 05:01:54 +08:00
Astra3456 4750d63db3 Update README.md 2026-04-15 04:57:14 +08:00
Astra3456 7af7306729 Update README.md 2026-04-15 04:55:24 +08:00
Astra3456 ff16db907a Update README.md 2026-04-15 04:50:49 +08:00
Astra3456 96c262a7dd Update README.md
Since version 0.0.5, EPUSDT has been upgraded to a multi-chain payment solution, not only for TRC20-USDT but also for other tokens.
2026-04-15 04:50:04 +08:00
Astra3456 8e96cc0058 Update README.md
just Readme update,Introduce the new features and add the official website
2026-04-15 04:41:37 +08:00
Ginta 9c003fbead feat: get support chain api 2026-04-15 03:18:38 +08:00
Ginta aff1055743 feat: get support chain api 2026-04-15 02:53:52 +08:00
Ginta f6a8fb47df Merge pull request #31 from GMwalletApp/dev-payment
Dev payment
2026-04-15 02:18:25 +08:00
Ginta d46bc7793e feat: get support chain api 2026-04-15 01:44:38 +08:00
Ginta 9d01443f3e Merge pull request #27 from jiamovo/fix/epay-submit-form-compat
support epay submit form params
2026-04-15 00:03:23 +08:00
Ginta 70f8ed4ea0 Merge pull request #25 from mar-heaven/dev-payment
test: bsc
2026-04-14 21:01:30 +08:00
jiamovo 8cd816c35e support epay submit form params 2026-04-14 20:58:53 +08:00
Ginta 2f6cd34610 feat: get support chain api 2026-04-14 19:03:39 +08:00
Ginta 1e2282ba2e feat: get support chain api 2026-04-14 18:57:38 +08:00
line-6000 786c5e8175 update router for support epay GET and POST 2026-04-14 18:43:20 +08:00
Ginta 5e68bf8309 test: bsc pol plasma 2026-04-13 07:50:07 +08:00
line-6000 5168beda00 Merge pull request #26 from GMwalletApp/dev-payment 2026-04-13 03:57:45 +08:00
line-6000 5e4d5dfae4 feat: add multi-network payment support
- Redesign payment UI with a two-step flow (select coin/network → pay)
  - Add wallet address validation in Telegram bot
  - Rename NetworkEthereum constant from "eth" to "ethereum"
2026-04-13 03:52:45 +08:00
line-6000 32ca778735 add epay route support 2026-04-12 18:14:40 +08:00
line-6000 730ff983f8 Merge pull request #24 from mar-heaven/dev-payment
feat: add Ethereum ERC-20 payment scanning

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

- improve sqlite busy handling and runtime DB tuning

- restore telegram payment notification and wallet input flow

- add behavior-based config and callback recovery tests
2026-03-31 13:54:10 +08:00
alphago9 08f0c93e11 Merge branch 'dev' into dev-runtime 2026-03-30 20:14:29 +08:00
line-6000 938396d82b feat: refactor payment system with multi-currency support and TRX native transfer detection
- Add multi-currency exchange rate API integration (GetRateForCoin)
 - Implement native TRX transfer detection alongside TRC20 USDT transfers
 - Replace deprecated Tronscan API with Trongrid API for better reliability
 - Add TRON Grid API key configuration support
 - Refactor order service to support multiple tokens and currencies
 - Update wallet address locking/unlocking to track token type
 - Add base58 and gjson dependencies for crypto operations
 - Optimize SQLite connection with WAL mode and busy timeout
 - Remove deprecated USDT rate job (replaced by dynamic rate API)
 - Update order data model to include currency and receive address fields
 - Enhance TRC20 callback with improved error handling and logging
2026-03-30 16:19:10 +08:00
alphago9 dea81caa61 build(release): add tag-based release automation and version metadata 2026-03-30 01:37:08 +08:00
alphago9 fc8d58024e refactor: replace redis runtime and queues with sqlite-backed scheduler 2026-03-29 17:26:36 +08:00
line-6000 d6e7927605 feat: use no cgo sqlite as default 2026-03-27 17:04:43 +08:00
line-6000 1e4cf0fdd5 Update README.md 2026-03-24 11:57:41 +08:00
line-6000 d4ed951058 Update README.md 2026-03-24 11:52:34 +08:00
line-6000 0d4b6dc52a Update README.md 2026-03-24 11:51:32 +08:00
ashang c0787d19b3 Merge pull request #15 from CarpCap/master
手动部署文档,修复错误的命令
2026-01-27 15:54:08 +08:00
ashang 0c2f3da947 Merge pull request #14 from AnnonaOrg/main
为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间
2026-01-27 14:13:19 +08:00
CarpCap 89529ce4c1 手动部署文档,修复错误的命令 2026-01-27 13:56:50 +08:00
dbidib 7e777bf1d6 Merge pull request #9 from tmpMeM/main
为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间
2026-01-27 12:19:38 +08:00
dev 9def1ec447 为asynq历史任务在redis中添加生命周期,过期时间为订单过期时间 2026-01-27 12:18:32 +08:00
ashang 5e1cd4e637 Update README.md 2026-01-27 11:05:05 +08:00
ashang 91a65773fd Merge pull request #13 from CarpCap/master
手动运行epusdt教程 版本更新为0.0.3
2026-01-27 11:03:32 +08:00
ashang 28e8e9236f Merge pull request #12 from AnnonaOrg/main
新增支持postgres/sqlite数据库,Dockerfile文件
2026-01-27 11:02:47 +08:00
dbidib 2a39c43cae Merge pull request #8 from tmpMeM/main
更新carbon依赖
2026-01-27 10:27:39 +08:00
dev 14435ba426 更新carbon依赖 2026-01-25 13:03:31 +08:00
ashang 883001bc53 Update README.md 2025-11-27 10:25:25 +08:00
dbidib 89c6d1965a Merge pull request #7 from tmpMeM/main
debug
2025-11-22 17:19:58 +08:00
“dev” 61d3cc819e debug 2025-11-22 17:07:25 +08:00
CarpCap 2aa3d43e57 手动运行epusdt教程 版本更新为0.0.3 2025-09-22 04:29:27 +08:00
ashang c20cacbc64 Update README.md 2025-09-03 09:29:39 +08:00
dbidib e174dac6be Merge pull request #6 from tmpMeM/main
bot poller time.second ,synchronous true
2025-07-23 12:42:50 +08:00
“dev” 011d602ffe bot poller time.second ,synchronous true 2025-07-23 12:39:17 +08:00
ashang 9171884fc0 Update README.md
ad
2025-05-26 11:37:57 +08:00
dbidib fb14ae5387 Merge pull request #5 from tmpMeM/main
debug
2025-04-07 09:32:07 +08:00
“dev” 1ab580ea13 debug 2025-04-07 09:31:04 +08:00
dbidib d2d1f1dbbb Merge pull request #4 from tmpMeM/main
debug
2025-04-07 09:26:46 +08:00
“dev” 9b08e49ba1 debug 2025-04-07 09:25:22 +08:00
dbidib 0c6c76c0c3 Merge pull request #3 from tmpMeM/main
remove port
2025-02-13 23:23:24 +08:00
“dev” 84fc330ce1 remove port 2025-02-13 23:21:47 +08:00
ashang 1cded44570 Update README.md
反诈提醒
2024-12-27 16:55:19 +08:00
dbidib 1697b6f410 Merge pull request #2 from tmpMeM/main
修改数据库连接失败后的处理逻辑为报错,然后退出程序
2024-10-14 21:21:58 +08:00
noreply 67206df6a7 修改数据库连接失败后的处理逻辑为报错,然后退出程序 2024-10-14 21:20:46 +08:00
dbidib 4ce58c836c Merge pull request #1 from tmpMeM/main
修改数据库连接失败挂起的逻辑为10s后重试
2024-10-14 20:16:00 +08:00
noreply 392c922130 修改数据库连接失败挂起的逻辑为10s后重试 2024-10-14 20:14:10 +08:00
noreply b697409ec0 修改数据库连接失败挂起的逻辑为10s后重试 2024-10-14 20:12:34 +08:00
ashang 6bab71abb4 remove ad 2024-08-30 10:08:40 +08:00
noreply c09886f6da debug 2024-07-29 12:25:13 +08:00
noreply ab443fd808 debug 2024-07-29 10:27:58 +08:00
noreply e386591e0c debug 2024-07-29 10:25:07 +08:00
noreply 3d02b41dba add sqlite 2024-07-29 10:07:22 +08:00
ashang 0487bd0a45 Update README.md
ad
2024-05-23 21:25:41 +08:00
noreply 2cb9ed4393 README 2024-04-17 11:30:54 +08:00
noreply 136af2676a README 2024-04-17 11:24:12 +08:00
noreply ed0a7f7d07 debug 2024-04-16 23:55:11 +08:00
noreply d3ed2c927e 新增 订单回调失败最大重试次数 配置项 2024-04-16 20:32:04 +08:00
noreply 9031240e2a del env 2024-04-10 11:06:49 +08:00
noreply aae8308973 add pg conf 2024-04-10 11:00:50 +08:00
ashang 71f0a9cdc3 Update README.md 2023-07-13 16:46:45 +08:00
ashang 254a38fa33 Merge pull request #9 from tbxark-archive/master
fix: mysql密码中有特殊字符导致拼接的MysqlDns错误
2023-07-13 16:46:13 +08:00
TBXark 2b805ff770 fix: mysql密码中有特殊字符导致拼接的MysqlDns错误 2023-05-22 14:26:23 +08:00
ashang 0c4249634c Update README.md 2023-05-13 12:22:42 +08:00
ashang 6789315efd Update README.md 2022-12-15 13:32:10 +08:00
337 changed files with 19833 additions and 2068 deletions
+111
View File
@@ -0,0 +1,111 @@
name: Bug 反馈
description: 有一个 bug 需要解决
title: "发现bug"
labels:
- bug
body:
- type: markdown
attributes:
value: |
# 提 ISSUE 须知
请先阅读文档:
- [epusdt 文档](https://epusdt.com/)
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
- type: checkboxes
id: checks
attributes:
label: 提交前检查
options:
- label: 我已阅读文档并检查常见问题
required: true
- label: 我已搜索现有 issue,确认不是重复问题
required: true
- label: 我已尽量提供可复现信息(代码、配置、日志)
required: true
- type: textarea
id: epusdt_version
attributes:
label: 1. epusdt 版本
description: 请提供版本号或 commit hash,避免只写“最新版”
placeholder: |
例如:
- v1.2.3
- commit: abcdef1
validations:
required: true
- type: textarea
id: runtime
attributes:
label: 2. 运行环境版本
description: 请提供语言/运行时及相关依赖版本
placeholder: |
例如:
- Go/Python/Node 版本:
- 数据库及版本:
- Docker 版本(如有):
validations:
required: true
- type: textarea
id: os_info
attributes:
label: 3. 操作系统信息
description: Windows / Linux / macOS 或其他系统,请写明版本
placeholder: |
例如:
- Ubuntu 22.04
- macOS 14.6
- Windows Server 2022
validations:
required: true
- type: textarea
id: repro_code
attributes:
label: 4. 可复现代码/配置
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
render: shell
placeholder: |
# 最小复现步骤
1. ...
2. ...
3. ...
# 关键请求/配置(脱敏后)
curl -X POST https://xxx/api/...
-H "Authorization: Bearer ***"
-d '{"amount":100,"order_id":"test-001"}'
validations:
required: true
- type: textarea
id: stacktrace
attributes:
label: 5. 错误日志 / 堆栈信息
description: 请粘贴完整报错信息、状态码、关键日志
render: shell
placeholder: |
例如:
- HTTP 状态码:
- 错误码:
- 错误堆栈:
- 关键日志片段:
validations:
required: true
- type: textarea
id: extra
attributes:
label: 补充信息
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
placeholder: 其他你认为有帮助的信息(可选)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 安全漏洞报告
url: https://github.com/ginta/epusdt/security/advisories/new
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
+57
View File
@@ -0,0 +1,57 @@
name: docker-alpine-epusdt
on:
workflow_dispatch:
push:
branches:
- "main"
- "master"
tags:
- "v*"
env:
DOCKERHUB_NAMESPACE: gmwallet
IMAGE_NAME: epusdt
jobs:
push-docker-hub:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=alpine
type=raw,value=latest
type=ref,event=branch,suffix=-alpine
type=ref,event=tag
type=sha,prefix=sha-,suffix=-alpine
- name: Login To Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build And Push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
API_RATE_URL=${{ vars.API_RATE_URL }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+86
View File
@@ -0,0 +1,86 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
required: true
type: string
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
goreleaser:
name: Build And Publish Binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
- name: Ensure Tag Commit Is On Default Branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "$DEFAULT_BRANCH" --depth=1
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then
echo "tag ${GITHUB_REF_NAME} must reference a commit reachable from ${DEFAULT_BRANCH}"
exit 1
fi
- name: Set Up Go
uses: actions/setup-go@v6
with:
go-version-file: src/go.mod
- name: Select Previous Stable Tag For Final Releases
run: |
current_tag="${GITHUB_REF_NAME}"
if [[ "$current_tag" == *-* ]]; then
echo "prerelease tag detected: $current_tag"
exit 0
fi
previous_stable_tag="$(
git tag --list 'v*' --sort=-version:refname |
grep -Fxv "$current_tag" |
grep -Ev -- '-' |
head -n 1
)"
if [[ -n "$previous_stable_tag" ]]; then
echo "using previous stable tag: $previous_stable_tag"
echo "GORELEASER_PREVIOUS_TAG=$previous_stable_tag" >> "$GITHUB_ENV"
else
echo "no previous stable tag found"
fi
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
workdir: src
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Attest Release Artifacts
uses: actions/attest@v4
with:
subject-checksums: src/dist/SHA256SUMS
+4
View File
@@ -1,4 +1,8 @@
.idea/
src/runtime/
src/.env
src/*.db
src/*.db-shm
src/*.db-wal
.DS_Store
.env
+157
View File
@@ -0,0 +1,157 @@
# Development Standard
## Purpose
This document defines the default development goals, review criteria, and testing constraints for changes in this repository.
Before starting any implementation, use this document as a checklist.
## Development Goals
Every change should be judged against all of the following goals:
1. Functional completeness
The target feature must actually work end to end, not just compile.
2. No accidental teammate regression
Do not accidentally remove or override teammate capabilities that already exist on the base branch.
3. Robustness
The code should handle normal failure cases, retries, state conflicts, and restart scenarios as reasonably as possible.
4. Elegance
Prefer clear responsibilities, minimal coupling, and a small number of well-named moving parts.
5. Maintainability
Avoid hidden behavior, confusing configuration, or implementation details that make future debugging harder.
6. No dirty output
Never commit local runtime artifacts, local databases, caches, logs, or other development byproducts.
7. No mojibake
Source files, messages, and docs should not contain broken encoding text.
## Multi-Dimensional Self Review
Each meaningful change should be evaluated across these dimensions:
- functional completeness
- engineering implementation quality
- robustness
- elegance
- maintainability
- regression risk
- commit readiness
- test coverage quality
Scoring should be honest. High scores require both working behavior and clean delivery quality.
## Code Quality Rules
### Redundancy
Avoid redundant code unless duplication is clearly cheaper and safer than abstraction.
The following are considered bad redundancy:
- duplicate business logic with only tiny naming differences
- duplicate retry or state logic in multiple places
- helper functions that exist only to wrap one line without improving clarity
The following are acceptable:
- small data shapes for read optimization
- isolated helpers that remove repeated infrastructure setup
- compatibility shims with clear removal intent
### Elegance
Code is considered elegant when:
- responsibilities are split cleanly
- names explain intent
- state transitions are explicit
- configuration is understandable
- behavior is observable through useful logs
- failure handling does not hide important problems
### Robustness
Code is considered robust when:
- state changes are guarded by conditions where needed
- retries are controlled, not infinite
- restart recovery is possible where it matters
- local concurrency does not easily corrupt behavior
- failure in one side task does not silently break the main flow
## Bug Standard
Ask these questions before treating a change as finished:
- Is there any blocking functional bug?
- Is there any likely edge-case bug?
- Is there any operational bug under realistic local concurrency?
- Is there any user-visible regression?
If a bug is known, it must be called out explicitly in review notes.
## Testing Rules
### Required Principle
Tests must validate behavior, not re-implement the code under test.
### Forbidden Test Types
Do not write:
- invalid tests
- mirror tests
- tests that only repeat implementation branches line by line
- tests that only verify mocks interacted in the same order as the code was written
- tests that lock onto incidental internals instead of real behavior
### Preferred Test Types
Prefer:
- end-to-end behavior tests
- state transition tests
- concurrency conflict tests
- retry and recovery tests
- expiration and timing behavior tests
- persistence and restart-oriented tests where relevant
### Test Coverage Expectations
Coverage quality matters more than raw coverage count.
For core flows, tests should cover:
- normal success path
- important failure path
- state conflict or idempotency path
- retry or delayed processing path where relevant
## Review Checklist
Before considering a change ready, confirm:
- functionality is complete
- teammate capability was not accidentally lost
- no local artifact will be committed
- no mojibake exists
- no obvious redundant code exists
- no known blocking bug remains
- robustness is acceptable for the target deployment model
- tests are behavior-based and not mirror tests
- review notes clearly explain intentional semantic changes
## Current Project Direction
For this repository specifically, current review should assume:
- single-instance deployment is the primary target unless stated otherwise
- runtime semantics may intentionally differ from older Redis and asynq behavior when the new SQLite design is deliberate
- any such semantic difference must be explained in merge or review notes
+27
View File
@@ -0,0 +1,27 @@
FROM golang:alpine AS builder
RUN apk add --no-cache --update git build-base
ENV CGO_ENABLED=0
WORKDIR /app
COPY . /app
WORKDIR /app/src
RUN go mod download
RUN go build -o /app/epusdt .
FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata
ARG API_RATE_URL=""
WORKDIR /app
COPY --from=builder /app/src/.env.example /app/.env
RUN if [ -n "$API_RATE_URL" ]; then \
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
fi
COPY --from=builder /app/epusdt .
VOLUME /app/conf
ENTRYPOINT ["./epusdt", "http", "start"]
+150 -71
View File
@@ -1,91 +1,170 @@
## Epusdt (Easy Payment Usdt)
# Epusdt Easy Payment USDT
<p align="center">
<img src="wiki/img/usdtlogo.png">
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p>
<p align="center">
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
</p>
## 项目简介
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql``redis`
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
<p align="center">
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
</p>
## 项目特点
- 支持私有化部署,无需担心钱包被篡改和吞单😁
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
- 多钱包地址轮询,提高订单并发率
- 异步队列响应,优雅及高性能
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
- 支持`http api`,其他系统亦可接入
- `Telegram`机器人接入,便捷使用和支付消息快速通知
<p align="center">
<a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
</p>
---
## 🌍 What is Epusdt?
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
### 🔗 已支持网络与代币
| 网络 | 代币 |
|------|------|
| **TRC20** (Tron) | USDT、TRX|
| **ERC20** (Ethereum) | USDT、USDC、ETH |
| **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
---
## 🔌 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
| 领域 | 已支持系统 |
|------|-----------|
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
| **更多** | 简易HTTP API 10分钟内接入 |
👉 查看更多集成列表与插件:[plugins/](plugins/)
---
## ✨ 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
- **异步队列** — 高性能消息回调,优雅处理高并发场景
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
- **Telegram Bot** — 实时支付通知,快捷管理与监控
---
## 📖 文档与教程
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
快速入门:
| 教程 | 说明 |
|------|------|
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
---
## 🏗️ 项目结构
## 项目结构
```
Epusdt
├── plugins ---> (已集成的插件库,例如dujiaoka)
├── src ---> (项目核心目录)
├── sdk ---> (接入SDK)
├── sql ---> (安装sql文件或更新sql文件)
└── wiki ---> (知识库)
├── plugins/ → 已集成的系统插件(独角数卡等)
├── src/ → 项目核心代码
├── sdk/ → 接入 SDK
├── sql/ → 数据库安装 / 升级脚本
└── wiki/ → 文档与知识库
```
## 教程:
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
- 不好意思我有洁癖,手动运行`epusdt`教程👉🏻[手动运行epusdt](wiki/manual_RUN.md)
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
- HTML+PHP极速运行`epusdt`教程👉🏻[使用PHPAPI-for-epusdt极速接入epusdt](https://github.com/BlueSkyXN/PHPAPI-for-epusdt)
---
## 已适配系统插件
- 独角数卡[插件地址](plugins/dujiaoka)
## 🔧 实现原理
## 🔥推荐服务器
- (美国免备案vps,配置2核2G仅需`20.98$``145RMB`一年/支持支付宝付款)[👉🏻点我直达](https://my.racknerd.com/aff.php?aff=2745&pid=681)
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
## 加入交流/意见反馈
- `Epusdt`频道[https://t.me/epusdt](https://t.me/epusdt)
- `Epusdt`交流群组[https://t.me/epusdt_group](https://t.me/epusdt_group)
## 设计实现
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
监听钱包地址`usdt`代币入账事件,通过`金额差异``时效性`来判定交易归属信息,
可参考下方`流程图`
```
简单的原理
1.客户需支付20.05usdt
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
3.发起支付的时候,我们可以判定钱包address_120.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功
工作流程
1. 客户发起支付,需支付 20.05 USDT
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
3.address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
```
### 流程图:
![Implementation principle](wiki/img/implementation_principle.jpg)
## 打赏
如果该项目对您有所帮助,希望可以请我喝一杯咖啡☕️
```
Usdt(trc20)打赏地址: TNEns8t9jbWENbStkQdVQtHMGpbsYsQjZK
```
<img src="wiki/img/usdt_thanks.jpeg" width = "300" height = "400" alt="usdt扫码打赏"/>
![Epusdt 支付流程图](wiki/img/implementation_principle.jpg)
---
## 💬 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
## 声明
`Epusdt`为开源的产品,仅用于学习交流使用!
不可用于任何违反中华人民共和国(含台湾省)或使用者所在地区法律法规的用途。
因为作者即本人仅完成代码的开发和开源活动(开源即任何人都可以下载使用或修改分发),从未参与用户的任何运营和盈利活动。
且不知晓用户后续将程序源代码用于何种用途,故用户使用过程中所带来的任何法律责任即由用户自己承担。
```
!!!Warning!!!
项目中所涉及区块链代币均为学习用途,作者并不赞成区块链所繁衍出代币的金融属性
亦不鼓励和支持任何"挖矿""炒币""虚拟币ICO"等非法行为
虚拟币市场行为不受监管要求和控制,投资交易需谨慎,仅供学习区块链知识
```
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
| 渠道 | 链接 |
|------|------|
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
---
## Star History
<a href="https://www.star-history.com/?type=date&repos=gmwalletapp%2Fepusdt">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
</picture>
</a>
---
## 📜 开源协议
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
---
## ⚠️ 免责声明
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GM Wallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,所有代码均按原样提供,仅供教育用途,相关决策请咨询专业人士。
---
<p align="center">
<sub>
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
</sub>
</p>
+70
View File
@@ -0,0 +1,70 @@
# Releasing
This repository publishes versioned binaries to GitHub Releases when a tag is pushed.
## What gets published
For each tag like `v1.2.3`, GitHub Actions builds release archives for:
- `linux/amd64`
- `linux/arm64`
- `darwin/amd64`
- `darwin/arm64`
- `windows/amd64`
- `windows/arm64`
Each archive includes:
- the `epusdt` binary
- `src/.env.example`
- `src/static/`
Windows artifacts are published as `.zip`. Other platforms are published as `.tar.gz`.
Each release also publishes:
- a `SHA256SUMS` checksum file
- a GitHub build provenance attestation for the checksum file
## How to cut a release
1. Make sure the release commit has already landed on the default branch.
2. Create an annotated tag:
```bash
git tag -a v1.0.0 -m "release v1.0.0"
```
3. Push the tag:
```bash
git push origin v1.0.0
```
4. Wait for the `release` workflow to finish on GitHub.
5. Download the generated binaries from the GitHub Release page.
## Verify a release
Verify the GitHub build provenance attestation:
```bash
gh attestation verify SHA256SUMS -R GMwalletApp/epusdt
```
## Local validation
Run a snapshot build locally before pushing a tag:
```bash
cd src
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean
```
The resulting artifacts will be generated under `src/dist/`.
## Release candidates
If you use tags like `v1.2.3-rc1`, GoReleaser will mark the GitHub release as a prerelease.
For a final tag like `v1.2.3`, the release workflow forces GoReleaser to compare against the previous stable tag instead of the latest `rc` tag. This keeps the final changelog focused on stable-to-stable changes.
+208
View File
@@ -0,0 +1,208 @@
# Review
## Scope
This review checks `dev-runtime` against `dev` with the following standards:
- Do not accidentally remove or override teammate payment capabilities already merged into `dev`.
- Do not submit local test artifacts or dirty files.
- Confirm Redis -> SQLite replacement is functionally complete for the intended single-instance target.
- Judge code quality, elegance, robustness, and maintainability.
- Check for real bugs, mojibake, and redundant code.
- Compare current quality with the previous `dev` implementation.
These standards are also the default development constraints for future work:
- no invalid tests
- no mirror tests
- prefer behavior-based tests over implementation-shaped tests
- explain intentional semantic changes in review notes
- verify dirty local artifacts are excluded before commit
- think about teammate-regression risk before coding, not only after coding
## Files That Must Not Be Committed
These local SQLite artifacts must never be committed:
- `src/epusdt.db`
- `src/epusdt.db-shm`
- `src/epusdt.db-wal`
They are now excluded by `.gitignore`.
## Teammate Capabilities Still Preserved
The following teammate payment capabilities are still present:
- multi-currency order model: `src/model/mdb/orders_mdb.go`
- wallet address model: `src/model/mdb/wallet_address_mdb.go`
- native `TRX` transfer detection: `src/model/service/task_service.go`
- `TRC20 USDT` detection: `src/model/service/task_service.go`
- dynamic exchange-rate path: `src/model/service/order_service.go`, `src/config/config.go`
- `TRON_GRID_API_KEY` support: `src/config/config.go`
Conclusion: teammate core payment capabilities were not accidentally removed.
## Intentional Architecture Changes
These are intentional refactors, not accidental overrides:
- Redis runtime removed: `src/model/dao/rdb.go`
- asynq handlers removed: `src/mq/handle/callback_queue.go`, `src/mq/handle/order_expiration_queue.go`
- SQLite runtime lock introduced: `src/model/mdb/transaction_lock_mdb.go`
- SQLite scheduler introduced: `src/mq/queue.go`, `src/mq/worker.go`
- reservation moved from Redis key to SQLite unique constraint: `src/model/data/order_data.go`
Conclusion: this branch keeps payment capability but changes the runtime implementation.
## Previously Regressed Parts That Are Now Restored
The following regressions have been restored in the current working tree:
- rich Telegram payment notification template: `src/model/service/task_service.go`
- key chain-scan observability logs: `src/model/service/task_service.go`
- Chinese checkout error message: `src/model/service/pay_service.go`
- more reliable Telegram add-wallet flow: `src/telegram/handle.go`
## Functional Completion
For the single-instance target, the Redis -> SQLite replacement is complete across:
- order creation
- amount reservation
- chain scan matching
- payment success processing
- order expiration
- callback scheduling
- Telegram notification
Conclusion: feature work is complete for the intended single-instance model.
## Mojibake Check
No real mojibake was found in the source files checked during review.
Files spot-checked:
- `src/telegram/handle.go`
- `src/model/service/task_service.go`
- `src/model/service/pay_service.go`
Note: some terminals may render UTF-8 Chinese poorly, but the source content itself is fine.
## Redundant Code Check
No obvious redundant business code was found.
Recent helper additions are justified:
- `PendingCallbackOrder`
- `expirableOrder`
- `configureSQLite`
Conclusion: no clear "fix by duplication" smell was found.
## Bug Assessment
No confirmed blocking business bug is currently visible in the reviewed code path.
Important risk that still needs attention:
- SQLite main DB can still hit `SQLITE_BUSY` under concurrent polling and writes.
This risk is now being mitigated by:
- `src/model/dao/sqlite_config.go`
- `src/model/dao/mdb_sqlite.go`
- `src/model/dao/runtime_sqlite.go`
- lightweight busy retry in `src/mq/worker.go`
Conclusion: no confirmed blocking functional bug, but SQLite concurrency remains a real operational boundary that should still be watched in runtime.
## Robustness Assessment
Strengths:
- conditional state transitions prevent reviving expired orders
- reservation is now a persistent unique constraint instead of a temporary Redis key
- callback state is durable across process restarts
- behavior tests exist for key paths:
- `src/model/service/order_service_test.go`
- `src/mq/worker_test.go`
- callback polling now has a small targeted retry for transient SQLite busy errors
Remaining limits:
- robust for single-instance deployment
- not a final design for multi-instance deployment
- SQLite contention should still be watched in real runtime
Conclusion: robust enough for single-instance use, not a direct multi-instance design.
## Elegance Assessment
Strengths:
- runtime responsibility is cleaner with SQLite reservation + scheduler
- logging responsibility is split more clearly:
- `log_level`
- `http_access_log`
- `sql_debug`
- Telegram input flow is more practical for real clients
- SQLite tuning is extracted into one helper
Tradeoffs:
- callback semantics are no longer the same as the old immediate-asynq enqueue path
- current branch still has local uncommitted fixes that must be organized into a clean commit set
Conclusion: elegant for the chosen single-instance direction, but must be explained clearly when merging.
## Quality Compared With Previous Code
Overall judgment: quality did not go down; it improved in the single-instance direction.
Improvements:
- stronger state-machine constraints
- better log control
- more durable runtime reservation
- better Telegram interaction resilience
- added behavior tests
- added restart-oriented callback recovery coverage
- reduced scheduler fragility under transient SQLite busy errors
Changed semantics rather than degradation:
- merchant callback is now polled by SQLite scheduler instead of immediate asynq enqueue
Conclusion: quality is not lower, but runtime semantics changed and must be stated explicitly.
## Self Score
- functional completeness: 9.1/10
- engineering implementation: 8.9/10
- robustness: 8.7/10
- elegance: 9.0/10
- commit readiness: 8.8/10
Overall self score: 8.9/10
Main deductions:
- current uncommitted fixes still need to be organized into a clean commit
- SQLite single-file concurrency boundary still exists
- callback semantics differ from `dev-payment` and require clear communication
## Final Conclusion
This code can be submitted, but only if all of the following are done:
1. Do not commit local SQLite artifacts.
2. Include the current uncommitted fixes in a clean, intentional commit set.
3. Explain clearly in merge notes:
- what teammate capabilities were preserved
- what runtime behavior was intentionally refactored
- what semantics changed and why
If those three conditions are met, this is not a hidden override of teammate code. It is a reviewed and documented refactor.
+11
View File
@@ -0,0 +1,11 @@
services:
epusdt:
image: gmwallet/epusdt:latest
restart: always
build:
context: .
dockerfile: Dockerfile
volumes:
- ./env:/app/.env
ports:
- "8000:8000"
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
+2 -2
View File
@@ -2,8 +2,8 @@
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @author GMWalletApp<ashang@utf8.hk>
* @copyright GMWalletApp<ashang@utf8.hk>
* @link http://utf8.hk/
*/
use Illuminate\Support\Facades\Route;
+23 -45
View File
@@ -1,60 +1,38 @@
app_name=epusdt
#下面配置你的域名,收银台会需要
app_uri=https://dujiaoka.com
#是否开启debug,默认false
app_debug=false
#http服务监听端口
app_uri=https://epusdt.com
log_level=info
http_access_log=false
sql_debug=false
http_listen=:8000
#静态资源文件目录
static_path=/static
#缓存路径
runtime_root_path=/runtime
runtime_root_path=./runtime
#日志配置
log_save_path=/logs
log_save_path=./logs
log_max_size=32
log_max_age=7
max_backups=3
# mysql配置
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql账号
mysql_passwd=mysql密码
mysql_database=数据库
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# supported values: postgres,mysql,sqlite
db_type=sqlite
# 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
# sqlite primary database config
sqlite_database_filename=
sqlite_table_prefix=
# 消息队列配置
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# background scheduler config
queue_concurrency=10
queue_level_critical=6
queue_level_default=3
queue_level_low=1
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
#机器人Apitoken
tg_bot_token=
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
tg_proxy=
#管理员userid
tg_manage=
#api接口认证token
api_auth_token=
#订单过期时间(单位分钟)
order_expiration_time=10
order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate=
api_rate_url=
# Set to true to re-run the install wizard on next startup.
install=true
+1
View File
@@ -1,2 +1,3 @@
.DS_Store
dist/
*.log
+42 -24
View File
@@ -1,44 +1,62 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
version: 2
project_name: epusdt
before:
hooks:
# You may remove this if you don't use go modules.
#- go mod tidy
# you may remove this if you don't need go generate
#- go generate ./...
- go test ./...
builds:
- env:
- id: epusdt
main: .
binary: epusdt
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
goos:
- darwin
- linux
- darwin
- windows
goarch:
- amd64
- arm
- 386
- arm64
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
id: epusdt
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format: tar.gz
- id: epusdt
ids:
- epusdt
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
files:
- .env.example
- static/*
- runtime/*
- static/**
format_overrides:
- goos: windows
formats:
- zip
checksum:
name_template: 'checksums.txt'
name_template: "SHA256SUMS"
snapshot:
name_template: "{{ incpatch .Version }}-next"
release:
github:
owner: GMwalletApp
name: epusdt
prerelease: auto
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
- "^fix\\(ci\\):"
- "^build\\(release\\):"
- "^Merge pull request"
+55 -23
View File
@@ -1,33 +1,65 @@
package bootstrap
import (
"github.com/assimon/luuu/command"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/task"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/log"
"sync"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/mq"
"github.com/GMWalletApp/epusdt/task"
"github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
)
//Start 服务启动
func Start() {
// 配置加载
var initOnce sync.Once
func InitApp() {
initOnce.Do(func() {
config.Init()
// 日志加载
log.Init()
// Mysql启动
dao.MysqlInit()
// redis启动
dao.RedisInit()
// 队列启动
mq.Start()
// telegram机器人启动
go telegram.BotStart()
// 定时任务
go task.Start()
err := command.Execute()
dao.Init()
// Wire settings-table lookups into the config package so
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
// Seed rate.api_url from .env into the settings table on first run
// so the admin UI can display and change it without a code restart.
// Only written if the key is not already present in the DB.
if data.GetSettingString("rate.api_url", "") == "" {
if envURL := config.GetRateApiUrlFromEnv(); envURL != "" {
if err := data.SetSetting("rate", "rate.api_url", envURL, "string"); err != nil {
color.Red.Printf("[bootstrap] seed rate.api_url err=%s\n", err)
}
}
}
// Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent.
_, isNew, err := data.EnsureDefaultAdmin()
if err != nil {
panic(err)
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
}
if isNew {
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
color.Yellow.Printf("║ Username: admin ║\n")
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
}
if _, err := appjwt.EnsureSecret(); err != nil {
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
}
// Seed one universal default API key on fresh installs. The seeded
// key (PID=1000) works for all three gateway flows.
_, err = data.EnsureDefaultApiKey()
if err != nil {
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
}
mq.Start()
go telegram.BotStart()
go task.Start()
})
}
+18
View File
@@ -0,0 +1,18 @@
package command
import (
"github.com/GMWalletApp/epusdt/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",
)
}
+75 -27
View File
@@ -2,26 +2,31 @@ package command
import (
"context"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/middleware"
"github.com/assimon/luuu/route"
"github.com/assimon/luuu/util/constant"
luluHttp "github.com/assimon/luuu/util/http"
"github.com/assimon/luuu/util/log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/GMWalletApp/epusdt/bootstrap"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/install"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/route"
"github.com/GMWalletApp/epusdt/util/constant"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
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 +37,19 @@ func init() {
var startCmd = &cobra.Command{
Use: "start",
Short: "启动",
Long: "启动http服务",
Short: "start",
Long: "start http service",
Run: func(cmd *cobra.Command, args []string) {
// If no config file exists, or if install=true is set in the config,
// run the first-run install API on the same port as the main server.
// The wizard writes the .env (with install=false) and shuts itself
// down so bootstrap.InitApp() can read it normally on the same port.
if config.NeedsInstall() {
envPath, _ := config.ResolveConfigPath()
install.RunInstallServer(install.DefaultInstallAddr, envPath)
}
bootstrap.InitApp()
printBanner()
HttpServerStart()
},
}
@@ -44,23 +59,45 @@ 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)
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
Skipper: func(c echo.Context) bool {
path := c.Request().URL.Path
if path == "/install" || strings.HasPrefix(path, "/install/") {
// The install wizard is only served by install.RunInstallServer
// before bootstrap. Once main server starts, block /install.
return true
}
return luluHttp.ShouldSkipSPAFallback(path)
},
HTML5: true,
Index: "index.html",
Root: wwwRoot,
}))
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 +105,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"
@@ -86,14 +120,28 @@ func customHTTPErrorHandler(err error, e echo.Context) {
Message: msg,
RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
}
// echo.HTTPError carries a real HTTP status (401 for auth failures,
// 404 for missing routes, etc.). Propagate it instead of flattening
// everything to 200 — clients rely on the status code.
if he, ok := err.(*echo.HTTPError); ok {
e.String(http.StatusOK, he.Message.(string))
resp.StatusCode = he.Code
if s, ok := he.Message.(string); ok {
resp.Message = s
} else if he.Message != nil {
resp.Message = http.StatusText(he.Code)
}
_ = e.JSON(he.Code, resp)
return
}
// Internal RspError: propagate Code as both the JSON status_code and
// the real HTTP status when it maps to one (400/401/...); business
// codes (>=1000) map to HTTP 400 so clients get a proper 4xx while
// still reading the granular code from the body.
if he, ok := err.(*constant.RspError); ok {
resp.StatusCode = he.Code
resp.Message = he.Msg
}
_ = e.JSON(http.StatusOK, resp)
_ = e.JSON(he.HttpStatus(), resp)
return
}
_ = e.JSON(http.StatusInternalServerError, resp)
}
+10 -1
View File
@@ -1,6 +1,11 @@
package command
import "github.com/spf13/cobra"
import (
"github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra"
)
var configPath string
var rootCmd = &cobra.Command{}
@@ -9,5 +14,9 @@ func Execute() error {
}
func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env")
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
config.SetConfigPath(configPath)
}
rootCmd.AddCommand(httpCmd)
}
+26
View File
@@ -0,0 +1,26 @@
package command
import (
"fmt"
"github.com/GMWalletApp/epusdt/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)
}
+419 -32
View File
@@ -1,60 +1,312 @@
package config
import (
"errors"
"fmt"
"github.com/spf13/viper"
"io"
"log"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
)
// parseFloat is the minimal helper used by the settings bridge; kept
// here so settings_bridge.go stays import-free.
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
var (
AppDebug bool
HTTPAccessLog bool
SQLDebug bool
LogLevel string
MysqlDns string
RuntimePath string
LogSavePath string
StaticPath string
StaticFilePath string
TgBotToken string
TgProxy string
TgManage int64
UsdtRate float64
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, "/"))
if err = ensureConfiguredStaticFiles(); err != nil {
panic(err)
}
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",
viper.GetString("mysql_user"),
viper.GetString("mysql_passwd"),
fmt.Sprintf(
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
url.QueryEscape(viper.GetString("mysql_user")),
url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
viper.GetString("mysql_database"))
TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage")
}
func mustMkdir(path string) {
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
}
func ensureConfiguredStaticFiles() error {
if strings.TrimSpace(StaticFilePath) == "" {
return nil
}
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return err
}
srcDir := filepath.Join(filepath.Dir(exePath), "static")
srcInfo, err := os.Stat(srcDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !srcInfo.IsDir() {
return nil
}
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return err
}
dstAbs, err := filepath.Abs(StaticFilePath)
if err != nil {
return err
}
if srcAbs == dstAbs {
return nil
}
return copyMissingStaticFiles(srcAbs, dstAbs)
}
func copyMissingStaticFiles(srcDir, dstDir string) error {
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, rel)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if _, err = os.Stat(dstPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
return copyFile(path, dstPath)
})
}
func copyFile(srcPath, dstPath string) error {
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil
}
return err
}
if _, err = io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
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")
}
// NeedsInstall returns true when the first-run install wizard should run.
// It returns true if the config file does not exist yet, or if the file
// exists and contains install=true (an explicit reset flag).
// Existing installs that have no install key are NOT affected.
func NeedsInstall() bool {
envPath, exists := ResolveConfigPath()
if !exists {
return true
}
// Read just the install key without triggering full Init().
v := viper.New()
v.SetConfigFile(envPath)
if err := v.ReadInConfig(); err != nil {
// Unreadable config → treat as needing install.
return true
}
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
}
// ResolveConfigPath returns the absolute path to the .env file and whether
// it currently exists on disk. Unlike resolveConfigFilePath it does not
// return an error when the file is absent — callers (e.g. the install
// wizard) need the target path even before the file is written.
func ResolveConfigPath() (path string, exists bool) {
candidate := explicitConfigPath
if candidate == "" {
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
candidate = e
} else {
candidate = ".env"
}
}
// Resolve relative paths against cwd.
p := strings.TrimSpace(candidate)
if !filepath.IsAbs(p) {
cwd, err := os.Getwd()
if err == nil {
p = filepath.Join(cwd, p)
}
}
info, err := os.Stat(p)
if err == nil && info.IsDir() {
p = filepath.Join(p, ".env")
info, err = os.Stat(p)
}
if err != nil {
return p, false
}
return p, true
}
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 {
@@ -69,19 +321,98 @@ func GetAppUri() string {
return viper.GetString("app_uri")
}
func GetApiAuthToken() string {
return viper.GetString("api_auth_token")
func GetRateApiUrl() string {
// settings table wins (admin-configurable); .env and env var remain
// as fallbacks for smooth migration from the old layout.
if db := settingsRateApiUrl(); db != "" {
return db
}
return GetRateApiUrlFromEnv()
}
// GetRateApiUrlFromEnv returns the rate API URL read only from the
// .env / environment variables, bypassing the settings table. Used
// by bootstrap to seed the settings table from the initial .env value.
func GetRateApiUrlFromEnv() string {
rateURL := viper.GetString("api_rate_url")
if rateURL == "" {
rateURL = os.Getenv("API_RATE_URL")
}
if rateURL == "" {
log.Println("api_rate_url is empty")
}
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
}
return 0
}
}
return getRateForCoinFromAPI(coin, base)
}
func getRateForCoinFromAPI(coin string, base string) float64 {
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 {
return forcedUsdtRate
// Only the admin setting can force the USDT/CNY rate. When the
// setting is unset, zero, or negative, fall back to the rate API.
if forced := settingsForcedUsdtRate(); forced > 0 {
return forced
}
if UsdtRate <= 0 {
return 6.4
apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 {
return 1 / apiRate
}
return UsdtRate
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
return 0
}
func GetOrderExpirationTime() int {
@@ -96,3 +427,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
}
+356
View File
@@ -0,0 +1,356 @@
package config
import (
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
"github.com/spf13/viper"
)
func installSettingsGetter(t *testing.T, values map[string]string) {
t.Helper()
oldGetter := SettingsGetString
SettingsGetString = func(key string) string {
return values[key]
}
t.Cleanup(func() {
SettingsGetString = oldGetter
})
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
t.Helper()
root := t.TempDir()
configPath := filepath.Join(root, "custom.env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := normalizeConfiguredPath(configPath)
if err != nil {
t.Fatalf("normalize explicit file: %v", err)
}
if got != configPath {
t.Fatalf("config path = %s, want %s", got, configPath)
}
}
func TestNormalizeConfiguredPathUsesExplicitDirectory(t *testing.T) {
t.Helper()
root := t.TempDir()
configPath := filepath.Join(root, ".env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := normalizeConfiguredPath(root)
if err != nil {
t.Fatalf("normalize explicit directory: %v", err)
}
if got != configPath {
t.Fatalf("config path = %s, want %s", got, configPath)
}
}
func TestResolveConfigFilePathUsesCurrentDirectoryByDefault(t *testing.T) {
t.Helper()
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("get cwd: %v", err)
}
defer func() { _ = os.Chdir(oldCwd) }()
root := t.TempDir()
configPath := filepath.Join(root, ".env")
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Setenv("EPUSDT_CONFIG", "")
SetConfigPath("")
got, err := resolveConfigFilePath()
if err != nil {
t.Fatalf("resolve config path: %v", err)
}
gotAbs, err := filepath.Abs(got)
if err != nil {
t.Fatalf("abs got: %v", err)
}
wantAbs, err := filepath.Abs(configPath)
if err != nil {
t.Fatalf("abs want: %v", err)
}
gotReal, err := filepath.EvalSymlinks(gotAbs)
if err != nil {
t.Fatalf("eval symlinks got: %v", err)
}
wantReal, err := filepath.EvalSymlinks(wantAbs)
if err != nil {
t.Fatalf("eval symlinks want: %v", err)
}
if gotReal != wantReal {
t.Fatalf("config path = %s, want %s", gotReal, wantReal)
}
}
func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Helper()
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("get cwd: %v", err)
}
defer func() { _ = os.Chdir(oldCwd) }()
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
envDir := filepath.Join(root, "from-env")
if err := os.MkdirAll(envDir, 0o755); err != nil {
t.Fatalf("mkdir env dir: %v", err)
}
envPath := filepath.Join(envDir, ".env")
if err := os.WriteFile(envPath, []byte("app_name=env\n"), 0o644); err != nil {
t.Fatalf("write env config: %v", err)
}
flagDir := filepath.Join(root, "from-flag")
if err := os.MkdirAll(flagDir, 0o755); err != nil {
t.Fatalf("mkdir flag dir: %v", err)
}
flagPath := filepath.Join(flagDir, ".env")
if err := os.WriteFile(flagPath, []byte("app_name=flag\n"), 0o644); err != nil {
t.Fatalf("write flag config: %v", err)
}
t.Setenv("EPUSDT_CONFIG", envDir)
SetConfigPath(flagDir)
defer SetConfigPath("")
got, err := resolveConfigFilePath()
if err != nil {
t.Fatalf("resolve config path: %v", err)
}
if got != flagPath {
t.Fatalf("config path = %s, want %s", got, flagPath)
}
}
func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "app-static")
dst := filepath.Join(root, "data-static")
if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
t.Fatalf("write source index: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
t.Fatalf("write source payment: %v", err)
}
if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
t.Fatalf("write source logo: %v", err)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatalf("mkdir destination: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
t.Fatalf("write existing destination index: %v", err)
}
if err := copyMissingStaticFiles(src, dst); err != nil {
t.Fatalf("copy missing static files: %v", err)
}
index, err := os.ReadFile(filepath.Join(dst, "index.html"))
if err != nil {
t.Fatalf("read destination index: %v", err)
}
if string(index) != "custom-index" {
t.Fatalf("existing index was overwritten: %q", string(index))
}
payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
if err != nil {
t.Fatalf("read copied payment: %v", err)
}
if string(payment) != "source-payment" {
t.Fatalf("payment.js = %q, want source-payment", string(payment))
}
logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
if err != nil {
t.Fatalf("read copied nested asset: %v", err)
}
if string(logo) != "source-logo" {
t.Fatalf("logo.png = %q, want source-logo", string(logo))
}
}
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
apiCalled := false
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
apiCalled = true
return &http.Response{
StatusCode: http.StatusInternalServerError,
Status: http.StatusText(http.StatusInternalServerError),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "7.25",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
if got != 7.25 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
}
if apiCalled {
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
}
}
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/cny.json" {
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "-1",
"rate.api_url": "https://rate.example.test",
})
got := GetUsdtRate()
want := 1 / 0.14635
if math.Abs(got-want) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
}
rate := GetRateForCoin("usdt", "cny")
if math.Abs(rate-0.14635) > 1e-9 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
}
}
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetUsdtRate(); got != 0 {
t.Fatalf("GetUsdtRate() = %v, want 0", got)
}
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
}
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "")
callCount := 0
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
callCount++
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0",
"rate.api_url": "https://rate.example.test",
})
if got := GetRateForCoin("usdt", "cny"); got != 0 {
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
}
if callCount != 1 {
t.Fatalf("rate api call count = %d, want 1", callCount)
}
}
+34
View File
@@ -0,0 +1,34 @@
package config
// Settings lookups are injected at runtime by bootstrap (after DB init)
// to keep the config package free of a `model/data` import, which would
// create a cycle via `model/dao -> config`.
//
// If unset (e.g. during early startup or tests) the getters return the
// zero string / 0 and callers apply their own fallback behavior.
// SettingsGetString is installed by bootstrap.Init with a closure that
// reads from the settings table. Runtime-only.
var SettingsGetString func(key string) string
func settingsRateApiUrl() string {
if SettingsGetString == nil {
return ""
}
return SettingsGetString("rate.api_url")
}
func settingsForcedUsdtRate() float64 {
if SettingsGetString == nil {
return 0
}
raw := SettingsGetString("rate.forced_usdt_rate")
if raw == "" {
return 0
}
f, err := parseFloat(raw)
if err != nil {
return 0
}
return f
}
+310
View File
@@ -0,0 +1,310 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// CreateApiKeyRequest is the payload for creating an API key.
// A single key is valid for both gateway flows (epay/gmpay); there is
// no gateway_type. PID is auto-generated (incrementing from 1000);
// no manual override.
type CreateApiKeyRequest struct {
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
IpWhitelist string `json:"ip_whitelist" example:""`
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
}
// CreateApiKeyResponse is the response for a newly created API key.
type CreateApiKeyResponse struct {
ID uint64 `json:"id" example:"1"`
Name string `json:"name" example:"My API Key"`
Pid string `json:"pid" example:"1003"`
// SecretKey is returned ONCE on creation. After that, fetch via
// GET /api-keys/:id/secret or rotate to generate a new one.
SecretKey string `json:"secret_key" example:"secret123abc456"`
}
// UpdateApiKeyRequest is the payload for updating an API key.
type UpdateApiKeyRequest struct {
Name *string `json:"name" example:"Updated Key Name"`
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
}
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
type ChangeApiKeyStatusRequest struct {
// 状态 1=启用 2=禁用
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
}
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
// mdb.ApiKey json tag (`json:"-"`).
// @Summary List API keys
// @Description Returns all API keys
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys [get]
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
rows, err := data.ListApiKeys()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateApiKey mints a new universal key. PID is auto-incremented from
// the highest existing numeric PID (starting at 1000). Secret is
// randomly generated and returned once.
// @Summary Create API key
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateApiKeyRequest true "API key payload"
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys [post]
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
req := new(CreateApiKeyRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
// Retry on unique-index violation: two concurrent creates could
// both see the same max PID from NextPid() and race on INSERT.
// The loser re-reads and retries with the now-higher max.
const maxAttempts = 5
secret := randomHex(32)
var row *mdb.ApiKey
for attempt := 0; attempt < maxAttempts; attempt++ {
pid, err := data.NextPid()
if err != nil {
return c.FailJson(ctx, err)
}
row = &mdb.ApiKey{
Name: req.Name,
Pid: strconv.Itoa(pid),
SecretKey: secret,
IpWhitelist: req.IpWhitelist,
NotifyUrl: req.NotifyUrl,
Status: mdb.ApiKeyStatusEnable,
}
err = data.CreateApiKey(row)
if err == nil {
break
}
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
return c.FailJson(ctx, err)
}
}
return c.SucJson(ctx, CreateApiKeyResponse{
ID: row.ID,
Name: row.Name,
Pid: row.Pid,
SecretKey: secret,
})
}
// isUniqueViolation matches the driver-specific strings for a unique-
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
// the PID retry loop; a cleaner approach would be a typed error but
// GORM doesn't surface one portably.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") ||
strings.Contains(msg, "duplicate") ||
strings.Contains(msg, "constraint failed")
}
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
// and enable/disable have their own endpoints.
// @Summary Update API key
// @Description Patch API key name / ip_whitelist / notify_url
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "API key ID"
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id} [patch]
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateApiKeyRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = *req.Name
}
if req.IpWhitelist != nil {
fields["ip_whitelist"] = *req.IpWhitelist
}
if req.NotifyUrl != nil {
fields["notify_url"] = *req.NotifyUrl
}
if err := data.UpdateApiKeyFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ChangeApiKeyStatus toggles enable/disable.
// @Summary Change API key status
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
// @Tags Admin API Keys
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "API key ID"
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/status [post]
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(ChangeApiKeyStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
// @Summary Rotate API key secret
// @Description Generate a new secret_key. Returned only once.
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
secret := randomHex(32)
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]string{"secret_key": secret})
}
// DeleteApiKey soft-deletes a row. Orders created with this key will
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
// disabling via /status over deletion if there are live orders.
// @Summary Delete API key
// @Description Soft-delete an API key
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id} [delete]
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteApiKeyByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// GetApiKeyStats returns usage stats for one key. Currently thin — just
// the stored call_count and last_used_at — since we don't persist per-
// call telemetry. Expanded later if/when we add a request log table.
// @Summary Get API key stats
// @Description Returns usage stats (call_count, last_used_at) for an API key
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/stats [get]
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetApiKeyByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found"))
}
return c.SucJson(ctx, map[string]interface{}{
"call_count": row.CallCount,
"last_used_at": row.LastUsedAt,
})
}
// GetApiKeySecret returns the secret_key for one API key. Requires
// admin JWT authentication.
// @Summary View API key secret
// @Description Returns the secret_key for an API key (admin only)
// @Tags Admin API Keys
// @Security AdminJWT
// @Produce json
// @Param id path int true "API key ID"
// @Success 200 {object} response.ApiResponse{data=map[string]string}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/api-keys/{id}/secret [get]
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetApiKeyByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("api key not found"))
}
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+199
View File
@@ -0,0 +1,199 @@
package admin
import (
"errors"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
// LoginRequest is the payload for admin login.
type LoginRequest struct {
Username string `json:"username" validate:"required" example:"admin"`
Password string `json:"password" validate:"required" example:"password123"`
}
// LoginResponse is the response for a successful login.
type LoginResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
Username string `json:"username" example:"admin"`
UserID uint64 `json:"user_id" example:"1"`
}
// ChangePasswordRequest is the payload for changing admin password.
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" validate:"required" example:"old_pass"`
NewPassword string `json:"new_password" validate:"required|minLen:6" example:"new_pass123"`
}
// MeResponse wraps AdminUser with a default-password flag.
type MeResponse struct {
mdb.AdminUser
PasswordIsDefault bool `json:"password_is_default" example:"true"`
}
// InitialPasswordResponse is returned by the one-time initial password API.
type InitialPasswordResponse struct {
Username string `json:"username" example:"admin"`
Password string `json:"password" example:"a1b2c3d4e5f6"`
}
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login
// @Description Verify credentials and return a signed JWT token
// @Tags Admin Auth
// @Accept json
// @Produce json
// @Param request body admin.LoginRequest true "Login credentials"
// @Success 200 {object} response.ApiResponse{data=admin.LoginResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/login [post]
func (c *BaseAdminController) Login(ctx echo.Context) error {
req := new(LoginRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
user, err := data.GetAdminUserByUsername(req.Username)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
return c.FailJson(ctx, errors.New("invalid username or password"))
}
if user.Status != mdb.AdminUserStatusEnable {
return c.FailJson(ctx, errors.New("user disabled"))
}
token, err := appjwt.Sign(user.ID, user.Username)
if err != nil {
return c.FailJson(ctx, err)
}
err = data.TouchAdminUserLastLogin(user.ID)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, LoginResponse{
Token: token,
Username: user.Username,
UserID: user.ID,
})
}
// GetInitialPassword returns the one-time initial admin password.
// @Summary Get initial admin password (one-time)
// @Description Returns the initial random admin password once, then invalidates it.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password [get]
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
password, err := data.ConsumeInitialAdminPassword()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, InitialPasswordResponse{
Username: "admin",
Password: password,
})
}
// GetInitialPasswordHash returns the initial-password fingerprint so the
// frontend can warn until the password is changed.
// @Summary Get initial admin password hash
// @Description Returns the initial password hash and password-changed flag.
// @Tags Admin Auth
// @Produce json
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/init-password-hash [get]
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
info, err := data.GetInitialAdminPasswordHashInfo()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, info)
}
// Logout is a no-op stub — tokens are stateless and the frontend
// discards them. Kept for API symmetry with the documented spec.
// @Summary Admin logout
// @Description Logout (no-op, frontend discards token)
// @Tags Admin Auth
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse
// @Router /admin/api/v1/auth/logout [post]
func (c *BaseAdminController) Logout(ctx echo.Context) error {
return c.SucJson(ctx, nil)
}
// Me returns the currently authenticated admin user profile.
// @Summary Get current admin profile
// @Description Returns the currently authenticated admin user
// @Tags Admin Auth
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=admin.MeResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/me [get]
func (c *BaseAdminController) Me(ctx echo.Context) error {
uid := currentAdminUserID(ctx)
if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized"))
}
user, err := data.GetAdminUserByID(uid)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found"))
}
// Warn the frontend when the operator hasn't changed the seeded
// initial password.
isDefault := data.IsUsingInitialAdminPassword()
return c.SucJson(ctx, MeResponse{
AdminUser: *user,
PasswordIsDefault: isDefault,
})
}
// ChangePassword requires the old password, updates to the new one.
// @Summary Change admin password
// @Description Change the current admin user's password
// @Tags Admin Auth
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.ChangePasswordRequest true "Password payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/auth/password [post]
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
uid := currentAdminUserID(ctx)
if uid == 0 {
return c.FailJson(ctx, errors.New("unauthorized"))
}
req := new(ChangePasswordRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
user, err := data.GetAdminUserByID(uid)
if err != nil {
return c.FailJson(ctx, err)
}
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
return c.FailJson(ctx, errors.New("old password incorrect"))
}
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
+26
View File
@@ -0,0 +1,26 @@
package admin
import (
"github.com/GMWalletApp/epusdt/controller"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4"
)
// Ctrl is the singleton admin controller. Keeps parity with comm.Ctrl.
var Ctrl = &BaseAdminController{}
// BaseAdminController aggregates admin-domain handlers. Split across
// *_controller.go files in this package; all receive methods hang off
// this type so a single router registration is enough.
type BaseAdminController struct {
controller.BaseController
}
// currentAdminUserID returns the admin user id injected by CheckAdminJWT.
// Returns 0 if the middleware is missing (treat as auth bug upstream).
func currentAdminUserID(ctx echo.Context) uint64 {
if v, ok := ctx.Get(middleware.AdminUserIDKey).(uint64); ok {
return v
}
return 0
}
+71
View File
@@ -0,0 +1,71 @@
package admin
import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
// UpdateChainRequest is the payload for updating chain settings.
type UpdateChainRequest struct {
Enabled *bool `json:"enabled" example:"true"`
MinConfirmations *int `json:"min_confirmations" example:"20"`
ScanIntervalSec *int `json:"scan_interval_sec" example:"5"`
DisplayName *string `json:"display_name" example:"Tron"`
}
// ListChains returns the chains table (tron/ethereum/solana seeded; new
// networks like bsc/polygon inserted manually once supported).
// @Summary List chains
// @Description Returns all supported blockchain networks
// @Tags Admin Chains
// @Security AdminJWT
// @Produce json
// @Success 200 {object} response.ApiResponse{data=[]mdb.Chain}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chains [get]
func (c *BaseAdminController) ListChains(ctx echo.Context) error {
rows, err := data.ListChains()
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// UpdateChain toggles enabled / tweaks scan params. Scanners re-read the
// chains table on every poll tick, so changes take effect within one
// cycle without a restart.
// @Summary Update chain
// @Description Toggle enabled / tweak scan params for a chain
// @Tags Admin Chains
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
// @Param request body admin.UpdateChainRequest true "Chain settings"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chains/{network} [patch]
func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
network := ctx.Param("network")
req := new(UpdateChainRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if req.MinConfirmations != nil {
fields["min_confirmations"] = *req.MinConfirmations
}
if req.ScanIntervalSec != nil {
fields["scan_interval_sec"] = *req.ScanIntervalSec
}
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if err := data.UpdateChainFields(network, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
@@ -0,0 +1,181 @@
package admin
import (
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// CreateChainTokenRequest is the payload for creating a chain token.
type CreateChainTokenRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
MinAmount float64 `json:"min_amount" example:"1.0"`
}
// UpdateChainTokenRequest is the payload for updating a chain token.
type UpdateChainTokenRequest struct {
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals *int `json:"decimals" example:"6"`
Enabled *bool `json:"enabled" example:"true"`
MinAmount *float64 `json:"min_amount" example:"1.0"`
}
// ChangeChainTokenStatusRequest is the payload for toggling chain token status.
type ChangeChainTokenStatusRequest struct {
Enabled bool `json:"enabled" example:"true"`
}
// ListChainTokens returns rows, optionally filtered by network.
// @Summary List chain tokens
// @Description Returns chain tokens, optionally filtered by network
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens [get]
func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
rows, err := data.ListChainTokens(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
// @Summary Create chain token
// @Description Create a new chain token entry
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateChainTokenRequest true "Chain token payload"
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens [post]
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
req := new(CreateChainTokenRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
decimals := req.Decimals
if decimals == 0 {
decimals = 6
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.ChainToken{
Network: strings.ToLower(strings.TrimSpace(req.Network)),
Symbol: strings.ToUpper(strings.TrimSpace(req.Symbol)),
ContractAddress: strings.TrimSpace(req.ContractAddress),
Decimals: decimals,
Enabled: enabled,
MinAmount: req.MinAmount,
}
if err := data.CreateChainToken(row); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, row)
}
// UpdateChainToken patches mutable columns.
// @Summary Update chain token
// @Description Patch chain token fields
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Chain token ID"
// @Param request body admin.UpdateChainTokenRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id} [patch]
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateChainTokenRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.ContractAddress != nil {
fields["contract_address"] = *req.ContractAddress
}
if req.Decimals != nil {
fields["decimals"] = *req.Decimals
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if req.MinAmount != nil {
fields["min_amount"] = *req.MinAmount
}
if err := data.UpdateChainTokenFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ChangeChainTokenStatus toggles enable/disable.
// @Summary Change chain token status
// @Description Toggle enable/disable for a chain token
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Chain token ID"
// @Param request body admin.ChangeChainTokenStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id}/status [post]
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(ChangeChainTokenStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteChainToken soft-deletes the row.
// @Summary Delete chain token
// @Description Soft-delete a chain token
// @Tags Admin Chain Tokens
// @Security AdminJWT
// @Produce json
// @Param id path int true "Chain token ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/chain-tokens/{id} [delete]
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteChainTokenByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
@@ -0,0 +1,288 @@
package admin
import (
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4"
)
// OverviewResponse is the dashboard top-card response.
type OverviewResponse struct {
TotalAsset float64 `json:"total_asset" example:"12345.6789"`
Volume float64 `json:"volume" example:"100.5"`
OrderCount int64 `json:"order_count" example:"42"`
SuccessRate float64 `json:"success_rate" example:"0.85"`
SevenDayVolume float64 `json:"seven_day_volume" example:"700.25"`
ActiveAddresses int64 `json:"active_addresses" example:"5"`
OnlineChains int64 `json:"online_chains" example:"3"`
}
// OrderStatsResponse is the order statistics response.
type OrderStatsResponse struct {
OrderCount int64 `json:"order_count" example:"100"`
SuccessCount int64 `json:"success_count" example:"85"`
SuccessRate float64 `json:"success_rate" example:"0.85"`
AvgPaymentSeconds float64 `json:"avg_payment_seconds" example:"120.5"`
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
}
// RpcHealthCheckResponse is the response for a health check probe.
type RpcHealthCheckResponse struct {
// 健康状态 unknown=未知 ok=正常 down=异常
Status string `json:"status" example:"ok" enums:"unknown,ok,down"`
LastLatencyMs int `json:"last_latency_ms" example:"120"`
}
// ApiKeyStatsResponse is the response for API key usage stats.
type ApiKeyStatsResponse struct {
CallCount int64 `json:"call_count" example:"342"`
LastUsedAt string `json:"last_used_at" example:"2026-04-14T08:30:00+00:00"`
}
// RotateSecretResponse is returned when rotating an API key secret.
type RotateSecretResponse struct {
SecretKey string `json:"secret_key" example:"newSecret123abc456"`
}
// BatchImportResult is one row in the batch import response.
type BatchImportResult struct {
Address string `json:"address" example:"TTestTronAddress001"`
OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""`
}
// SettingsUpsertResult is one row in the settings upsert response.
type SettingsUpsertResult struct {
Key string `json:"key" example:"rate.api_url"`
OK bool `json:"ok" example:"true"`
Error string `json:"error,omitempty" example:""`
}
// Overview returns the top-card numbers for the dashboard landing page.
// All values are computed with on-demand SQL — no cache — per the design.
// The selected time range (range / start_at / end_at) is applied to
// volume, order_count, success_rate and active_addresses.
// seven_day_volume always covers the rolling past 7 days.
// total_asset and online_chains are time-independent.
// @Summary Dashboard overview
// @Description Returns top-card numbers: total asset, volume, success rate, active addresses, etc. Supports ?range= for filtering.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=admin.OverviewResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/overview [get]
func (c *BaseAdminController) Overview(ctx echo.Context) error {
start, end := parseRange(ctx)
now := time.Now()
sevenDaysAgo := startOfDay(now).AddDate(0, 0, -6)
totalActual, _ := data.SumPaidActualAmount()
orderCount, successCount, volume, _ := data.PaidStatsInRange(start, end)
var successRate float64
if orderCount > 0 {
successRate = float64(successCount) / float64(orderCount)
}
_, _, sevenDayActual, _ := data.PaidStatsInRange(sevenDaysAgo, now)
activeAddr, _ := data.ActiveAddressCountInRange(start, end)
onlineChains, _ := data.CountEnabledChains()
return c.SucJson(ctx, map[string]interface{}{
"total_asset": totalActual,
"volume": volume,
"order_count": orderCount,
"success_rate": successRate,
"seven_day_volume": sevenDayActual,
"active_addresses": activeAddr,
"online_chains": onlineChains,
})
}
// isHourlyRange returns true when the time window spans ≤ 1 day,
// so callers can choose hourly vs daily granularity.
func isHourlyRange(start, end time.Time) bool {
return end.Sub(start) <= 24*time.Hour
}
// AssetTrend returns per-day actual_amount for paid orders. group_by=
// "total" yields one series; "address" yields rows keyed by
// receive_address for the stacked chart.
// Granularity: hourly when range ≤ 1 day, daily otherwise.
// Missing buckets are always zero-filled.
// @Summary Asset trend
// @Description Returns per-period actual_amount for paid orders. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Param group_by query string false "Group by: total or address" default(total)
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/asset-trend [get]
func (c *BaseAdminController) AssetTrend(ctx echo.Context) error {
start, end := parseRange(ctx)
groupBy := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group_by")))
hourly := isHourlyRange(start, end)
if groupBy == "address" {
var rows interface{}
var err error
if hourly {
rows, err = data.HourlyAssetByAddress(start, end)
} else {
rows, err = data.DailyAssetByAddress(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
var rows interface{}
var err error
if hourly {
rows, err = data.HourlyOrderStats(start, end)
} else {
rows, err = data.DailyOrderStats(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// RevenueTrend mirrors the daily stats view used for revenue charts.
// Same data shape as AssetTrend(group_by=total) but kept as its own
// endpoint so the frontend can evolve each chart independently.
// Granularity: hourly when range ≤ 1 day, daily otherwise.
// Missing buckets are always zero-filled.
// @Summary Revenue trend
// @Description Returns per-period revenue stats for charts. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/revenue-trend [get]
func (c *BaseAdminController) RevenueTrend(ctx echo.Context) error {
start, end := parseRange(ctx)
var rows interface{}
var err error
if isHourlyRange(start, end) {
rows, err = data.HourlyOrderStats(start, end)
} else {
rows, err = data.DailyOrderStats(start, end)
}
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// OrderStats returns aggregate order figures for the selected window:
// total count, success rate, average payment duration (seconds), and
// expired-unpaid count.
// @Summary Order statistics
// @Description Returns aggregate order figures: count, success rate, avg payment time, expired count
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
// @Param end_at query int false "End time (Unix seconds, for custom range)"
// @Success 200 {object} response.ApiResponse{data=admin.OrderStatsResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/order-stats [get]
func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
start, end := parseRange(ctx)
total, success, _, err := data.PaidStatsInRange(start, end)
if err != nil {
return c.FailJson(ctx, err)
}
avg, _ := data.AveragePaymentDurationSeconds(start, end)
expired, _ := data.CountExpiredInRange(start, end)
var rate float64
if total > 0 {
rate = float64(success) / float64(total)
}
return c.SucJson(ctx, map[string]interface{}{
"order_count": total,
"success_count": success,
"success_rate": rate,
"avg_payment_seconds": avg,
"expired_unpaid_count": expired,
})
}
// RecentOrders returns the last N orders for the dashboard list card.
// @Summary Recent orders
// @Description Returns the last N orders for the dashboard
// @Tags Admin Dashboard
// @Security AdminJWT
// @Produce json
// @Param limit query int false "Number of orders to return" default(20)
// @Success 200 {object} response.ApiResponse{data=[]mdb.Orders}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/dashboard/recent-orders [get]
func (c *BaseAdminController) RecentOrders(ctx echo.Context) error {
limit := 20
if s := ctx.QueryParam("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
limit = n
}
}
rows, err := data.RecentOrders(limit)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// parseRange maps the ?range=... shorthand to a concrete [start, end].
// Accepted: today / 7d / 30d / custom (with start_at/end_at Unix secs).
// Defaults to today.
func parseRange(ctx echo.Context) (time.Time, time.Time) {
rng := strings.ToLower(strings.TrimSpace(ctx.QueryParam("range")))
now := time.Now()
todayStart := startOfDay(now)
switch rng {
case "7d":
return todayStart.AddDate(0, 0, -6), now
case "30d":
return todayStart.AddDate(0, 0, -29), now
case "custom":
start := todayStart
end := now
if s := ctx.QueryParam("start_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
start = time.Unix(n, 0)
}
}
if s := ctx.QueryParam("end_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
end = time.Unix(n, 0)
}
}
return start, end
default:
return todayStart, now
}
}
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
@@ -0,0 +1,314 @@
package admin
import (
"encoding/json"
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
// CreateNotificationChannelRequest is the payload for creating a notification channel.
type CreateNotificationChannelRequest struct {
Type string `json:"type" validate:"required|in:telegram,webhook,email" enums:"telegram,webhook,email" example:"telegram"`
Name string `json:"name" validate:"required|maxLen:128" example:"主通知群"`
Config map[string]interface{} `json:"config" validate:"required"`
Events interface{} `json:"events"`
Enabled *bool `json:"enabled" example:"true"`
}
// UpdateNotificationChannelRequest is the payload for updating a notification channel.
type UpdateNotificationChannelRequest struct {
Name *string `json:"name" example:"更新后的名称"`
Config map[string]interface{} `json:"config"`
Events interface{} `json:"events"`
Enabled *bool `json:"enabled" example:"true"`
}
// ChangeChannelStatusRequest is the payload for toggling notification channel status.
type ChangeChannelStatusRequest struct {
Enabled bool `json:"enabled" example:"true"`
}
// ListNotificationChannels lists rows, optionally filtered by type.
// @Summary List notification channels
// @Description Returns notification channels, optionally filtered by type
// @Tags Admin Notifications
// @Security AdminJWT
// @Produce json
// @Param type query string false "Channel type: telegram, webhook, email"
// @Success 200 {object} response.ApiResponse{data=[]mdb.NotificationChannel}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels [get]
func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
channelType := strings.ToLower(strings.TrimSpace(ctx.QueryParam("type")))
rows, err := data.ListNotificationChannels(channelType)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateNotificationChannel validates Config shape per type (only
// telegram is strongly validated today) and inserts a row.
// @Summary Create notification channel
// @Description Create a new notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateNotificationChannelRequest true "Channel payload"
// @Success 200 {object} response.ApiResponse{data=mdb.NotificationChannel}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels [post]
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
req := new(CreateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := validateChannelConfig(req.Type, req.Config); err != nil {
return c.FailJson(ctx, err)
}
eventsMap, err := normalizeChannelEvents(req.Events)
if err != nil {
return c.FailJson(ctx, err)
}
configJSON, _ := json.Marshal(req.Config)
eventsJSON, _ := json.Marshal(eventsMap)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.NotificationChannel{
Type: req.Type,
Name: req.Name,
Config: string(configJSON),
Events: string(eventsJSON),
Enabled: enabled,
}
if err := data.CreateNotificationChannel(row); err != nil {
return c.FailJson(ctx, err)
}
if strings.ToLower(strings.TrimSpace(row.Type)) == mdb.NotificationTypeTelegram {
telegram.ReloadBotAsync("admin create telegram channel")
}
return c.SucJson(ctx, row)
}
// UpdateNotificationChannel patches name/config/events/enabled.
// @Summary Update notification channel
// @Description Patch notification channel fields
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Channel ID"
// @Param request body admin.UpdateNotificationChannelRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id} [patch]
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
req := new(UpdateNotificationChannelRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = *req.Name
}
if req.Config != nil {
configJSON, _ := json.Marshal(req.Config)
fields["config"] = string(configJSON)
}
if req.Events != nil {
eventsMap, err := normalizeChannelEvents(req.Events)
if err != nil {
return c.FailJson(ctx, err)
}
eventsJSON, _ := json.Marshal(eventsMap)
fields["events"] = string(eventsJSON)
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if err := data.UpdateNotificationChannelFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin update telegram channel")
}
return c.SucJson(ctx, nil)
}
// ChangeNotificationChannelStatus toggles enabled.
// @Summary Change notification channel status
// @Description Toggle enable/disable for a notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Channel ID"
// @Param request body admin.ChangeChannelStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id}/status [post]
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
req := new(ChangeChannelStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin change telegram channel status")
}
return c.SucJson(ctx, nil)
}
// DeleteNotificationChannel soft-deletes the row.
// @Summary Delete notification channel
// @Description Soft-delete a notification channel
// @Tags Admin Notifications
// @Security AdminJWT
// @Produce json
// @Param id path int true "Channel ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/notification-channels/{id} [delete]
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
existing, err := data.GetNotificationChannelByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
if err := data.DeleteNotificationChannelByID(id); err != nil {
return c.FailJson(ctx, err)
}
if isTelegram {
telegram.ReloadBotAsync("admin delete telegram channel")
}
return c.SucJson(ctx, nil)
}
// validateChannelConfig enforces minimum shape. Telegram needs bot_token
// + chat_id; webhook/email kept loose until those senders exist.
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
if cfg == nil {
return errors.New("config required")
}
if channelType == mdb.NotificationTypeTelegram {
configJSON, _ := json.Marshal(cfg)
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
return err
}
}
return nil
}
func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
if raw == nil {
return map[string]bool{}, nil
}
toFlag := func(v interface{}) (bool, error) {
switch t := v.(type) {
case bool:
return t, nil
case float64:
return t != 0, nil
case string:
s := strings.ToLower(strings.TrimSpace(t))
switch s {
case "", "0", "false", "off", "no":
return false, nil
case "1", "true", "on", "yes":
return true, nil
default:
return false, errors.New("invalid boolean string")
}
default:
return false, errors.New("invalid event value type")
}
}
trimKey := func(s string) string {
return strings.TrimSpace(s)
}
out := make(map[string]bool)
switch t := raw.(type) {
case map[string]bool:
for k, v := range t {
k = trimKey(k)
if k == "" {
continue
}
out[k] = v
}
return out, nil
case map[string]interface{}:
for k, v := range t {
k = trimKey(k)
if k == "" {
continue
}
flag, err := toFlag(v)
if err != nil {
return nil, err
}
out[k] = flag
}
return out, nil
case []interface{}:
for _, v := range t {
s, ok := v.(string)
if !ok {
return nil, errors.New("events array must contain strings")
}
s = trimKey(s)
if s == "" {
continue
}
out[s] = true
}
return out, nil
default:
return nil, errors.New("events must be object or string array")
}
}
+393
View File
@@ -0,0 +1,393 @@
package admin
import (
"encoding/csv"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
// MarkPaidRequest is the payload for manually marking an order as paid.
type MarkPaidRequest struct {
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
}
// OrderListResponse wraps the paginated order list.
type OrderListResponse struct {
List []mdb.Orders `json:"list"`
Total int64 `json:"total" example:"100"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"20"`
}
// OrderWithSub embeds a parent order together with its child orders.
type OrderWithSub struct {
mdb.Orders
SubOrders []mdb.Orders `json:"sub_orders"`
}
// OrderWithSubListResponse wraps the paginated list-with-sub result.
type OrderWithSubListResponse struct {
List []OrderWithSub `json:"list"`
Total int64 `json:"total" example:"100"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"20"`
}
// ListOrders handles the admin order list with filters + pagination.
// @Summary List orders
// @Description Returns paginated orders with optional filters
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search (trade_id or order_id)"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders [get]
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
f := parseOrderFilter(ctx)
rows, total, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]interface{}{
"list": rows,
"total": total,
"page": f.Page,
"page_size": f.PageSize,
})
}
// GetOrder returns full order detail by trade_id.
// @Summary Get order
// @Description Returns full order detail by trade_id
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id} [get]
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
return c.SucJson(ctx, order)
}
// CloseOrder flips a waiting order to expired and releases its lock.
// @Summary Close order
// @Description Manually close a waiting order (mark as expired)
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/close [post]
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment"))
}
ok, err := data.CloseOrderManually(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if !ok {
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
}
// Release the transaction lock so the amount slot becomes reusable.
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
return c.SucJson(ctx, nil)
}
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
// same OrderProcessing path as on-chain confirmation so downstream
// callback + notification fire normally.
// @Summary Mark order paid
// @Description Manually mark a waiting order as paid (operator补单)
// @Tags Admin Orders
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
req := new(MarkPaidRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment"))
}
err = service.OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: req.BlockTransactionId,
})
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// ResendCallback requeues the order callback by flipping
// callback_confirm back to NO. The mq worker will grab it on the
// next poll tick.
// @Summary Resend callback
// @Description Re-queue the order callback notification
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
ok, err := data.ReopenOrderCallback(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if !ok {
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
}
return c.SucJson(ctx, nil)
}
// ExportOrders streams matching orders as CSV. No pagination — the
// caller drives what rows to include via filter params. Capped at
// 10k rows to keep the response bounded.
// @Summary Export orders as CSV
// @Description Stream matching orders as a CSV file (max 10k rows)
// @Tags Admin Orders
// @Security AdminJWT
// @Produce text/csv
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Success 200 {string} string "CSV file"
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/export [get]
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
f := parseOrderFilter(ctx)
f.Page = 1
f.PageSize = 10000
rows, _, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
resp := ctx.Response()
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
resp.WriteHeader(http.StatusOK)
w := csv.NewWriter(resp.Writer)
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
for _, r := range rows {
_ = w.Write([]string{
r.TradeId,
r.OrderId,
fmt.Sprintf("%.4f", r.Amount),
fmt.Sprintf("%.4f", r.ActualAmount),
r.Currency,
r.Token,
r.Network,
r.ReceiveAddress,
strconv.Itoa(r.Status),
r.BlockTransactionId,
r.CreatedAt.ToDateTimeString(),
})
}
w.Flush()
return nil
}
// parseOrderFilter extracts filter + pagination values from the query
// string. Time params are Unix seconds.
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
f := data.OrderListFilter{
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
Address: strings.TrimSpace(ctx.QueryParam("address")),
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
}
if s := ctx.QueryParam("status"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Status = n
}
}
if s := ctx.QueryParam("start_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
t := time.Unix(n, 0)
f.StartAt = &t
}
}
if s := ctx.QueryParam("end_at"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
t := time.Unix(n, 0)
f.EndAt = &t
}
}
if s := ctx.QueryParam("page"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Page = n
}
}
if s := ctx.QueryParam("page_size"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.PageSize = n
}
}
if f.Page < 1 {
f.Page = 1
}
if f.PageSize < 1 {
f.PageSize = 20
}
return f
}
// ListOrdersWithSub returns the same paginated order list as ListOrders but
// each row has a sub_orders field containing all child orders under it.
// @Summary List orders with sub-orders
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param token query string false "Token filter"
// @Param address query string false "Receive address filter"
// @Param keyword query string false "Keyword search (trade_id or order_id)"
// @Param status query int false "Order status filter"
// @Param start_at query int false "Start time (Unix seconds)"
// @Param end_at query int false "End time (Unix seconds)"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/list-with-sub [get]
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
f := parseOrderFilter(ctx)
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
f.ParentOnly = true
rows, total, err := data.ListOrders(f)
if err != nil {
return c.FailJson(ctx, err)
}
// Batch-fetch all sub-orders for the current page in one query.
tradeIds := make([]string, 0, len(rows))
for _, r := range rows {
tradeIds = append(tradeIds, r.TradeId)
}
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
if err != nil {
return c.FailJson(ctx, err)
}
// Group sub-orders by parent_trade_id.
subMap := make(map[string][]mdb.Orders, len(subs))
for _, s := range subs {
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
}
list := make([]OrderWithSub, 0, len(rows))
for _, r := range rows {
children := subMap[r.TradeId]
if children == nil {
children = []mdb.Orders{}
}
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
}
return c.SucJson(ctx, OrderWithSubListResponse{
List: list,
Total: total,
Page: f.Page,
PageSize: f.PageSize,
})
}
// GetOrderWithSub returns a single parent order together with all its child
// orders (any status), regardless of whether the caller passes a parent or
// child trade_id — if it is a child, the parent is resolved first.
// @Summary Get order with sub-orders
// @Description Returns an order and its child orders embedded in sub_orders
// @Tags Admin Orders
// @Security AdminJWT
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if order.ID == 0 {
return c.FailJson(ctx, errors.New("order not found"))
}
subOrders, err := data.GetAllSubOrders(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if subOrders == nil {
subOrders = []mdb.Orders{}
}
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
}
+190
View File
@@ -0,0 +1,190 @@
package admin
import (
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4"
)
// CreateRpcNodeRequest is the payload for creating an RPC node.
type CreateRpcNodeRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
// 连接类型 http=HTTP请求 ws=WebSocket长连接
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
Weight int `json:"weight" example:"1"`
ApiKey string `json:"api_key" example:""`
Enabled *bool `json:"enabled" example:"true"`
}
// UpdateRpcNodeRequest is the payload for updating an RPC node.
type UpdateRpcNodeRequest struct {
Url *string `json:"url" example:"https://api.trongrid.io"`
Weight *int `json:"weight" example:"1"`
ApiKey *string `json:"api_key" example:"your-api-key"`
Enabled *bool `json:"enabled" example:"true"`
}
// ListRpcNodes returns rows optionally filtered by network.
// @Summary List RPC nodes
// @Description Returns RPC nodes, optionally filtered by network
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Success 200 {object} response.ApiResponse{data=[]mdb.RpcNode}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes [get]
func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
rows, err := data.ListRpcNodes(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// CreateRpcNode inserts a row. Status starts as "unknown" until the
// health-check task runs.
// @Summary Create RPC node
// @Description Create a new RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.CreateRpcNodeRequest true "RPC node payload"
// @Success 200 {object} response.ApiResponse{data=mdb.RpcNode}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes [post]
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
req := new(CreateRpcNodeRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
weight := req.Weight
if weight < 1 {
weight = 1
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
row := &mdb.RpcNode{
Network: strings.ToLower(strings.TrimSpace(req.Network)),
Url: strings.TrimSpace(req.Url),
Type: strings.ToLower(strings.TrimSpace(req.Type)),
Weight: weight,
ApiKey: req.ApiKey,
Enabled: enabled,
Status: mdb.RpcNodeStatusUnknown,
LastLatencyMs: -1,
}
if err := data.CreateRpcNode(row); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, row)
}
// UpdateRpcNode patches url/weight/api_key/enabled.
// @Summary Update RPC node
// @Description Patch RPC node fields
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "RPC node ID"
// @Param request body admin.UpdateRpcNodeRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id} [patch]
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(UpdateRpcNodeRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Url != nil {
fields["url"] = *req.Url
}
if req.Weight != nil {
fields["weight"] = *req.Weight
}
if req.ApiKey != nil {
fields["api_key"] = *req.ApiKey
}
if req.Enabled != nil {
fields["enabled"] = *req.Enabled
}
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// DeleteRpcNode soft-deletes the row.
// @Summary Delete RPC node
// @Description Soft-delete an RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param id path int true "RPC node ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id} [delete]
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteRpcNodeByID(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// HealthCheckRpcNode performs an on-demand probe and writes the result.
// For HTTP endpoints this is a GET to the URL with a short timeout; for
// WS we just attempt a TCP-level check via the same HTTP client (the
// handshake URL resolves the host identically).
// @Summary Health check RPC node
// @Description Perform an on-demand health probe on an RPC node
// @Tags Admin RPC Nodes
// @Security AdminJWT
// @Produce json
// @Param id path int true "RPC node ID"
// @Success 200 {object} response.ApiResponse{data=admin.RpcHealthCheckResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/rpc-nodes/{id}/health-check [post]
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
row, err := data.GetRpcNodeByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if row.ID == 0 {
return c.FailJson(ctx, errors.New("node not found"))
}
status, latency := task.ProbeNode(row.Url)
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, map[string]interface{}{
"status": status,
"last_latency_ms": latency,
})
}
+173
View File
@@ -0,0 +1,173 @@
package admin
import (
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4"
)
// SettingUpsertItem is a single setting entry for batch upsert.
// Supported groups and keys:
//
// - group=rate:
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
// rate.adjust_percent (float) — rate adjustment percentage
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
//
// - group=epay:
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
//
// - group=okpay:
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
// okpay.shop_id (string) — OkPay merchant/shop identifier
// okpay.shop_token (string) — OkPay signing token
// okpay.api_url (string) — OkPay API base URL
// okpay.callback_url (string) — server callback URL used for OkPay notify
// okpay.return_url (string) — optional default browser return URL after payment
// okpay.timeout_seconds (int) — outbound OkPay API timeout in seconds
// okpay.allow_tokens (string) — comma-separated allowed tokens, e.g. "USDT,TRX"
//
// - group=brand:
// brand.checkout_name (string) — cashier display name (preferred)
// brand.logo_url (string) — logo image URL
// brand.site_title (string) — payment page title / website title (preferred)
// brand.success_copy (string) — text shown on payment success (preferred)
// brand.support_url (string) — support / help URL
// brand.site_name (string) — legacy cashier/site display name
// brand.page_title (string) — legacy payment page title
// brand.pay_success_text (string) — legacy success text
//
// - group=system:
// system.order_expiration_time (int) — order expiry in minutes
type SettingUpsertItem struct {
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
Key string `json:"key" example:"epay.default_network"`
Value string `json:"value" example:"tron"`
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
}
// SettingsUpsertRequest is the payload for batch upserting settings.
type SettingsUpsertRequest struct {
Items []SettingUpsertItem `json:"items" validate:"required"`
}
// ListSettings returns all rows, optionally filtered by group.
// @Summary List settings
// @Description Returns all settings, optionally filtered by group.
// @Description Available groups: brand, rate, system, epay, okpay.
// @Description See SettingUpsertItem for the full list of supported keys per group.
// @Tags Admin Settings
// @Security AdminJWT
// @Produce json
// @Param group query string false "Group filter (brand|rate|system|epay|okpay)"
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings [get]
func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
group := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group")))
rows, err := data.ListSettingsByGroup(group)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, rows)
}
// UpsertSettings batch-inserts / updates rows. Each item is treated
// independently so a malformed row in the middle doesn't drop earlier
// ones. Errors are returned per-key so the UI can surface them.
// @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status.
// @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
// @Description system group keys: system.order_expiration_time.
// @Tags Admin Settings
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.SettingsUpsertRequest true "Settings payload"
// @Success 200 {object} response.ApiResponse{data=[]admin.SettingsUpsertResult}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings [put]
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
req := new(SettingsUpsertRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
type result struct {
Key string `json:"key"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
out := make([]result, 0, len(req.Items))
for _, item := range req.Items {
key := strings.TrimSpace(item.Key)
if key == "" {
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
continue
}
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue
}
out = append(out, result{Key: key, OK: true})
}
// When telegram credentials are updated via settings, reload the
// command bot so operators don't need to restart the process, and
// sync the notification_channels row so the notify dispatcher picks
// up the new values immediately.
telegramKeys := map[string]bool{
"system.telegram_bot_token": true,
"system.telegram_chat_id": true,
"system.telegram_payment_notice_enabled": true,
"system.telegram_abnormal_notice_enabled": true,
}
for _, item := range req.Items {
if telegramKeys[strings.TrimSpace(item.Key)] {
telegram.ReloadBotAsync("settings upsert")
go dao.SyncTelegramChannelFromSettings()
break
}
}
return c.SucJson(ctx, out)
}
// DeleteSetting removes one row. The next read of that key will fall
// back to the hardcoded default (see settings_data.GetSetting*).
// @Summary Delete setting
// @Description Remove a setting by key (falls back to default)
// @Tags Admin Settings
// @Security AdminJWT
// @Produce json
// @Param key path string true "Setting key"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings/{key} [delete]
func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
key := strings.TrimSpace(ctx.Param("key"))
if key == "" {
return c.SucJson(ctx, nil)
}
if err := data.DeleteSetting(key); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// Public helper for the rate/usdt overrides — used by config package to
// read settings-backed values without importing the controller package.
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
+279
View File
@@ -0,0 +1,279 @@
package admin
import (
"errors"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4"
)
// AdminAddWalletRequest is the payload for adding a wallet via admin.
type AdminAddWalletRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
Remark string `json:"remark" example:"主钱包"`
}
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
type AdminUpdateWalletRequest struct {
Remark *string `json:"remark" example:"已更新的备注"`
}
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
type AdminChangeWalletStatusRequest struct {
// 状态 1=启用 2=禁用
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
}
// AdminBatchImportRequest is the payload for batch importing wallets.
type AdminBatchImportRequest struct {
Network string `json:"network" validate:"required" example:"tron"`
// Addresses listed as observation-only wallets. Private-key import
// and API-managed modes are out of scope for v1.
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
}
// WalletListItem extends WalletAddress with order count.
type WalletListItem struct {
mdb.WalletAddress
OrderCount int64 `json:"order_count" example:"5"`
}
// AdminListWallets extends the read model with per-wallet order counts
// so the UI can render the "订单数" column without an N+1 follow-up.
// @Summary List wallets (admin)
// @Description Returns wallets with per-wallet order counts
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param network query string false "Network filter"
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
// @Param keyword query string false "Search by address or remark"
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets [get]
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
status := strings.TrimSpace(ctx.QueryParam("status"))
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
tx := dao.Mdb.Model(&mdb.WalletAddress{})
if network != "" {
tx = tx.Where("network = ?", network)
}
if status != "" {
if s, err := strconv.Atoi(status); err == nil {
tx = tx.Where("status = ?", s)
}
}
if keyword != "" {
kw := "%" + keyword + "%"
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
}
var rows []mdb.WalletAddress
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
return c.FailJson(ctx, err)
}
counts, err := data.CountOrdersByAddress()
if err != nil {
return c.FailJson(ctx, err)
}
out := make([]WalletListItem, 0, len(rows))
for _, r := range rows {
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
}
return c.SucJson(ctx, out)
}
// AdminAddWallet wraps the existing create with admin-exposed Remark +
// Source=manual so audit fields are populated.
// @Summary Add wallet (admin)
// @Description Create a wallet with admin-specific fields (remark, source=manual)
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets [post]
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
req := new(AdminAddWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
if err != nil {
return c.FailJson(ctx, err)
}
// Patch remark/source separately to avoid changing the existing
// package-level AddWalletAddressWithNetwork signature (used by
// Telegram bot + legacy endpoint).
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
if req.Remark != "" {
fields["remark"] = req.Remark
}
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
row.Remark = req.Remark
row.Source = mdb.WalletSourceManual
return c.SucJson(ctx, row)
}
// AdminGetWallet returns detail including order count for one wallet.
// @Summary Get wallet (admin)
// @Description Returns wallet detail with order count
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param id path int true "Wallet ID"
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [get]
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
wallet, err := data.GetWalletAddressById(id)
if err != nil {
return c.FailJson(ctx, err)
}
if wallet.ID == 0 {
return c.FailJson(ctx, errors.New("wallet not found"))
}
counts, _ := data.CountOrdersByAddress()
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
}
// AdminUpdateWallet only patches remark (status has its own endpoint).
// @Summary Update wallet (admin)
// @Description Patch wallet remark
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Wallet ID"
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [patch]
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(AdminUpdateWalletRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
fields := map[string]interface{}{}
if req.Remark != nil {
fields["remark"] = *req.Remark
}
if len(fields) == 0 {
return c.SucJson(ctx, nil)
}
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminChangeWalletStatus reuses existing helper.
// @Summary Change wallet status (admin)
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param id path int true "Wallet ID"
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id}/status [post]
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
req := new(AdminChangeWalletStatusRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminDeleteWallet soft-deletes the wallet.
// @Summary Delete wallet (admin)
// @Description Soft-delete a wallet
// @Tags Admin Wallets
// @Security AdminJWT
// @Produce json
// @Param id path int true "Wallet ID"
// @Success 200 {object} response.ApiResponse
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/{id} [delete]
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, err)
}
if err := data.DeleteWalletAddressById(id); err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, nil)
}
// AdminBatchImportWallets accepts a list of addresses and creates
// them all as observation wallets. Per-row failures are collected and
// returned so the UI can surface which addresses already existed.
// @Summary Batch import wallets
// @Description Import multiple wallet addresses at once. Per-row status is returned.
// @Tags Admin Wallets
// @Security AdminJWT
// @Accept json
// @Produce json
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
// @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/wallets/batch-import [post]
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
req := new(AdminBatchImportRequest)
if err := ctx.Bind(req); err != nil {
return c.FailJson(ctx, err)
}
if err := c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
type result struct {
Address string `json:"address"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
out := make([]result, 0, len(req.Addresses))
for _, addr := range req.Addresses {
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
if err != nil {
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
continue
}
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
Update("source", mdb.WalletSourceImport).Error
out = append(out, result{Address: addr, OK: true})
}
return c.SucJson(ctx, out)
}
+1 -1
View File
@@ -2,7 +2,7 @@ package controller
import (
"errors"
"github.com/assimon/luuu/util/http"
"github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/validate"
"github.com/gookit/validate/locales/zhcn"
"github.com/gookit/validate/locales/zhtw"
+1 -1
View File
@@ -1,6 +1,6 @@
package comm
import "github.com/assimon/luuu/controller"
import "github.com/GMWalletApp/epusdt/controller"
var Ctrl = &BaseCommController{}
+154
View File
@@ -0,0 +1,154 @@
package comm
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
)
// OkPayNotify receives the server-to-server OkPay payment callback.
// The handler accepts JSON or form-urlencoded payloads and returns plain-text
// acknowledgements expected by the upstream provider.
// @Summary OkPay notify callback
// @Description Receives OkPay deposit callbacks for hosted checkout child orders.
// @Description The callback is verified against the configured OkPay shop token and, on success, marks the matching child order as paid.
// @Tags Payment
// @Accept json
// @Accept x-www-form-urlencoded
// @Produce plain
// @Success 200 {string} string "success"
// @Failure 400 {string} string "fail"
// @Router /payments/okpay/v1/notify [post]
func (c *BaseCommController) OkPayNotify(ctx echo.Context) error {
req := ctx.Request()
rawBody, err := io.ReadAll(req.Body)
if err != nil {
log.Sugar.Warnf("[okpay] read notify body failed err=%v", err)
return ctx.String(http.StatusBadRequest, "fail")
}
req.Body = io.NopCloser(bytes.NewReader(rawBody))
form := make(map[string]string)
copyForm := func(values url.Values) {
for key, items := range values {
if len(items) == 0 {
continue
}
form[key] = items[0]
}
}
copyForm(req.URL.Query())
contentType := strings.ToLower(req.Header.Get(echo.HeaderContentType))
if strings.Contains(contentType, echo.MIMEApplicationJSON) && len(rawBody) > 0 {
if jsonForm, jsonErr := flattenOkPayJSONBody(rawBody); jsonErr == nil {
for key, value := range jsonForm {
form[key] = value
}
}
}
// Match the PHP example's $_POST semantics: let Echo/net/http parse both
// application/x-www-form-urlencoded and multipart/form-data.
if len(form) == 0 {
req.Body = io.NopCloser(bytes.NewReader(rawBody))
if parsedForm, formErr := ctx.FormParams(); formErr == nil {
copyForm(parsedForm)
}
}
// Final fallback for raw query-string style bodies.
if len(form) == 0 && len(rawBody) > 0 && looksLikeQueryPayload(string(rawBody)) {
if parsed, parseErr := url.ParseQuery(string(rawBody)); parseErr == nil && len(parsed) > 0 {
copyForm(parsed)
}
}
rawFormData := string(rawBody)
if rawFormData == "" {
rawFormData = req.URL.RawQuery
}
if len(form) == 0 {
log.Sugar.Warnf("[okpay] empty notify payload method=%s content_type=%s remote=%s raw_query=%q raw_body=%q", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), req.URL.RawQuery, rawFormData)
return ctx.String(http.StatusBadRequest, "fail")
}
if err = service.HandleOkPayNotify(form, rawFormData); err != nil {
log.Sugar.Warnf("[okpay] notify handle failed method=%s content_type=%s remote=%s form=%v raw_query=%q raw_body=%q err=%v", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), form, req.URL.RawQuery, rawFormData, err)
return ctx.String(http.StatusBadRequest, "fail")
}
return ctx.String(http.StatusOK, "success")
}
func flattenOkPayJSONBody(rawBody []byte) (map[string]string, error) {
decoder := json.NewDecoder(bytes.NewReader(rawBody))
decoder.UseNumber()
var payload map[string]interface{}
if err := decoder.Decode(&payload); err != nil {
return nil, err
}
flat := make(map[string]string)
for key, value := range payload {
if key == "data" {
dataMap, ok := value.(map[string]interface{})
if !ok {
continue
}
for dataKey, dataValue := range dataMap {
flat["data["+dataKey+"]"] = stringifyOkPayJSONValue(dataValue)
}
continue
}
flat[key] = stringifyOkPayJSONValue(value)
}
return flat, nil
}
func stringifyOkPayJSONValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case json.Number:
return v.String()
case float64:
if v == math.Trunc(v) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
return "true"
}
return "false"
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func looksLikeQueryPayload(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
return false
}
return strings.Contains(trimmed, "=")
}
+127 -4
View File
@@ -1,13 +1,48 @@
package comm
import (
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/constant"
"encoding/json"
"net/http"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4"
)
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
// Returns nil when the middleware didn't run (should not happen on authed routes).
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok {
return v
}
return nil
}
// CreateTransaction 创建交易
// @Summary Create transaction
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
// @Tags Payment
// @Accept json
// @Accept x-www-form-urlencoded
// @Produce json
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
// @Param order_id formData string false "Merchant order ID"
// @Param currency formData string false "Fiat currency (e.g. cny)"
// @Param token formData string false "Crypto token (e.g. usdt)"
// @Param network formData string false "Network (e.g. tron)"
// @Param amount formData number false "Amount"
// @Param notify_url formData string false "Callback URL"
// @Param signature formData string false "MD5 signature"
// @Param redirect_url formData string false "Redirect URL"
// @Param name formData string false "Order name"
// @Param payment_type formData string false "Payment type"
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/order/create-transaction [post]
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest)
if err = ctx.Bind(req); err != nil {
@@ -16,9 +51,97 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
if err = c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
resp, err := service.CreateTransaction(req)
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, resp)
}
// SwitchNetwork 切换支付网络,创建或返回子订单
// @Summary Switch payment network
// @Description Switch to a different payment target, creating or returning a sub-order.
// @Description Normal values such as tron/solana/ethereum create on-chain child orders.
// @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url.
// @Tags Payment
// @Accept json
// @Produce json
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /pay/switch-network [post]
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
req := new(request.SwitchNetworkRequest)
if err = ctx.Bind(req); err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err = c.ValidateStruct(ctx, req); err != nil {
return c.FailJson(ctx, err)
}
resp, err := service.SwitchNetwork(req)
if err != nil {
return c.FailJson(ctx, err)
}
jsonBytes, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return c.FailJson(ctx, err)
}
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
return c.SucJson(ctx, resp)
}
// CreateTransactionAndRedirect creates a transaction and redirects to
// the checkout counter. The route accepts BOTH GET (query string) and
// POST (form) per the legacy EPAY protocol; swagger documents POST as
// the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @Tags Payment
// @Accept x-www-form-urlencoded
// @Produce html
// @Param pid query integer false "API key PID (GET query)"
// @Param money query number false "Amount (fiat, GET query)"
// @Param out_trade_no query string false "Merchant order ID (GET query)"
// @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (GET query)"
// @Param type query string false "Payment type (e.g. alipay, GET query)"
// @Param sign query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID"
// @Param money formData number true "Amount (fiat)"
// @Param out_trade_no formData string true "Merchant order ID"
// @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name"
// @Param type formData string false "Payment type (e.g. alipay)"
// @Param sign formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter"
// @Failure 400 {object} response.ApiResponse
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
req := new(request.CreateTransactionRequest)
if err = ctx.Bind(req); err != nil {
log.Sugar.Errorf("bind request error: %v", err)
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
if err = c.ValidateStruct(ctx, req); err != nil {
log.Sugar.Errorf("validate request error: %v", err)
return c.FailJson(ctx, err)
}
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
if err != nil {
log.Sugar.Errorf("create transaction error: %v", err)
return c.FailJson(ctx, err)
}
log.Sugar.Debugf("create transaction response: %+v", resp)
tradeID := resp.TradeId
return ctx.Redirect(http.StatusFound, "/pay/checkout-counter/"+tradeID)
}
+27 -11
View File
@@ -1,30 +1,46 @@
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"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
// CheckoutCounter 收银台
// @Summary Checkout counter page
// @Description Render the payment checkout counter JSON response for a given trade
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.CheckoutCounterResponse
// @Router /pay/checkout-counter-resp/{trade_id} [get]
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
if err != nil {
return ctx.String(http.StatusOK, err.Error())
if err == service.ErrOrder {
// Unknown trade id: render the page with empty payload
// (client side shows a friendly "order not found" screen).
emptyResp := response.CheckoutCounterResponse{}
return c.SucJson(ctx, emptyResp)
}
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html"))
if err != nil {
return ctx.String(http.StatusOK, err.Error())
return ctx.String(http.StatusInternalServerError, err.Error())
}
return tmpl.Execute(ctx.Response(), resp)
return c.SucJson(ctx, resp)
}
// CheckStatus 支付状态检测
// @Summary Check payment status
// @Description Check the payment status of an order by trade ID
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /pay/check-status/{trade_id} [get]
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id")
order, err := service.GetOrderInfoByTradeId(tradeId)
@@ -0,0 +1,138 @@
package comm
import (
"sort"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4"
)
func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
chains, err := data.ListEnabledChains()
if err != nil {
return nil, err
}
wallets, err := data.GetAvailableWalletAddress()
if err != nil {
return nil, err
}
networkHasWallet := make(map[string]struct{})
for _, w := range wallets {
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
}
supports := make([]response.NetworkTokenSupport, 0, len(chains))
for _, ch := range chains {
network := strings.ToLower(ch.Network)
if _, ok := networkHasWallet[network]; !ok {
continue
}
tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
return nil, err
}
if len(tokens) == 0 {
continue
}
symbols := make([]string, 0, len(tokens))
for _, t := range tokens {
sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
if sym == "" {
continue
}
symbols = append(symbols, sym)
}
if len(symbols) == 0 {
continue
}
sort.Strings(symbols)
supports = append(supports, response.NetworkTokenSupport{
Network: network,
Tokens: symbols,
})
}
return supports, nil
}
// GetPublicConfig returns payment/site config consumed by cashier/frontends.
// The public route returns brand/site display config plus non-sensitive
// epay/okpay knobs, while the authenticated admin route also includes OkPay
// credentials and internal URLs.
// @Summary Get public payment config
// @Description Returns supported_assets plus site, epay and okpay config used by cashier/frontends.
// @Description The authenticated admin variant (/admin/api/v1/config) also includes OkPay shop credentials and internal callback settings.
// @Tags Payment Config
// @Produce json
// @Success 200 {object} response.ApiResponse{data=response.PublicConfigResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/config [get]
// @Router /admin/api/v1/config [get]
func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
supports, err := buildSupportedAssets()
if err != nil {
return c.FailJson(ctx, err)
}
okpay := response.OkPayPublicConfig{
Enabled: data.GetOkPayEnabled(),
AllowTokens: data.GetOkPayAllowTokens(),
}
if ctx.Get(middleware.AdminUserIDKey) != nil {
okpay.ShopID = data.GetOkPayShopID()
okpay.ShopToken = data.GetOkPayShopToken()
okpay.APIURL = data.GetOkPayAPIURL()
okpay.CallbackURL = data.GetOkPayCallbackURL()
okpay.ReturnURL = data.GetOkPayReturnURL()
okpay.TimeoutSeconds = data.GetOkPayTimeoutSeconds()
}
return c.SucJson(ctx, response.PublicConfigResponse{
SupportedAssets: supports,
Site: response.SitePublicConfig{
CashierName: data.GetBrandCashierName(),
LogoURL: data.GetBrandLogoURL(),
WebsiteTitle: data.GetBrandWebsiteTitle(),
SupportLink: data.GetBrandSupportURL(),
},
Epay: response.EpayPublicConfig{
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
},
OkPay: okpay,
})
}
// ListSupportedAssetRecords is currently not wired to a public route.
// Kept as an internal helper candidate for future admin/debug use.
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
network := ctx.QueryParam("network")
list, err := data.ListEnabledChainTokens(network)
if err != nil {
return c.FailJson(ctx, err)
}
return c.SucJson(ctx, list)
}
// GetSupportedAsset is currently not wired to a public route.
// Kept as an internal helper candidate for future admin/debug use.
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return c.FailJson(ctx, constant.ParamsMarshalErr)
}
token, err := data.GetChainTokenByID(id)
if err != nil {
return c.FailJson(ctx, err)
}
if token.ID <= 0 || !token.Enabled {
return c.FailJson(ctx, constant.SupportedAssetNotFound)
}
return c.SucJson(ctx, token)
}
+30
View File
@@ -0,0 +1,30 @@
package crypto
import (
"crypto/sha256"
"github.com/shengdoushi/base58"
)
const AddressLength = 20
const PrefixMainnet = 0x41
// Encode returns the Base58 encoding of input using the Bitcoin alphabet.
func Encode(input []byte) string {
return base58.Encode(input, base58.BitcoinAlphabet)
}
// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum).
func EncodeCheck(input []byte) string {
h256h0 := sha256.New()
h256h0.Write(input)
h0 := h256h0.Sum(nil)
h256h1 := sha256.New()
h256h1.Write(h0)
h1 := h256h1.Sum(nil)
inputCheck := append(append([]byte(nil), input...), h1[:4]...)
return Encode(inputCheck)
}
+102 -20
View File
@@ -1,34 +1,116 @@
module github.com/assimon/luuu
module github.com/GMWalletApp/epusdt
go 1.16
go 1.25.0
require (
github.com/go-redis/redis/v8 v8.11.5
github.com/go-resty/resty/v2 v2.7.0
github.com/golang-module/carbon/v2 v2.0.1
github.com/google/btree v1.0.0 // indirect
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/btcsuite/btcutil v1.0.2
github.com/dromara/carbon/v2 v2.6.15
github.com/ethereum/go-ethereum v1.15.11
github.com/gagliardetto/solana-go v1.16.0
github.com/go-resty/resty/v2 v2.11.0
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/gookit/color v1.5.0
github.com/gookit/goutil v0.4.6
github.com/gookit/validate v1.3.1
github.com/hibiken/asynq v0.23.0
github.com/json-iterator/go v1.1.12
github.com/labstack/echo/v4 v4.6.0
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/libtnb/sqlite v1.0.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/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/cast v1.5.0 // indirect
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.9.0
go.uber.org/zap v1.17.0
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.1.2
gorm.io/gorm v1.21.15
gorm.io/driver/mysql v1.5.1
gorm.io/driver/postgres v1.5.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.20.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/consensys/bavard v0.1.27 // indirect
github.com/consensys/gnark-crypto v0.16.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.1.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/labstack/gommon v0.3.0 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)
+378 -708
View File
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
// Package install provides a first-run setup REST API.
//
// When the .env config file is absent (or has install=true) the HTTP start
// command calls RunInstallServer, which listens on the same address the main
// server will eventually use (default :8000) and mounts two JSON endpoints
// under /install consumed by the frontend install UI:
//
// GET /install/defaults — default field values for the form
// POST /install — validate + write .env, then shut down
//
// The HTTP listen address is submitted as two separate fields (http_bind_addr
// and http_bind_port) and combined internally as "ADDR:PORT" before writing
// the http_listen key in .env. This makes the form easier for users who only
// want to change the port without touching the bind address.
//
// Once the .env is written the install server stops and normal bootstrap
// proceeds on the same port without a restart.
package install
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"time"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/color"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// DefaultInstallAddr is the listen address used by the install API.
// Matches the default http_listen so no extra port is needed.
const DefaultInstallAddr = ":8000"
// InstallRequest is the payload submitted by the install form.
// All fields are optional except AppURI; omitted fields fall back to InstallDefaults().
type InstallRequest struct {
// Application display name (default: epusdt)
AppName string `json:"app_name" form:"app_name" example:"epusdt"`
// Public base URL of the service, e.g. https://pay.example.com (required)
AppURI string `json:"app_uri" form:"app_uri" example:"https://pay.example.com"`
// Bind address for the HTTP server (default: 127.0.0.1)
HttpBindAddr string `json:"http_bind_addr" form:"http_bind_addr" example:"127.0.0.1"`
// Bind port for the HTTP server (default: 8000)
HttpBindPort int `json:"http_bind_port" form:"http_bind_port" example:"8000"`
// Runtime directory for SQLite DB and temp files (default: ./runtime)
RuntimeRootPath string `json:"runtime_root_path" form:"runtime_root_path" example:"./runtime"`
// Directory for application log files (default: ./logs)
LogSavePath string `json:"log_save_path" form:"log_save_path" example:"./logs"`
// Minutes before an unpaid order expires (default: 10)
OrderExpirationTime int `json:"order_expiration_time" form:"order_expiration_time" example:"10"`
// Maximum webhook retry attempts (default: 1)
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
}
// InstallDefaults returns sensible default values for the install form.
func InstallDefaults() InstallRequest {
return InstallRequest{
AppName: "epusdt",
AppURI: "",
HttpBindAddr: "127.0.0.1",
HttpBindPort: 8000,
RuntimeRootPath: "./runtime",
LogSavePath: "./logs",
OrderExpirationTime: 10,
OrderNoticeMaxRetry: 1,
}
}
// installHandler holds the per-invocation state shared between handlers.
type installHandler struct {
envFilePath string
done chan struct{}
}
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
return c.Redirect(http.StatusFound, "/install")
}
return next(c)
}
}
func resolveInstallWWWRoot() string {
// Resolve www/ relative to the executable so SPA routes work regardless
// of the working directory. main.go extracts www/ next to the binary.
wwwRoot := "./www"
if exePath, err := os.Executable(); err == nil {
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
}
}
return wwwRoot
}
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
h := &installHandler{
envFilePath: envFilePath,
done: make(chan struct{}),
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
// api routes for the install frontend
api := e.Group("/api")
api.GET("/install/defaults", h.GetDefaults)
api.POST("/install", h.Submit)
// Redirect browser visits on root to /install so first-run users land
// on the wizard directly. This must run before the static middleware,
// otherwise "/" is intercepted by the SPA index.html fallback.
e.Use(installRootRedirectMiddleware)
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: func(c echo.Context) bool {
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
},
HTML5: true,
Index: "index.html",
Root: wwwRoot,
}))
return e, h
}
// GetDefaults returns default values for the install form.
//
// @Summary Install — get default values
// @Description Returns sensible default field values for the first-run install form.
//
// Available only before the .env config file has been written.
// After installation completes this route is no longer served.
//
// @Tags Install
// @Produce json
// @Success 200 {object} InstallRequest "Default install values"
// @Router /api/install/defaults [get]
func (h *installHandler) GetDefaults(c echo.Context) error {
return c.JSON(http.StatusOK, InstallDefaults())
}
// Submit validates the install payload, writes the .env file, and signals
// the install server to shut down so the main bootstrap can proceed.
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
// the http_listen config key (e.g. 0.0.0.0:8000).
//
// @Summary Install — submit configuration
// @Description Validates the submitted configuration and writes the .env file.
//
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
// http_bind_port must be in the range 165535 if provided.
// app_uri is required. All other fields are optional and fall back to
// the defaults returned by GET /api/install/defaults.
// Sets install=false in the written .env, then shuts down the install
// server so that normal application bootstrap starts on the same port.
// After installation completes this route is no longer served.
//
// @Tags Install
// @Accept json
// @Produce json
// @Param body body InstallRequest true "Install configuration"
// @Success 200 {object} map[string]string "message"
// @Failure 400 {object} map[string]string "error"
// @Failure 500 {object} map[string]string "error"
// @Router /api/install [post]
func (h *installHandler) Submit(c echo.Context) error {
req := new(InstallRequest)
if err := c.Bind(req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
req.AppURI = strings.TrimSpace(req.AppURI)
if req.AppURI == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "app_uri is required"})
}
if req.HttpBindPort != 0 && (req.HttpBindPort < 1 || req.HttpBindPort > 65535) {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "http_bind_port must be between 1 and 65535"})
}
d := InstallDefaults()
if strings.TrimSpace(req.AppName) == "" {
req.AppName = d.AppName
}
if strings.TrimSpace(req.HttpBindAddr) == "" {
req.HttpBindAddr = d.HttpBindAddr
}
if req.HttpBindPort <= 0 {
req.HttpBindPort = d.HttpBindPort
}
if strings.TrimSpace(req.RuntimeRootPath) == "" {
req.RuntimeRootPath = d.RuntimeRootPath
}
if strings.TrimSpace(req.LogSavePath) == "" {
req.LogSavePath = d.LogSavePath
}
if req.OrderExpirationTime <= 0 {
req.OrderExpirationTime = d.OrderExpirationTime
}
if req.OrderNoticeMaxRetry < 0 {
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
}
if err := writeEnvFile(h.envFilePath, req); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
go func() { close(h.done) }()
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"})
}
// RunInstallServer starts the install REST API on listenAddr (default :8000)
// under the /install path and blocks until the .env file has been written.
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
func RunInstallServer(listenAddr, envFilePath string) {
if listenAddr == "" {
listenAddr = DefaultInstallAddr
}
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
// Build a human-readable URL for the console hint.
installHost := listenAddr
if strings.HasPrefix(installHost, ":") {
installHost = "localhost" + installHost
}
color.Green.Printf("[install] no config found — install API available at http://%s/install\n", installHost)
go func() {
<-h.done
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = e.Shutdown(ctx)
}()
if err := e.Start(listenAddr); err != nil && err != http.ErrServerClosed {
color.Red.Printf("[install] server error: %s\n", err)
os.Exit(1)
}
color.Green.Printf("[install] configuration saved to %s, starting…\n", envFilePath)
}
// formControlledKeys are keys whose values always come from the install form
// (or are set unconditionally by the template). Existing config values for
// these keys must NOT be preserved — the form submission takes precedence.
var formControlledKeys = map[string]bool{
"app_name": true,
"app_uri": true,
"http_listen": true,
"runtime_root_path": true,
"log_save_path": true,
"order_expiration_time": true,
"order_notice_max_retry": true,
"install": true,
}
// writeEnvFile renders and writes a minimal .env file.
// If the file already exists, values for keys that are NOT controlled by the
// install form are preserved from the existing file so that operator-specific
// settings (tg_bot_token, db_type, etc.) survive a re-install.
// Keys that the form controls (app_uri, http_listen, …) always use the
// submitted values.
func writeEnvFile(path string, r *InstallRequest) error {
// Collect existing non-empty key→value pairs for non-form keys.
existingValues := map[string]string{}
if data, err := os.ReadFile(path); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if idx := strings.IndexByte(line, '='); idx >= 0 {
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
if v != "" && !formControlledKeys[k] {
existingValues[k] = v
}
}
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
// Render the template into a buffer first.
var buf bytes.Buffer
if err := envTemplate.Execute(&buf, r); err != nil {
return fmt.Errorf("render env template: %w", err)
}
// For non-form keys that already had a value, substitute the existing value
// so the template default does not clobber operator configuration.
lines := strings.Split(buf.String(), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if idx := strings.IndexByte(trimmed, '='); idx >= 0 {
k := strings.TrimSpace(trimmed[:idx])
if existing, ok := existingValues[k]; ok {
lines[i] = k + "=" + existing
}
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open config file: %w", err)
}
defer f.Close()
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
return err
}
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
app_uri={{.AppURI}}
log_level=info
http_access_log=false
sql_debug=false
http_listen={{.HttpBindAddr}}:{{.HttpBindPort}}
static_path=/static
runtime_root_path={{.RuntimeRootPath}}
log_save_path={{.LogSavePath}}
log_max_size=32
log_max_age=7
max_backups=3
# supported values: postgres,mysql,sqlite
db_type=sqlite
# sqlite primary database config
sqlite_database_filename=
sqlite_table_prefix=
# sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db
# background scheduler config
queue_concurrency=10
queue_poll_interval_ms=1000
callback_retry_base_seconds=5
order_expiration_time={{.OrderExpirationTime}}
order_notice_max_retry={{.OrderNoticeMaxRetry}}
api_rate_url=
# Set to true to re-run the install wizard on next startup.
install=false
`))
+224
View File
@@ -0,0 +1,224 @@
package install
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
func TestInstallDefaults(t *testing.T) {
d := InstallDefaults()
if d.AppName != "epusdt" {
t.Errorf("AppName = %q, want epusdt", d.AppName)
}
if d.HttpBindAddr != "127.0.0.1" {
t.Errorf("HttpBindAddr = %q, want 127.0.0.1", d.HttpBindAddr)
}
if d.HttpBindPort != 8000 {
t.Errorf("HttpBindPort = %d, want 8000", d.HttpBindPort)
}
if d.OrderExpirationTime != 10 {
t.Errorf("OrderExpirationTime = %d, want 10", d.OrderExpirationTime)
}
}
func TestWriteEnvFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
req := &InstallRequest{
AppName: "myapp",
AppURI: "http://1.2.3.4:8000",
HttpBindAddr: "0.0.0.0",
HttpBindPort: 9000,
RuntimeRootPath: "./runtime",
LogSavePath: "./logs",
OrderExpirationTime: 15,
OrderNoticeMaxRetry: 3,
}
if err := writeEnvFile(path, req); err != nil {
t.Fatalf("writeEnvFile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read env: %v", err)
}
content := string(data)
for _, want := range []string{
"app_name=myapp",
"app_uri=http://1.2.3.4:8000",
"http_listen=0.0.0.0:9000",
"order_expiration_time=15",
"order_notice_max_retry=3",
"db_type=sqlite",
"install=false",
} {
if !strings.Contains(content, want) {
t.Errorf("env file missing %q\ncontent:\n%s", want, content)
}
}
}
func TestInstallAPIDefaults(t *testing.T) {
h := &installHandler{done: make(chan struct{})}
e := echo.New()
e.GET("/install/defaults", h.GetDefaults)
req := httptest.NewRequest(http.MethodGet, "/install/defaults", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["app_name"] != "epusdt" {
t.Errorf("app_name = %v, want epusdt", body["app_name"])
}
if body["http_bind_addr"] != "127.0.0.1" {
t.Errorf("http_bind_addr = %v, want 127.0.0.1", body["http_bind_addr"])
}
if body["http_bind_port"] != float64(8000) {
t.Errorf("http_bind_port = %v, want 8000", body["http_bind_port"])
}
}
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/install" {
t.Fatalf("Location = %q, want /install", got)
}
}
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
dir := t.TempDir()
wwwRoot := filepath.Join(dir, "www")
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
t.Fatalf("mkdir www root: %v", err)
}
const wantBody = "install-ui"
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
t.Fatalf("write index.html: %v", err)
}
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
req := httptest.NewRequest(http.MethodGet, "/install", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
}
func TestInstallAPISubmit(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
// done channel should be closed after successful submit
select {
case <-h.done:
case <-time.After(500 * time.Millisecond):
t.Fatal("handler did not close done channel within timeout")
}
data, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("env file not written: %v", err)
}
content := string(data)
if !strings.Contains(content, "app_uri=http://10.0.0.1:8000") {
t.Errorf("env file missing app_uri; content:\n%s", content)
}
if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
t.Errorf("env file missing http_listen; content:\n%s", content)
}
}
func TestInstallAPISubmitMissingURI(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if _, err := os.Stat(envPath); err == nil {
t.Error("env file should not have been written for invalid request")
}
}
func TestInstallAPISubmitInvalidPort(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
e := echo.New()
e.POST("/install", h.Submit)
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(envPath); err == nil {
t.Error("env file should not have been written for invalid port")
}
}
+145
View File
@@ -0,0 +1,145 @@
package testutil
import (
"path/filepath"
"testing"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
appLog "github.com/GMWalletApp/epusdt/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("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)
config.HTTPAccessLog = false
config.SQLDebug = false
config.LogLevel = "error"
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.ProviderOrder{},
&mdb.WalletAddress{},
&mdb.ApiKey{},
&mdb.Setting{},
&mdb.NotificationChannel{},
&mdb.Chain{},
&mdb.ChainToken{},
&mdb.RpcNode{},
&mdb.AdminUser{},
)
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB
config.SettingsGetString = func(key string) string {
if dao.Mdb == nil {
return ""
}
var row mdb.Setting
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
return ""
}
return row.Value
}
// Seed all standard chains as enabled so IsChainEnabled checks pass.
for _, network := range []string{
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
} {
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
}
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
// flows; the numeric PID 1001 row lets legacy tests that submit
// `pid=1001` still match.
mainDB.Create(&mdb.ApiKey{
Name: "test-default",
Pid: "test-token", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable,
})
mainDB.Create(&mdb.ApiKey{
Name: "test-pid-1001",
Pid: "1001", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable,
})
if err := dao.Mdb.Create(&mdb.Setting{
Group: "rate",
Key: "rate.forced_usdt_rate",
Value: "1.0",
Type: "string",
}).Error; err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
return func() {
closeDB(t, runtimeDB)
closeDB(t, mainDB)
dao.Mdb = nil
dao.RuntimeDB = nil
config.SettingsGetString = 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)
}
}
+94 -5
View File
@@ -1,18 +1,107 @@
package main
import (
"github.com/assimon/luuu/bootstrap"
"github.com/assimon/luuu/config"
"embed"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color"
)
//go:embed all:www
var wwwDir embed.FS
func executableDir() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", err
}
return filepath.Dir(exePath), nil
}
func extractEmbeddedDir(src embed.FS, embeddedRoot, dstRoot string) error {
sub, err := fs.Sub(src, embeddedRoot)
if err != nil {
return err
}
return fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dstPath := filepath.Join(dstRoot, path)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
in, err := sub.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
func releaseStatic(fs embed.FS, target string) (string, error) {
baseDir, err := executableDir()
if err != nil {
return "", err
}
targetDir := filepath.Join(baseDir, target)
if err := os.RemoveAll(targetDir); err != nil {
return "", err
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return "", err
}
if err := extractEmbeddedDir(fs, target, targetDir); err != nil {
return "", err
}
return targetDir, nil
}
func main() {
wwwwPath, err := releaseStatic(wwwDir, "www")
if err != nil {
panic(err)
}
fmt.Println("www released to:", wwwwPath)
defer func() {
if err := recover(); err != nil {
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)
}
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"net/http"
"strings"
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4"
)
const (
// AdminUserIDKey is the echo.Context key holding the authenticated
// admin user's ID after successful JWT validation.
AdminUserIDKey = "admin_user_id"
// AdminUsernameKey mirrors the username for convenience.
AdminUsernameKey = "admin_username"
)
// CheckAdminJWT validates an Authorization: Bearer <jwt> header against
// the HS256 secret stored in system.jwt_secret. On success it injects
// the admin user ID and username into the echo context.
//
// Accepts both "Authorization: Bearer <jwt>" and a bare "<jwt>" for
// developer convenience (curl / postman). The token itself is always
// cryptographically validated — the relaxed header parsing doesn't
// widen the trust surface.
func CheckAdminJWT() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
if raw == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
}
if !strings.HasPrefix(raw, "Bearer ") {
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
}
token := strings.TrimSpace(raw[len("Bearer "):])
if token == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
}
claims, err := appjwt.Parse(token)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
}
ctx.Set(AdminUserIDKey, claims.AdminUserID)
ctx.Set(AdminUsernameKey, claims.Username)
return next(ctx)
}
}
}
+116 -10
View File
@@ -2,36 +2,142 @@ package middleware
import (
"bytes"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/sign"
"crypto/subtle"
"io"
"net"
"net/url"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/json"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4"
"io/ioutil"
)
// Context keys populated by CheckApiSign after successful verification.
// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id.
const (
ApiKeyIDKey = "api_key_id"
ApiKeyRowKey = "api_key_row"
)
// CheckApiSign validates the body signature against the secret_key of
// the api_keys row matching the submitted "pid" field. A single row is
// valid for all gateway flows — identification is always by pid.
//
// Flow:
// 1. Extract the pid from the request body.
// 2. Look up the enabled row by pid; if missing, return signature error.
// 3. Verify signature == MD5(sorted_params + secret_key).
// 4. Enforce IP whitelist (empty = allow any).
// 5. Bump call_count / last_used_at (best-effort).
// 6. Stash api_key_id + row in context and rewind the body.
func CheckApiSign() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
params, err := ioutil.ReadAll(ctx.Request().Body)
params, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return constant.SignatureErr
}
// Rewind the body for downstream bindings regardless of outcome.
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(params))
m := make(map[string]interface{})
err = json.Cjson.Unmarshal(params, &m)
contentType := ctx.Request().Header.Get("Content-Type")
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
values, parseErr := url.ParseQuery(string(params))
if parseErr != nil {
return constant.SignatureErr
}
for k, vs := range values {
if len(vs) > 0 {
m[k] = vs[0]
}
}
} else {
if err = json.Cjson.Unmarshal(params, &m); err != nil {
return constant.SignatureErr
}
}
signature, ok := m["signature"]
if !ok {
return constant.SignatureErr
}
checkSignature, err := sign.Get(m, config.GetApiAuthToken())
identifier := extractPid(m)
if identifier == "" {
return constant.SignatureErr
}
row, err := data.GetEnabledApiKey(identifier)
if err != nil || row.ID == 0 {
return constant.SignatureErr
}
checkSignature, err := sign.Get(m, row.SecretKey)
if err != nil {
return constant.SignatureErr
}
if checkSignature != signature {
signatureStr, _ := signature.(string)
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 {
return constant.SignatureErr
}
ctx.Request().Body = ioutil.NopCloser(bytes.NewBuffer(params))
if !IsIPWhitelisted(row.IpWhitelist, ctx.RealIP()) {
return constant.SignatureErr
}
_ = data.TouchApiKeyUsage(row.ID)
ctx.Set(ApiKeyIDKey, row.ID)
ctx.Set(ApiKeyRowKey, row)
return next(ctx)
}
}
}
// extractPid reads the "pid" field from the request body. JSON numbers
// unmarshal to float64 (EPAY merchants typically send numeric pid); we
// format with -1 precision to drop trailing zeros so "1000" matches
// regardless of whether the client sent a string or a number.
func extractPid(m map[string]interface{}) string {
v, ok := m["pid"]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
}
return ""
}
// IsIPWhitelisted checks the CSV IP list. Empty list = open.
// Entries may be single IPs or CIDR blocks.
func IsIPWhitelisted(csv, remote string) bool {
csv = strings.TrimSpace(csv)
if csv == "" {
return true
}
remoteIP := net.ParseIP(remote)
for _, raw := range strings.Split(csv, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
_, cidr, err := net.ParseCIDR(entry)
if err == nil && remoteIP != nil && cidr.Contains(remoteIP) {
return true
}
continue
}
if entry == remote {
return true
}
}
return false
}
+15
View File
@@ -0,0 +1,15 @@
package dao
import (
"log"
)
func Init() {
if err := DBInit(); err != nil {
log.Fatalf("DBInit err: %v", err)
}
if err := RuntimeInit(); err != nil {
log.Fatalf("RuntimeInit err: %v", err)
}
}
+16 -33
View File
@@ -1,47 +1,30 @@
package dao
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"strings"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"time"
)
var Mdb *gorm.DB
// MysqlInit 数据库初始化
func MysqlInit() {
var err error
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("mysql_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
panic(err)
func DBInit() error {
dbType := viper.GetString("db_type")
if strings.EqualFold(dbType, "postgres") {
if err := PostgreSQLInit(); err != nil {
return err
}
if config.AppDebug {
Mdb = Mdb.Debug()
} else if strings.EqualFold(dbType, "sqlite") {
if err := SqliteInit(); err != nil {
return err
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err)
panic(err)
} else {
if err := MysqlInit(); err != nil {
return err
}
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
panic(err)
}
log.Sugar.Debug("[store_db] mysql connDB success")
MdbTableInit()
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package dao
import (
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// MysqlInit 数据库初始化
func MysqlInit() error {
var err error
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("mysql_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[store_db] mysql open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err)
// panic(err)
time.Sleep(10 * time.Second)
MysqlInit()
return err
}
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
// panic(err)
time.Sleep(10 * time.Second)
MysqlInit()
return err
}
log.Sugar.Debug("[store_db] mysql connDB success")
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package dao
import (
"fmt"
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// PostgreSQLInit 数据库初始化
func PostgreSQLInit() error {
var err error
user := viper.GetString("postgres_user")
pass := viper.GetString("postgres_passwd")
host := viper.GetString("postgres_host")
port := viper.GetString("postgres_port")
dbname := viper.GetString("postgres_database")
dsn := fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
user, pass, host, port, dbname,
)
Mdb, err = gorm.Open(postgres.Open(dsn),
&gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("postgres_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
},
)
if err != nil {
color.Red.Printf("[store_db] postgres open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
sqlDB, err := Mdb.DB()
if err != nil {
color.Red.Printf("[store_db] postgres get DB,err=%s\n", err)
// panic(err)
return err
}
sqlDB.SetMaxIdleConns(viper.GetInt("postgres_max_idle_conns"))
sqlDB.SetMaxOpenConns(viper.GetInt("postgres_max_open_conns"))
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("postgres_max_life_time")))
err = sqlDB.Ping()
if err != nil {
color.Red.Printf("[store_db] postgres connDB err:%s", err.Error())
// panic(err)
return err
}
log.Sugar.Debug("[store_db] postgres connDB success")
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package dao
import (
"os"
"path/filepath"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"github.com/spf13/viper"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// SqliteInit 数据库初始化
func SqliteInit() error {
var err error
dbFilename := config.GetPrimarySqlitePath()
if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil {
color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err)
return err
}
color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename)
Mdb, err = openDB(dbFilename, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("sqlite_table_prefix"),
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[store_db] sqlite open DB,err=%s\n", err)
// panic(err)
return err
}
if config.SQLDebug {
Mdb = Mdb.Debug()
}
if _, err = configureSQLite(Mdb, 1); err != nil {
color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error())
return err
}
log.Sugar.Debug("[store_db] sqlite connDB success")
return nil
}
+16
View File
@@ -0,0 +1,16 @@
//go:build sqlite_cgo
package dao
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !sqlite_cgo
package dao
import (
"github.com/libtnb/sqlite"
"gorm.io/gorm"
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+261
View File
@@ -0,0 +1,261 @@
package dao
import (
"encoding/json"
"strconv"
"strings"
"sync"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color"
"gorm.io/gorm/clause"
)
var once sync.Once
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
// the minimum static rows: chains, chain tokens, default settings, and
// a Telegram notification channel migrated from legacy settings keys.
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
// established. The install wizard (see install/installer.go) writes the
// .env file before bootstrap runs, so this function always finds the DB
// available.
func MdbTableInit() {
once.Do(func() {
migrations := []struct {
name string
model interface{}
}{
{"Orders", &mdb.Orders{}},
{"WalletAddress", &mdb.WalletAddress{}},
{"AdminUser", &mdb.AdminUser{}},
{"ApiKey", &mdb.ApiKey{}},
{"Setting", &mdb.Setting{}},
{"NotificationChannel", &mdb.NotificationChannel{}},
{"Chain", &mdb.Chain{}},
{"ChainToken", &mdb.ChainToken{}},
{"RpcNode", &mdb.RpcNode{}},
{"ProviderOrder", &mdb.ProviderOrder{}},
}
for _, m := range migrations {
if err := Mdb.AutoMigrate(m.model); err != nil {
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
return
}
}
seedChains()
seedRpcNodes()
seedChainTokens()
seedDefaultSettings()
seedTelegramChannelFromSettings()
})
}
// seedChains inserts the built-in networks as enabled rows. Uses
// ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist.
func seedChains() {
defaults := []mdb.Chain{
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chains err=%s\n", err)
}
}
// seedChainTokens inserts default token configurations for each chain.
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
// re-runs are no-ops and admin edits to contract_address / decimals /
// min_amount persist across restarts.
func seedChainTokens() {
// Contract-address rows are matched by scanners via exact contract
// lookup. Native-token rows (empty contract_address) are matched by
// symbol — these act as admin toggles for the native payment path.
defaults := []mdb.ChainToken{
// TRON
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
// Ethereum
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
// Solana
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
// Polygon
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
// Plasma
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
}
}
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
// (network, url) so re-runs are idempotent and admin edits persist.
func seedRpcNodes() {
defaults := []mdb.RpcNode{
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
}
for _, d := range defaults {
var count int64
if err := Mdb.Model(&mdb.RpcNode{}).
Where("network = ? AND url = ?", d.Network, d.Url).
Count(&count).Error; err != nil {
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
continue
}
if count > 0 {
continue
}
if err := Mdb.Create(&d).Error; err != nil {
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
}
}
}
// seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() {
okPayCallbackURL := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if okPayCallbackURL != "" {
okPayCallbackURL += "/payments/okpay/v1/notify"
}
defaults := []mdb.Setting{
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayTimeoutSeconds, Value: "10", Type: mdb.SettingTypeInt},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAllowTokens, Value: "USDT,TRX", Type: mdb.SettingTypeString},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
}
}
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
// Telegram config into a notification_channels row so the notify dispatcher
// can use it. Idempotent: no-op when any telegram channel already exists.
//
// Legacy keys read from settings:
//
// system.telegram_bot_token → TelegramConfig.BotToken
// system.telegram_chat_id → TelegramConfig.ChatID
// system.telegram_payment_notice_enabled → events.pay_success
// system.telegram_abnormal_notice_enabled → events.order_expired
func seedTelegramChannelFromSettings() {
var count int64
if err := Mdb.Model(&mdb.NotificationChannel{}).
Where("type = ?", mdb.NotificationTypeTelegram).
Count(&count).Error; err != nil {
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
return
}
if count > 0 {
return
}
SyncTelegramChannelFromSettings()
}
// SyncTelegramChannelFromSettings reads the four Telegram keys from the
// settings table and upserts the matching notification_channels row:
// - creates a new row when none exists yet
// - updates config+events on the first existing row when settings change
//
// This is called at startup (via seedTelegramChannelFromSettings) and by
// the settings controller whenever any Telegram key is changed at runtime.
func SyncTelegramChannelFromSettings() {
keys := []string{
"system.telegram_bot_token",
"system.telegram_chat_id",
"system.telegram_payment_notice_enabled",
"system.telegram_abnormal_notice_enabled",
}
var rows []mdb.Setting
if err := Mdb.Where("key IN ?", keys).Find(&rows).Error; err != nil {
color.Red.Printf("[store_db] sync telegram channel: read settings err=%s\n", err)
return
}
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.Key] = r.Value
}
botToken := strings.TrimSpace(m["system.telegram_bot_token"])
chatIDStr := strings.TrimSpace(m["system.telegram_chat_id"])
if botToken == "" || chatIDStr == "" {
return // No telegram config to sync.
}
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
if err != nil {
color.Red.Printf("[store_db] sync telegram channel: invalid chat_id=%q\n", chatIDStr)
return
}
paymentEnabled, _ := strconv.ParseBool(m["system.telegram_payment_notice_enabled"])
abnormalEnabled, _ := strconv.ParseBool(m["system.telegram_abnormal_notice_enabled"])
configJSON, _ := json.Marshal(map[string]interface{}{
"bot_token": botToken,
"chat_id": chatID,
"proxy": "",
})
eventsJSON, _ := json.Marshal(map[string]bool{
mdb.NotifyEventPaySuccess: paymentEnabled,
mdb.NotifyEventOrderExpired: abnormalEnabled,
mdb.NotifyEventDailyReport: false,
})
// Try to update an existing row first.
// Use Find (not First) to avoid GORM printing a spurious
// "record not found" log when the table is empty on first boot.
var existing mdb.NotificationChannel
Mdb.Model(&mdb.NotificationChannel{}).
Where("type = ?", mdb.NotificationTypeTelegram).
Order("id ASC").Limit(1).Find(&existing)
if existing.ID != 0 {
// Row exists — patch config and events.
if err2 := Mdb.Model(&existing).Updates(map[string]interface{}{
"config": string(configJSON),
"events": string(eventsJSON),
}).Error; err2 != nil {
color.Red.Printf("[store_db] sync telegram channel update err=%s\n", err2)
}
return
}
// No row yet — create one.
ch := mdb.NotificationChannel{
Type: mdb.NotificationTypeTelegram,
Name: "Telegram",
Config: string(configJSON),
Events: string(eventsJSON),
Enabled: true,
}
if err := Mdb.Create(&ch).Error; err != nil {
color.Red.Printf("[store_db] sync telegram channel create err=%s\n", err)
} else {
color.Green.Printf("[store_db] created telegram notification_channel from settings (id=%d)\n", ch.ID)
}
}
-41
View File
@@ -1,41 +0,0 @@
package dao
import (
"context"
"fmt"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"github.com/gookit/color"
"github.com/spf13/viper"
"time"
)
var Rdb *redis.Client
func RedisInit() {
options := redis.Options{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")), // Redis地址
DB: viper.GetInt("redis_db"), // Redis库
PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小
MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数
IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间
}
if viper.GetString("redis_passwd") != "" {
options.Password = viper.GetString("redis_passwd")
}
Rdb = redis.NewClient(&options)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pong, err := Rdb.Ping(ctx).Result()
if err == redis.Nil {
log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.")
} else if err != nil {
color.Red.Printf("[store_redis] redis connRdb err,err=%s", err)
panic(err)
} else {
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
}
}
+54
View File
@@ -0,0 +1,54 @@
package dao
import (
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var RuntimeDB *gorm.DB
func RuntimeInit() error {
var err error
runtimePath := config.GetRuntimeSqlitePath()
color.Green.Printf("[runtime_db] sqlite filename: %s\n", runtimePath)
RuntimeDB, err = openDB(runtimePath, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[runtime_db] sqlite open DB,err=%s\n", err)
return err
}
concurrency := config.GetQueueConcurrency()
if concurrency < 2 {
concurrency = 2
}
if concurrency > 16 {
concurrency = 16
}
if _, err = configureSQLite(RuntimeDB, concurrency); err != nil {
color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error())
return err
}
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err
}
log.Sugar.Debug("[runtime_db] sqlite connDB success")
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package dao
import (
"database/sql"
"gorm.io/gorm"
)
func configureSQLite(db *gorm.DB, maxOpenConns int) (*sql.DB, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
if maxOpenConns <= 0 {
maxOpenConns = 1
}
sqlDB.SetMaxOpenConns(maxOpenConns)
sqlDB.SetMaxIdleConns(1)
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil {
return nil, err
}
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
return nil, err
}
if err := sqlDB.Ping(); err != nil {
return nil, err
}
return sqlDB, nil
}
+304
View File
@@ -0,0 +1,304 @@
package data
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const defaultAdminUsername = "admin"
const InitialAdminPasswordHashAlgorithm = "sha256"
var (
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
)
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
// so the frontend can detect whether the operator is still using the
// initial password.
type InitialAdminPasswordHashInfo struct {
Algorithm string `json:"algorithm" example:"sha256"`
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
PasswordChanged bool `json:"password_changed" example:"false"`
Available bool `json:"available" example:"true"`
}
// EnsureDefaultAdmin seeds an initial admin account when no admin user
// exists. The password is randomly generated and returned so the caller
// can print it to the console. Idempotent — subsequent calls return
// ("", false, nil).
func EnsureDefaultAdmin() (password string, created bool, err error) {
var count int64
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
return "", false, err
}
if count > 0 {
return "", false, nil
}
password = randomAdminPassword()
hash, err := HashPassword(password)
if err != nil {
return "", false, err
}
user := &mdb.AdminUser{
Username: defaultAdminUsername,
PasswordHash: hash,
Status: mdb.AdminUserStatusEnable,
}
if err := dao.Mdb.Create(user).Error; err != nil {
return "", false, err
}
if err := initAdminPasswordState(password); err != nil {
// Account was already created; surface both for visibility while
// preserving created=true and password for emergency fallback.
return password, true, err
}
return password, true, nil
}
func randomAdminPassword() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// HashInitialAdminPassword fingerprints the initial plaintext password so
// the frontend can compare user input locally without exposing plaintext.
func HashInitialAdminPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return fmt.Sprintf("%x", sum[:])
}
func initAdminPasswordState(plain string) error {
hash := HashInitialAdminPassword(plain)
settings := []mdb.Setting{
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
Value: plain, Type: mdb.SettingTypeString,
Description: "One-time readable initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
Value: hash, Type: mdb.SettingTypeString,
Description: "SHA-256 fingerprint for initial admin password",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
},
{
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: "false", Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
},
}
for _, row := range settings {
if err := upsertSettingRow(dao.Mdb, row); err != nil {
return err
}
}
// Keep in-process cache coherent for the current process.
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
settingsCacheMu.Unlock()
return nil
}
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
}).Create(&row).Error
}
// ConsumeInitialAdminPassword returns the one-time initial admin password.
// After a successful read, the plaintext is deleted and cannot be fetched
// again.
func ConsumeInitialAdminPassword() (string, error) {
var password string
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
row := new(mdb.Setting)
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Limit(1).
Find(row).Error; err != nil {
return err
}
if row.ID == 0 || row.Value == "" {
var fetched mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
Limit(1).
Find(&fetched).Error; err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
return ErrInitAdminPasswordAlreadyFetched
}
return ErrInitAdminPasswordUnavailable
}
password = row.Value
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInitAdminPasswordAlreadyFetched
}
return upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordFetched,
Value: "true",
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been fetched",
})
})
if err != nil {
return "", err
}
settingsCacheMu.Lock()
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
settingsCacheMu.Unlock()
return password, nil
}
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
// and the changed-state flag used by the admin frontend.
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: "",
PasswordChanged: true,
Available: false,
}, nil
}
return &InitialAdminPasswordHashInfo{
Algorithm: InitialAdminPasswordHashAlgorithm,
PasswordHash: hash,
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
Available: true,
}, nil
}
// IsUsingInitialAdminPassword reports whether the current admin password is
// still considered the seeded initial password.
func IsUsingInitialAdminPassword() bool {
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
if hash == "" {
return false
}
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
}
// HashPassword bcrypts a plaintext password.
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// VerifyPassword compares a plaintext password against a bcrypt hash.
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// GetAdminUserByUsername returns the row for a username (case-insensitive).
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
u := new(mdb.AdminUser)
err := dao.Mdb.Model(u).
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
Limit(1).Find(u).Error
return u, err
}
// GetAdminUserByID returns the row for an ID.
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
u := new(mdb.AdminUser)
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
return u, err
}
// UpdateAdminUserPassword rehashes and persists a new password.
func UpdateAdminUserPassword(id uint64, newPlain string) error {
hash, err := HashPassword(newPlain)
if err != nil {
return err
}
changedCacheValue := ""
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mdb.AdminUser{}).
Where("id = ?", id).
Update("password_hash", hash).Error; err != nil {
return err
}
// Old installs may not have initial-password metadata; skip in
// that case to keep password updates backward compatible.
var initHashRow mdb.Setting
if err := tx.Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
Limit(1).
Find(&initHashRow).Error; err != nil {
return err
}
initHash := strings.TrimSpace(initHashRow.Value)
if initHash == "" {
return nil
}
newHash := HashInitialAdminPassword(newPlain)
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
changedValue := "true"
if !changed {
changedValue = "false"
}
if err := upsertSettingRow(tx, mdb.Setting{
Group: mdb.SettingGroupSystem,
Key: mdb.SettingKeyInitAdminPasswordChanged,
Value: changedValue,
Type: mdb.SettingTypeBool,
Description: "Whether initial admin password has been changed",
}); err != nil {
return err
}
changedCacheValue = changedValue
return nil
})
if err != nil {
return err
}
if changedCacheValue != "" {
settingsCacheMu.Lock()
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
settingsCacheMu.Unlock()
}
return nil
}
// TouchAdminUserLastLogin stamps last_login_at to now.
func TouchAdminUserLastLogin(id uint64) error {
return dao.Mdb.Model(&mdb.AdminUser{}).
Where("id = ?", id).
Update("last_login_at", carbon.Now().StdTime()).Error
}
+137
View File
@@ -0,0 +1,137 @@
package data
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"gorm.io/gorm"
)
// GetEnabledApiKey looks up an enabled merchant credential row by PID.
// A single row is valid for both EPAY and GMPAY flows; the route
// determines the flow. Returns (row, nil) on success; (zero row, nil)
// when no match; error only on true DB failure.
func GetEnabledApiKey(pid string) (*mdb.ApiKey, error) {
row := new(mdb.ApiKey)
err := dao.Mdb.Model(row).
Where("pid = ?", strings.TrimSpace(pid)).
Where("status = ?", mdb.ApiKeyStatusEnable).
Limit(1).Find(row).Error
return row, err
}
// GetApiKeyByID fetches a row by primary key (including disabled).
func GetApiKeyByID(id uint64) (*mdb.ApiKey, error) {
row := new(mdb.ApiKey)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// ListApiKeys returns all rows ordered by ID descending.
func ListApiKeys() ([]mdb.ApiKey, error) {
var rows []mdb.ApiKey
err := dao.Mdb.Model(&mdb.ApiKey{}).Order("id DESC").Find(&rows).Error
return rows, err
}
// CreateApiKey persists a new row.
func CreateApiKey(row *mdb.ApiKey) error {
return dao.Mdb.Create(row).Error
}
// UpdateApiKeyFields updates a whitelist of mutable fields on a row.
func UpdateApiKeyFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.ApiKey{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteApiKeyByID removes (soft-deletes) a row.
func DeleteApiKeyByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ApiKey{}).Error
}
// TouchApiKeyUsage increments call_count and stamps last_used_at.
// Called from the signature middleware after a successful verify.
func TouchApiKeyUsage(id uint64) error {
return dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"call_count": gorm.Expr("call_count + 1"),
"last_used_at": carbon.Now().StdTime(),
}).Error
}
// basePid is the starting PID value for api_keys.
const basePid = 1000
// NextPid returns the next available numeric PID for api_keys. Scans
// ALL rows (including soft-deleted) to avoid reusing a PID whose row
// is still in the table with a non-null deleted_at — the unique index
// on pid doesn't know about soft deletes, so reuse would trigger a
// DB-level unique violation on Create.
func NextPid() (int, error) {
var rows []mdb.ApiKey
err := dao.Mdb.Unscoped().Model(&mdb.ApiKey{}).Select("pid").Find(&rows).Error
if err != nil {
return basePid, err
}
maxPid := basePid - 1
for _, r := range rows {
if pid, err := strconv.Atoi(strings.TrimSpace(r.Pid)); err == nil && pid > maxPid {
maxPid = pid
}
}
return maxPid + 1, nil
}
// SeededApiKey carries the details of a newly created default key so
// the caller can print them to the console (secret is never surfaced
// again via the admin API — it has to be viewed via /api-keys/:id/secret
// after login).
type SeededApiKey struct {
Pid string
Name string
SecretKey string
}
// EnsureDefaultApiKey seeds ONE universal API key on a fresh install.
// The seeded key is valid for all three gateway flows — the admin can
// create additional keys later, each also universal. Idempotent: if
// any api_keys row already exists, returns nil without seeding.
func EnsureDefaultApiKey() (*SeededApiKey, error) {
var count int64
if err := dao.Mdb.Model(&mdb.ApiKey{}).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, nil
}
secret := generateHex(32)
row := &mdb.ApiKey{
Name: "default",
Pid: strconv.Itoa(basePid),
SecretKey: secret,
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
return nil, err
}
return &SeededApiKey{
Pid: row.Pid,
Name: row.Name,
SecretKey: secret,
}, nil
}
func generateHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+54
View File
@@ -0,0 +1,54 @@
package data
import (
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListChains returns all chain rows ordered by id.
func ListChains() ([]mdb.Chain, error) {
var rows []mdb.Chain
err := dao.Mdb.Model(&mdb.Chain{}).Order("id ASC").Find(&rows).Error
return rows, err
}
// ListEnabledChains returns only rows with Enabled=true.
func ListEnabledChains() ([]mdb.Chain, error) {
var rows []mdb.Chain
err := dao.Mdb.Model(&mdb.Chain{}).
Where("enabled = ?", true).
Order("id ASC").Find(&rows).Error
return rows, err
}
// GetChainByNetwork fetches one row by network key.
func GetChainByNetwork(network string) (*mdb.Chain, error) {
row := new(mdb.Chain)
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Limit(1).Find(row).Error
return row, err
}
// IsChainEnabled returns whether the row exists and Enabled=true. A
// missing row is treated as disabled — scanners must be able to
// short-circuit even before the admin creates the chain entry.
func IsChainEnabled(network string) bool {
row, err := GetChainByNetwork(network)
if err != nil || row.ID == 0 {
return false
}
return row.Enabled
}
// UpdateChainFields patches mutable columns.
func UpdateChainFields(network string, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.Chain{}).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Updates(fields).Error
}
+125
View File
@@ -0,0 +1,125 @@
package data
import (
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
// An empty network returns all enabled tokens across all networks.
func ListEnabledChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{}).Where("enabled = ?", true)
if n := strings.ToLower(strings.TrimSpace(network)); n != "" {
tx = tx.Where("network = ?", n)
}
err := tx.Order("network ASC, id ASC").Find(&rows).Error
return rows, err
}
// ListChainTokens returns rows optionally filtered by network.
func ListChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{})
if network != "" {
tx = tx.Where("network = ?", strings.ToLower(network))
}
err := tx.Order("id ASC").Find(&rows).Error
return rows, err
}
// ListEnabledChainTokensByNetwork returns the enabled tokens for a network.
// Scanners call this to learn which contract addresses to subscribe to.
func ListEnabledChainTokensByNetwork(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
err := dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ?", strings.ToLower(network)).
Where("enabled = ?", true).
Find(&rows).Error
return rows, err
}
// GetEnabledChainTokenByContract finds one enabled token by (network,
// contract_address). Used by EVM/TRON scanners to resolve the symbol
// and decimals of an incoming transfer. Returns (zero row, nil) if no
// match. The contract address match is case-insensitive.
func GetEnabledChainTokenByContract(network, contractAddress string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
addr := strings.TrimSpace(contractAddress)
if addr == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("LOWER(contract_address) = ?", strings.ToLower(addr)).
Limit(1).Find(row).Error
return row, err
}
// GetEnabledChainTokenBySymbol finds one enabled token by (network, symbol).
// Used by Solana native-SOL path where there's no contract address.
func GetEnabledChainTokenBySymbol(network, symbol string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
sym := strings.TrimSpace(symbol)
if sym == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("UPPER(symbol) = ?", strings.ToUpper(sym)).
Limit(1).Find(row).Error
return row, err
}
// GetChainTokenByID fetches by primary key.
func GetChainTokenByID(id uint64) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateChainToken inserts a row.
// If a soft-deleted row with the same (network, symbol) exists it is
// restored with the incoming field values rather than creating a duplicate
// that would violate the unique index.
func CreateChainToken(row *mdb.ChainToken) error {
deleted := new(mdb.ChainToken)
err := dao.Mdb.Unscoped().
Where("network = ? AND symbol = ? AND deleted_at IS NOT NULL", row.Network, row.Symbol).
Limit(1).Find(deleted).Error
if err != nil {
return err
}
if deleted.ID > 0 {
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
"deleted_at": nil,
"contract_address": row.ContractAddress,
"decimals": row.Decimals,
"enabled": row.Enabled,
"min_amount": row.MinAmount,
}).Error
if err != nil {
return err
}
row.ID = deleted.ID
return nil
}
return dao.Mdb.Create(row).Error
}
// UpdateChainTokenFields patches mutable columns.
func UpdateChainTokenFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.ChainToken{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteChainTokenByID soft-deletes the row.
func DeleteChainTokenByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ChainToken{}).Error
}
@@ -0,0 +1,81 @@
package data
import (
"encoding/json"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// ListNotificationChannels returns all rows, optionally filtered by type.
func ListNotificationChannels(channelType string) ([]mdb.NotificationChannel, error) {
var rows []mdb.NotificationChannel
tx := dao.Mdb.Model(&mdb.NotificationChannel{})
if channelType != "" {
tx = tx.Where("type = ?", strings.ToLower(channelType))
}
err := tx.Order("id DESC").Find(&rows).Error
return rows, err
}
// ListEnabledChannelsByEvent returns enabled rows whose Events JSON has
// the named event key set to true. Done in Go (rather than JSON_EXTRACT)
// because the runtime DB is DB-agnostic (MySQL/Postgres/SQLite).
func ListEnabledChannelsByEvent(event string) ([]mdb.NotificationChannel, error) {
var rows []mdb.NotificationChannel
if err := dao.Mdb.Model(&mdb.NotificationChannel{}).
Where("enabled = ?", true).
Find(&rows).Error; err != nil {
return nil, err
}
out := rows[:0]
for _, r := range rows {
events := map[string]bool{}
if r.Events != "" {
_ = json.Unmarshal([]byte(r.Events), &events)
}
if events[event] {
out = append(out, r)
}
}
return out, nil
}
// GetFirstEnabledTelegramChannel returns the oldest enabled telegram
// row, or nil ID if none. Telegram bot startup uses this as the primary
// instance backing /start and command handlers.
func GetFirstEnabledTelegramChannel() (*mdb.NotificationChannel, error) {
row := new(mdb.NotificationChannel)
err := dao.Mdb.Model(row).
Where("type = ?", mdb.NotificationTypeTelegram).
Where("enabled = ?", true).
Order("id ASC").
Limit(1).Find(row).Error
return row, err
}
// GetNotificationChannelByID fetches by PK.
func GetNotificationChannelByID(id uint64) (*mdb.NotificationChannel, error) {
row := new(mdb.NotificationChannel)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateNotificationChannel inserts a new row.
func CreateNotificationChannel(row *mdb.NotificationChannel) error {
return dao.Mdb.Create(row).Error
}
// UpdateNotificationChannelFields patches mutable columns.
func UpdateNotificationChannelFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.NotificationChannel{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteNotificationChannelByID soft-deletes a row.
func DeleteNotificationChannelByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.NotificationChannel{}).Error
}
+151
View File
@@ -0,0 +1,151 @@
package data
import (
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
// OrderListFilter bundles every filter supported by the admin orders
// list page. Zero values are ignored so callers can pass only what the
// user actually filtered on.
type OrderListFilter struct {
Status int
Network string
Token string
Address string
Keyword string // matches trade_id / order_id / block_transaction_id
StartAt *time.Time
EndAt *time.Time
Page int
PageSize int
// ParentOnly restricts the result to top-level orders only
// (parent_trade_id = ''). Sub-orders are excluded from the listing.
ParentOnly bool
}
// ListOrders returns a paginated order slice plus the total count under
// the same filter (for the UI pagination bar).
func ListOrders(f OrderListFilter) ([]mdb.Orders, int64, error) {
tx := buildOrderListQuery(f)
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
page := f.Page
if page < 1 {
page = 1
}
size := f.PageSize
if size < 1 {
size = 20
}
if size > 200 {
size = 200
}
var rows []mdb.Orders
err := tx.Order("id DESC").
Offset((page - 1) * size).Limit(size).
Find(&rows).Error
return rows, total, err
}
func buildOrderListQuery(f OrderListFilter) *gorm.DB {
tx := dao.Mdb.Model(&mdb.Orders{})
if f.ParentOnly {
tx = tx.Where("parent_trade_id = ?", "")
}
if f.Status > 0 {
tx = tx.Where("status = ?", f.Status)
}
if f.Network != "" {
tx = tx.Where("network = ?", strings.ToLower(f.Network))
}
if f.Token != "" {
tx = tx.Where("token = ?", strings.ToUpper(f.Token))
}
if f.Address != "" {
tx = tx.Where("receive_address = ?", f.Address)
}
if f.Keyword != "" {
kw := "%" + strings.TrimSpace(f.Keyword) + "%"
tx = tx.Where("trade_id LIKE ? OR order_id LIKE ? OR block_transaction_id LIKE ?", kw, kw, kw)
}
if f.StartAt != nil {
tx = tx.Where("created_at >= ?", *f.StartAt)
}
if f.EndAt != nil {
tx = tx.Where("created_at <= ?", *f.EndAt)
}
return tx
}
// CountOrdersByStatus returns how many orders exist in each status.
// Used by the dashboard overview card.
func CountOrdersByStatus() (map[int]int64, error) {
type row struct {
Status int
Total int64
}
var rows []row
err := dao.Mdb.Model(&mdb.Orders{}).
Select("status, COUNT(*) AS total").
Group("status").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[int]int64{}
for _, r := range rows {
out[r.Status] = r.Total
}
return out, nil
}
// CloseOrderManually transitions a pending order to expired. Only
// touches rows currently in StatusWaitPay so idempotent / safe.
func CloseOrderManually(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitPay).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
// ReopenOrderCallback flips callback_confirm back to NO so the mq
// worker picks it up on the next tick. Used by "resend callback".
func ReopenOrderCallback(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusPaySuccess).
Updates(map[string]interface{}{
"callback_confirm": mdb.CallBackConfirmNo,
"callback_num": 0,
})
return result.RowsAffected > 0, result.Error
}
// CountOrdersByAddress returns order counts grouped by receive_address.
// The admin wallet list annotates each wallet row with this number.
func CountOrdersByAddress() (map[string]int64, error) {
type row struct {
Address string `gorm:"column:receive_address"`
Total int64
}
var rows []row
err := dao.Mdb.Model(&mdb.Orders{}).
Select("receive_address, COUNT(*) AS total").
Group("receive_address").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, r := range rows {
out[r.Address] = r.Total
}
return out, nil
}
+307 -57
View File
@@ -1,109 +1,359 @@
package data
import (
"context"
"fmt"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"errors"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"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 normalizeLockNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}
func normalizeLockAddress(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeLockNetwork(network)) {
return strings.ToLower(address)
}
return address
}
func normalizeLockToken(token string) string {
return strings.ToUpper(strings.TrimSpace(token))
}
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
if isEVMNetwork(network) {
return tx.Where("lower(address) = ?", address)
}
return tx.Where("address = ?", address)
}
// GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err
}
// GetOrderInfoByTradeId 通过交易号查询订单
// GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err
}
// CreateOrderWithTransaction 事务创建订单
// CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error
return err
return tx.Model(order).Create(order).Error
}
// GetOrderByBlockIdWithTransaction 通过区块获取订单
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
return order, err
}
// OrderSuccessWithTransaction 事务支付成功
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", req.TradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
}).Error
return err
})
return result.RowsAffected > 0, result.Error
}
// GetPendingCallbackOrders 查询出等待回调的订单
func GetPendingCallbackOrders() ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(orders).
Where("callback_num < ?", 5).
// GetPendingCallbackOrders returns the minimal callback scheduling state.
func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
var orders []PendingCallbackOrder
query := dao.Mdb.Model(&mdb.Orders{}).
Select("trade_id", "callback_num", "callback_confirm", "updated_at").
Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess).
Order("updated_at asc")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp persists a callback attempt result.
func SaveCallBackOrdersResp(order *mdb.Orders) error {
return dao.Mdb.Model(order).
Where("id = ?", order.ID).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
}
// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
result := dao.Mdb.Model(mdb.Orders{}).
Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent.
func CountActiveSubOrders(parentTradeId string) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay).
Count(&count).Error
return count, err
}
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).
Where("parent_trade_id = ?", parentTradeId).
Where("token = ?", token).
Where("network = ?", network).
Where("status = ?", mdb.StatusWaitPay).
Limit(1).
Find(order).Error
return order, err
}
// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id.
func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("trade_id != ?", excludeTradeId).
Where("status = ?", mdb.StatusWaitPay).
Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp 保存订单回调结果
func SaveCallBackOrdersResp(order *mdb.Orders) error {
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
// MarkParentOrderSuccess marks the parent order as paid and records which sub-order
// settled it. Only the status, callback_confirm, and pay_by_sub_id fields are updated;
// the parent's own block_transaction_id, actual_amount, and receive_address are
// intentionally left unchanged because the parent was not directly paid.
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
return MarkParentOrderSuccessWithTransaction(dao.Mdb, parentTradeId, sub)
}
// MarkParentOrderSuccessWithTransaction is the transactional variant of
// MarkParentOrderSuccess.
func MarkParentOrderSuccessWithTransaction(tx *gorm.DB, parentTradeId string, sub *mdb.Orders) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
"pay_by_sub_id": sub.ID,
})
return result.RowsAffected > 0, result.Error
}
// ExpireSiblingSubOrdersWithTransaction expires all waiting sibling
// sub-orders under the same parent in one statement.
func ExpireSiblingSubOrdersWithTransaction(tx *gorm.DB, parentTradeId string, excludeTradeId string) error {
return tx.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("trade_id != ?", excludeTradeId).
Where("status = ?", mdb.StatusWaitPay).
Update("status", mdb.StatusExpired).Error
}
// MarkOrderSelected sets is_selected=true for the given trade_id.
func MarkOrderSelected(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Update("is_selected", true).Error
}
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
// child-order attempts that should not remain selectable/reusable.
func ExpireOrderByTradeID(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"status": mdb.StatusExpired,
"is_selected": false,
}).Error
return err
}
// UpdateOrderIsExpirationById 通过id设置订单过期
func UpdateOrderIsExpirationById(id uint64) error {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error
return err
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
// Called on the parent order when a sub-order is created or returned.
func RefreshOrderExpiration(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Update("created_at", time.Now()).Error
}
// 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
// ResetCallbackConfirmOk sets callback_confirm back to Ok.
// Prevents the callback worker from retrying a sub-order with an empty notify_url.
func ResetCallbackConfirmOk(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Update("callback_confirm", mdb.CallBackConfirmOk).Error
}
// GetActiveSubOrders returns all active sub-orders under a parent.
func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Where("status = ?", mdb.StatusWaitPay).
Find(&orders).Error
return orders, err
}
// GetAllSubOrders returns all sub-orders (any status) under a parent order,
// ordered by creation time ascending.
func GetAllSubOrders(parentTradeId string) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
Order("created_at ASC").
Find(&orders).Error
return orders, err
}
// GetSubOrdersByParentTradeIds batch-fetches all sub-orders (any status)
// whose parent_trade_id is in the given set. Ordered by parent_trade_id
// then created_at ASC so callers can build a grouped map in one pass.
func GetSubOrdersByParentTradeIds(parentTradeIds []string) ([]mdb.Orders, error) {
if len(parentTradeIds) == 0 {
return nil, nil
}
var orders []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id IN ?", parentTradeIds).
Order("parent_trade_id ASC, created_at ASC").
Find(&orders).Error
return orders, err
}
// ExpireOrderByTradeId marks a single order as expired if still waiting.
func ExpireOrderByTradeId(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Where("status = ?", mdb.StatusWaitPay).
Update("status", mdb.StatusExpired).Error
}
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("network = ?", network).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now())
query = applyLockAddressFilter(query, network, address)
err := query.Limit(1).Find(&lock).Error
if err != nil {
return "", err
}
return result, nil
if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
}
// LockTransaction 锁定交易
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token)
now := time.Now()
lock := &mdb.TransactionLock{
Network: network,
Address: address,
Token: normalizedToken,
AmountScaled: scaledAmount,
AmountText: amountText,
TradeId: tradeID,
ExpiresAt: now.Add(expirationTime),
}
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
expiredQuery := tx.Where("network = ?", network).
Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now)
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
// 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
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 releases the reservation for network+address+token+amount.
func UnLockTransaction(network string, address string, token string, amount float64) error {
network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount)
query := dao.RuntimeDB.
Where("network = ?", network).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount)
query = applyLockAddressFilter(query, network, address)
return query.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
}
+319
View File
@@ -0,0 +1,319 @@
package data
import (
"time"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
// DailyStat is one bucket of day-level aggregation used by the
// dashboard trend charts.
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
// "YYYY-MM-DD HH:00" for hourly buckets.
type DailyStat struct {
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
OrderCount int64 `json:"order_count"`
SuccessCount int64 `json:"success_count"`
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
}
// AddressDailyStat buckets actual_amount by (day, receive_address) for
// the stacked asset-trend chart.
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
type AddressDailyStat struct {
Day string `json:"day"`
Address string `json:"address"`
ActualAmount float64 `json:"actual_amount"`
}
// DailyOrderStats returns per-day counts/amounts within [start, end],
// zero-filling any days in the range that have no orders.
// Only orders in StatusPaySuccess contribute to Amount sums — that's
// what the user means by "流水". Pending orders count toward OrderCount
// to keep成交率 computable.
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`DATE(created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillDailyStats(start, end, rows), nil
}
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
// zero-filling any hours that have no orders.
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
var rows []DailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at)").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillHourlyStats(start, end, rows), nil
}
// fillDailyStats generates a zero-filled slice covering every calendar
// day in [start, end]. Days present in rows overwrite the zero entries.
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
byDay := make(map[string]DailyStat, len(rows))
for _, r := range rows {
byDay[r.Day] = r
}
var out []DailyStat
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
for !cur.After(last) {
key := cur.Format("2006-01-02")
if r, ok := byDay[key]; ok {
out = append(out, r)
} else {
out = append(out, DailyStat{Day: key})
}
cur = cur.AddDate(0, 0, 1)
}
return out
}
// fillHourlyStats generates a zero-filled slice covering every whole
// hour in [start, end]. Hours present in rows overwrite the zero entries.
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
byHour := make(map[string]DailyStat, len(rows))
for _, r := range rows {
byHour[r.Day] = r
}
var out []DailyStat
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
for !cur.After(endHour) {
key := cur.Format("2006-01-02 15:00")
if r, ok := byHour[key]; ok {
out = append(out, r)
} else {
out = append(out, DailyStat{Day: key})
}
cur = cur.Add(time.Hour)
}
return out
}
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
// for the per-address stacked chart, zero-filling missing days.
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("DATE(created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillAddressDailyStats(start, end, rows), nil
}
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
// for the per-address stacked chart, zero-filling missing hours.
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
var rows []AddressDailyStat
err := dao.Mdb.Model(&mdb.Orders{}).
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
Order("day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return fillAddressHourlyStats(start, end, rows), nil
}
// fillAddressDailyStats zero-fills missing day×address combinations.
// All addresses that appear in rows are represented for every day in range.
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
return fillAddressStats(start, end, rows, false)
}
// fillAddressHourlyStats zero-fills missing hour×address combinations.
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
return fillAddressStats(start, end, rows, true)
}
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
type key struct{ day, addr string }
byKey := make(map[key]float64, len(rows))
addrSet := make(map[string]struct{}, 4)
for _, r := range rows {
byKey[key{r.Day, r.Address}] = r.ActualAmount
addrSet[r.Address] = struct{}{}
}
if len(addrSet) == 0 {
return rows
}
var periods []string
if hourly {
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
for !cur.After(endH) {
periods = append(periods, cur.Format("2006-01-02 15:00"))
cur = cur.Add(time.Hour)
}
} else {
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
for !cur.After(last) {
periods = append(periods, cur.Format("2006-01-02"))
cur = cur.AddDate(0, 0, 1)
}
}
var out []AddressDailyStat
for _, day := range periods {
for addr := range addrSet {
out = append(out, AddressDailyStat{
Day: day,
Address: addr,
ActualAmount: byKey[key{day, addr}],
})
}
}
return out
}
// SumPaidActualAmount returns the total paid actual_amount across all
// time. Used by the overview "total asset" card.
func SumPaidActualAmount() (float64, error) {
var sum float64
err := dao.Mdb.Model(&mdb.Orders{}).
Select("COALESCE(SUM(actual_amount), 0)").
Where("status = ?", mdb.StatusPaySuccess).
Scan(&sum).Error
return sum, err
}
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
// a given time range. Caller chooses the range (today / last 7 days).
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
type row struct {
OrderCount int64
SuccessCount int64
ActualSum float64
}
var r row
err := dao.Mdb.Model(&mdb.Orders{}).
Select(`COUNT(*) AS order_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Scan(&r).Error
return r.OrderCount, r.SuccessCount, r.ActualSum, err
}
// ActiveAddressCountInRange returns the number of distinct wallet addresses
// that appear in at least one order within [start, end].
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Distinct("receive_address").
Count(&count).Error
return count, err
}
// AveragePaymentDurationSeconds returns the mean elapsed time between
// created_at and updated_at for orders that reached StatusPaySuccess
// in the given range. Zero-safe when no rows match.
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
type row struct {
Avg float64
}
var r row
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
// columns and compute in Go — trend windows are small enough.
type pair struct {
CreatedAt time.Time
UpdatedAt time.Time
}
var pairs []pair
err := dao.Mdb.Model(&mdb.Orders{}).
Select("created_at, updated_at").
Where("status = ?", mdb.StatusPaySuccess).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Scan(&pairs).Error
if err != nil {
return 0, err
}
if len(pairs) == 0 {
return 0, nil
}
total := 0.0
for _, p := range pairs {
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
}
r.Avg = total / float64(len(pairs))
return r.Avg, nil
}
// CountExpiredInRange returns the number of StatusExpired orders in range.
func CountExpiredInRange(start, end time.Time) (int64, error) {
var n int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("status = ?", mdb.StatusExpired).
Where("created_at >= ?", start).
Where("created_at <= ?", end).
Count(&n).Error
return n, err
}
// RecentOrders returns the latest N orders regardless of status.
func RecentOrders(limit int) ([]mdb.Orders, error) {
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
var rows []mdb.Orders
err := dao.Mdb.Model(&mdb.Orders{}).
Order("id DESC").
Limit(limit).
Find(&rows).Error
return rows, err
}
// CountEnabledChains counts chains where Enabled=true.
func CountEnabledChains() (int64, error) {
var n int64
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
return n, err
}
+62
View File
@@ -0,0 +1,62 @@
package data
import (
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tradeID := "trade-evm-case"
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
if err != nil {
t.Fatalf("lookup transaction lock: %v", err)
}
if gotTradeID != tradeID {
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
}
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
t.Fatalf("unlock transaction: %v", err)
}
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
if err != nil {
t.Fatalf("lookup after unlock: %v", err)
}
if gotTradeID != "" {
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
}
}
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tradeID := "trade-tron-case"
address := "TCaseSensitiveAddress001"
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
if err != nil {
t.Fatalf("lookup transaction lock: %v", err)
}
if gotTradeID != "" {
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
}
}
+76
View File
@@ -0,0 +1,76 @@
package data
import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
row := new(mdb.ProviderOrder)
err := dao.Mdb.Model(row).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Limit(1).
Find(row).Error
return row, err
}
func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
return tx.Model(row).Create(row).Error
}
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
"provider_order_id": providerOrderID,
"pay_url": payURL,
"status": mdb.ProviderOrderStatusPending,
}).Error
}
func MarkProviderOrderFailed(tradeID string, provider string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Update("status", mdb.ProviderOrderStatusFailed).Error
}
func SaveProviderOrderNotify(tradeID string, provider string, notifyRaw string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Update("notify_raw", notifyRaw).Error
}
func MarkProviderOrderPaid(tradeID string, provider string, notifyRaw string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
"status": mdb.ProviderOrderStatusPaid,
"notify_raw": notifyRaw,
}).Error
}
func MarkProviderOrderExpired(tradeID string, provider string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Where("status IN ?", []string{mdb.ProviderOrderStatusCreating, mdb.ProviderOrderStatusPending}).
Update("status", mdb.ProviderOrderStatusExpired).Error
}
func GetSubOrderByTokenPayProvider(parentTradeID string, token string, payProvider string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).
Where("parent_trade_id = ?", parentTradeID).
Where("token = ?", token).
Where("pay_provider = ?", payProvider).
Where("status = ?", mdb.StatusWaitPay).
Limit(1).
Find(order).Error
return order, err
}
+124
View File
@@ -0,0 +1,124 @@
package data
import (
"math/rand"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
)
// ListRpcNodes returns rows optionally filtered by network.
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
var rows []mdb.RpcNode
tx := dao.Mdb.Model(&mdb.RpcNode{})
if network != "" {
tx = tx.Where("network = ?", strings.ToLower(network))
}
err := tx.Order("id ASC").Find(&rows).Error
return rows, err
}
// GetRpcNodeByID fetches one row.
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
row := new(mdb.RpcNode)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateRpcNode inserts a row.
func CreateRpcNode(row *mdb.RpcNode) error {
return dao.Mdb.Create(row).Error
}
// UpdateRpcNodeFields patches mutable columns.
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteRpcNodeByID soft-deletes the row.
func DeleteRpcNodeByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).Error
}
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
// Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to enabled rows with status=unknown when no health check has
// run yet. Explicitly down rows are not selected.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}).
Where("network = ?", strings.ToLower(network)).
Where("type = ?", strings.ToLower(nodeType)).
Where("enabled = ?", true).
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows {
switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
}
}
candidates := healthy
if len(candidates) == 0 {
candidates = bootstrap
}
if len(candidates) == 0 {
return nil, nil
}
return pickWeighted(candidates), nil
}
// pickWeighted chooses one row using the Weight column. Weights < 1 are
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
// to truly disable).
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
total := 0
for i := range rows {
w := rows[i].Weight
if w < 1 {
w = 1
}
total += w
}
if total <= 0 {
return &rows[0]
}
pick := rand.Intn(total)
acc := 0
for i := range rows {
w := rows[i].Weight
if w < 1 {
w = 1
}
acc += w
if pick < acc {
return &rows[i]
}
}
return &rows[len(rows)-1]
}
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
return dao.Mdb.Model(&mdb.RpcNode{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"last_latency_ms": latencyMs,
"last_checked_at": carbon.Now().StdTime(),
}).Error
}
+101
View File
@@ -0,0 +1,101 @@
package data
import (
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestSelectRpcNodeUsesHealthyRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://ok.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}).Error; err != nil {
t.Fatalf("seed ok rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://ok.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want ok row", got)
}
}
func TestSelectRpcNodeFallsBackToUnknownOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://unknown.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusUnknown,
}).Error; err != nil {
t.Fatalf("seed unknown rpc_node: %v", err)
}
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got == nil || got.Url != "https://unknown.example.com" {
t.Fatalf("SelectRpcNode() = %#v, want unknown row", got)
}
}
func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := dao.Mdb.Create(&mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: "https://down.example.com",
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusDown,
}).Error; err != nil {
t.Fatalf("seed down rpc_node: %v", err)
}
got, err := SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
t.Fatalf("SelectRpcNode(): %v", err)
}
if got != nil {
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
}
}
+254
View File
@@ -0,0 +1,254 @@
package data
import (
"strconv"
"strings"
"sync"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause"
)
// Simple in-process cache. Settings are read frequently (on every order
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
// is invalidated explicitly via SetString/Delete and expires on process
// restart — that's enough for admin-edit semantics without TTL complexity.
var (
settingsCache = map[string]string{}
settingsCacheMu sync.RWMutex
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
settingsLoaded bool
)
func loadAllSettings() error {
var rows []mdb.Setting
if err := dao.Mdb.Find(&rows).Error; err != nil {
return err
}
settingsCacheMu.Lock()
defer settingsCacheMu.Unlock()
settingsCache = make(map[string]string, len(rows))
for _, r := range rows {
settingsCache[r.Key] = r.Value
}
settingsLoaded = true
return nil
}
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
// a dedicated mutex + double-checked flag so two concurrent callers
// don't both hit the DB. Can't use sync.Once because ReloadSettings
// needs to be able to rebuild after a failed initial load.
func ensureLoaded() {
settingsCacheMu.RLock()
loaded := settingsLoaded
settingsCacheMu.RUnlock()
if loaded {
return
}
settingsLoadMu.Lock()
defer settingsLoadMu.Unlock()
// Recheck under the load mutex — another goroutine may have loaded
// while we were blocked.
settingsCacheMu.RLock()
loaded = settingsLoaded
settingsCacheMu.RUnlock()
if loaded {
return
}
_ = loadAllSettings()
}
// ReloadSettings forces a refresh from DB. Call after external writes.
func ReloadSettings() error {
settingsLoadMu.Lock()
defer settingsLoadMu.Unlock()
return loadAllSettings()
}
// GetSettingString returns the string value for key, or fallback if unset.
func GetSettingString(key, fallback string) string {
ensureLoaded()
settingsCacheMu.RLock()
defer settingsCacheMu.RUnlock()
v, ok := settingsCache[key]
if !ok || strings.TrimSpace(v) == "" {
return fallback
}
return v
}
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
func GetSettingInt(key string, fallback int) int {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
func GetSettingFloat(key string, fallback float64) float64 {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return fallback
}
return f
}
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
func GetSettingBool(key string, fallback bool) bool {
v := GetSettingString(key, "")
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
// SetSetting upserts a setting row and refreshes the cache entry.
func SetSetting(group, key, value, valueType string) error {
if valueType == "" {
valueType = mdb.SettingTypeString
}
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
err := dao.Mdb.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
}).Create(&row).Error
if err != nil {
return err
}
settingsCacheMu.Lock()
settingsCache[key] = value
settingsCacheMu.Unlock()
return nil
}
// DeleteSetting removes a setting row and drops the cache entry.
func DeleteSetting(key string) error {
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
return err
}
settingsCacheMu.Lock()
delete(settingsCache, key)
settingsCacheMu.Unlock()
return nil
}
// sensitiveSettingKeys lists keys that must never be returned to API callers.
var sensitiveSettingKeys = []string{
mdb.SettingKeyJwtSecret,
mdb.SettingKeyInitAdminPasswordPlain,
mdb.SettingKeyInitAdminPasswordHash,
mdb.SettingKeyInitAdminPasswordFetched,
mdb.SettingKeyInitAdminPasswordChanged,
}
func getFirstNonEmptySetting(fallback string, keys ...string) string {
for _, key := range keys {
value := strings.TrimSpace(GetSettingString(key, ""))
if value != "" {
return value
}
}
return fallback
}
func GetBrandCashierName() string {
return getFirstNonEmptySetting("", mdb.SettingKeyBrandCheckoutName, mdb.SettingKeyBrandSiteName)
}
func GetBrandLogoURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandLogoUrl, ""))
}
func GetBrandWebsiteTitle() string {
return getFirstNonEmptySetting("", mdb.SettingKeyBrandSiteTitle, mdb.SettingKeyBrandPageTitle)
}
func GetBrandSupportURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandSupportUrl, ""))
}
// OkPay settings helpers keep the provider-specific defaults in one place so
// business logic does not need to repeat raw settings keys.
func GetOkPayEnabled() bool {
return GetSettingBool(mdb.SettingKeyOkPayEnabled, false)
}
func GetOkPayShopID() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopID, ""))
}
func GetOkPayShopToken() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopToken, ""))
}
func GetOkPayAPIURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayAPIURL, "https://api.okaypay.me/shop/"))
}
func GetOkPayCallbackURL() string {
if configured := strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayCallbackURL, "")); configured != "" {
return configured
}
appURI := strings.TrimSpace(config.GetAppUri())
if appURI == "" {
return ""
}
return strings.TrimRight(appURI, "/") + "/payments/okpay/v1/notify"
}
func GetOkPayReturnURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayReturnURL, ""))
}
func GetOkPayTimeoutSeconds() int {
return GetSettingInt(mdb.SettingKeyOkPayTimeoutSeconds, 10)
}
func GetOkPayAllowTokens() []string {
raw := GetSettingString(mdb.SettingKeyOkPayAllowTokens, "USDT,TRX")
if strings.TrimSpace(raw) == "" {
return []string{"USDT", "TRX"}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
token := strings.ToUpper(strings.TrimSpace(part))
if token == "" {
continue
}
out = append(out, token)
}
if len(out) == 0 {
return []string{"USDT", "TRX"}
}
return out
}
// ListSettingsByGroup returns all rows for a given group (empty group = all),
// excluding any keys in sensitiveSettingKeys.
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
var rows []mdb.Setting
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
if group != "" {
tx = tx.Where("`group` = ?", group)
}
err := tx.Order("`key` ASC").Find(&rows).Error
return rows, err
}
+106 -10
View File
@@ -1,32 +1,93 @@
package data
import (
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
"strings"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
)
// AddWalletAddress 创建钱包
func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token)
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
}
// isEVMNetwork 判断是否是 EVM 网络
func isEVMNetwork(network string) bool {
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
}
return false
}
func normalizeWalletNetwork(network string) string {
return strings.ToLower(strings.TrimSpace(network))
}
func normalizeWalletAddressByNetwork(network, address string) string {
address = strings.TrimSpace(address)
if isEVMNetwork(normalizeWalletNetwork(network)) {
return strings.ToLower(address)
}
return address
}
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
address = normalizeWalletAddressByNetwork(network, address)
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil {
return nil, err
}
if exist.ID > 0 {
return nil, constant.WalletAddressAlreadyExists
}
// Check for a soft-deleted record with the same (network, address) and restore it.
deleted := new(mdb.WalletAddress)
err = dao.Mdb.Unscoped().
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
Limit(1).Find(deleted).Error
if err != nil {
return nil, err
}
if deleted.ID > 0 {
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
"deleted_at": nil,
"status": mdb.TokenStatusEnable,
}).Error
return deleted, err
}
walletAddress := &mdb.WalletAddress{
Token: token,
Network: network,
Address: address,
Status: mdb.TokenStatusEnable,
}
err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取token
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) {
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
address = normalizeWalletAddressByNetwork(network, address)
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error
err := dao.Mdb.Model(walletAddress).
Where("network = ?", network).
Where("address = ?", address).
Limit(1).Find(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口)
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
return walletAddress, err
}
@@ -50,6 +111,25 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
var list []mdb.WalletAddress
err := dao.Mdb.Model(list).
Where("status = ?", mdb.TokenStatusEnable).
Where("network = ?", network).
Find(&list).Error
if err != nil {
return nil, err
}
if isEVMNetwork(network) {
for i := range list {
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
return list, err
}
// GetAllWalletAddress 获得所有钱包地址
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
var WalletAddressList []mdb.WalletAddress
@@ -57,6 +137,22 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
network = normalizeWalletNetwork(network)
var list []mdb.WalletAddress
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
if err != nil {
return nil, err
}
if isEVMNetwork(network) {
for i := range list {
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
}
}
return list, err
}
// ChangeWalletAddressStatus 启用禁用钱包
func ChangeWalletAddressStatus(id uint64, status int) error {
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
@@ -0,0 +1,83 @@
package data
import (
"strings"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
)
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
if err != nil {
t.Fatalf("add wallet: %v", err)
}
if row.Address != strings.ToLower(input) {
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
}
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
if err != nil {
t.Fatalf("load wallet by mixed-case address: %v", err)
}
if loaded.ID == 0 {
t.Fatal("expected to find wallet by mixed-case query")
}
if loaded.Address != strings.ToLower(input) {
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
}
}
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: mixed,
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed mixed-case wallet: %v", err)
}
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
if err != nil {
t.Fatalf("list wallets: %v", err)
}
if len(rows) != 1 {
t.Fatalf("wallet count = %d, want 1", len(rows))
}
if rows[0].Address != strings.ToLower(mixed) {
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
}
}
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
tronAddress := "TCaseSensitiveTronAddress001"
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
if err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if tronRow.Address != tronAddress {
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
}
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
if err != nil {
t.Fatalf("add solana wallet: %v", err)
}
if solRow.Address != solAddress {
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
}
}
+21
View File
@@ -0,0 +1,21 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
AdminUserStatusEnable = 1
AdminUserStatusDisable = 2
)
type AdminUser struct {
Username string `gorm:"column:username;uniqueIndex:admin_users_username_uindex;size:64" json:"username" example:"admin"`
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
// 状态 1=启用 2=禁用
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
LastLoginAt carbon.Time `gorm:"column:last_login_at" json:"last_login_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (a *AdminUser) TableName() string {
return "admin_users"
}
+29
View File
@@ -0,0 +1,29 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
ApiKeyStatusEnable = 1
ApiKeyStatusDisable = 2
)
// ApiKey stores a universal merchant credential (PID + secret) that is
// valid for both EPAY and GMPAY payment flows. Identification at
// request time is always by PID; the gateway flow is chosen by the
// route (not by any property of the row).
type ApiKey struct {
Name string `gorm:"column:name;size:128" json:"name" example:"My API Key"`
Pid string `gorm:"column:pid;size:128;uniqueIndex:api_keys_pid_uindex" json:"pid" example:"1000"`
SecretKey string `gorm:"column:secret_key;size:255" json:"-"`
IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"`
NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"`
// 状态 1=启用 2=禁用
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"`
LastUsedAt carbon.Time `gorm:"column:last_used_at" json:"last_used_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (a *ApiKey) TableName() string {
return "api_keys"
}
+4 -4
View File
@@ -1,13 +1,13 @@
package mdb
import (
"github.com/golang-module/carbon/v2"
"github.com/dromara/carbon/v2"
"gorm.io/gorm"
)
type BaseModel struct {
ID uint64 `gorm:"column:id;primary_key" json:"id"`
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}
+19
View File
@@ -0,0 +1,19 @@
package mdb
// Chain represents an enabled blockchain network.
// Scanner tasks read this table on each polling tick to decide whether to
// process the network. When Enabled=false the scanner skips the chain
// entirely without restarting.
type Chain struct {
Network string `gorm:"column:network;uniqueIndex:chains_network_uindex;size:32" json:"network" example:"tron"`
DisplayName string `gorm:"column:display_name;size:64" json:"display_name" example:"Tron"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
MinConfirmations int `gorm:"column:min_confirmations;default:1" json:"min_confirmations" example:"20"`
ScanIntervalSec int `gorm:"column:scan_interval_sec;default:5" json:"scan_interval_sec" example:"5"`
Extra string `gorm:"column:extra;type:text" json:"extra" example:"{}"`
BaseModel
}
func (c *Chain) TableName() string {
return "chains"
}
+24
View File
@@ -0,0 +1,24 @@
package mdb
const (
ChainTokenStatusEnable = 1
ChainTokenStatusDisable = 2
)
// ChainToken describes a token to watch on a given chain. Replaces the
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
// load this table on startup (and refresh periodically) to learn which
// contracts to subscribe to.
type ChainToken struct {
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
ContractAddress string `gorm:"column:contract_address;size:128" json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
Decimals int `gorm:"column:decimals;default:6" json:"decimals" example:"6"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
MinAmount float64 `gorm:"column:min_amount;default:0" json:"min_amount" example:"1.0"`
BaseModel
}
func (c *ChainToken) TableName() string {
return "chain_tokens"
}
+36
View File
@@ -0,0 +1,36 @@
package mdb
const (
NotificationTypeTelegram = "telegram"
NotificationTypeWebhook = "webhook"
NotificationTypeEmail = "email"
)
const (
NotificationStatusEnable = 1
NotificationStatusDisable = 2
)
// Notification events (JSON keys in Events column).
const (
NotifyEventPaySuccess = "pay_success"
NotifyEventOrderExpired = "order_expired"
NotifyEventDailyReport = "daily_report"
)
// NotificationChannel represents one push target instance.
// Config/Events are JSON strings consumed by the notify dispatcher.
// For telegram, Config = {"bot_token":"...","chat_id":"...","proxy":"..."}.
// For webhook, Config = {"url":"...","headers":{...},"secret":"..."}.
type NotificationChannel struct {
Type string `gorm:"column:type;size:32;index:notification_channels_type_enabled_index,priority:1" json:"type" enums:"telegram,webhook,email" example:"telegram"`
Name string `gorm:"column:name;size:128" json:"name" example:"主通知群"`
Config string `gorm:"column:config;type:text" json:"config" example:"{\"bot_token\":\"123:ABC\",\"chat_id\":\"456\"}"`
Events string `gorm:"column:events;type:text" json:"events" example:"{\"pay_success\":true,\"order_expired\":true}"`
Enabled bool `gorm:"column:enabled;default:true;index:notification_channels_type_enabled_index,priority:2" json:"enabled" example:"true"`
BaseModel
}
func (n *NotificationChannel) TableName() string {
return "notification_channels"
}
+51 -12
View File
@@ -8,22 +8,61 @@ const (
CallBackConfirmNo = 2
)
const (
PaymentTypeEpay = "Epay"
)
const (
// PaymentProviderOnChain means this concrete order record is settled by
// sending funds to a directly assigned wallet address on a supported chain.
PaymentProviderOnChain = "on_chain"
// PaymentProviderOkPay means this concrete order record is settled through
// the third-party OkayPay/OkPay hosted checkout flow.
//
// In the current design this is typically used by a switch-network-created
// child order, while the parent order keeps its original merchant-facing
// semantics and callback behavior.
PaymentProviderOkPay = "okpay"
)
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:"column:block_transaction_id" json:"block_transaction_id"` // 区块id
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
Status int `gorm:"column:status" json:"status"` // 1:等待支付,2:支付成功,3:已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
CallbackNum int `gorm:"column:callback_num" json:"callback_num"` // 回调次数
CallBackConfirm int `gorm:"column:callback_confirm" json:"callback_confirm"` // 回调是否已确认 1是 2否
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
Token string `gorm:"column:token" json:"token" example:"USDT"`
Network string `gorm:"column:network" json:"network" example:"tron"`
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
// 回调确认状态 1=回调成功 2=未回调/回调失败
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
// PayProvider identifies how this specific order row is collected.
//
// Semantics:
// - parent orders and regular chain child orders use on_chain
// - third-party hosted checkout child orders use their provider name
// (for example okpay)
//
// Existing rows default to on_chain for backward compatibility so upgrades
// can rely on AutoMigrate without rewriting old orders.
PayProvider string `gorm:"column:pay_provider;size:32;default:on_chain;index:orders_pay_provider_index" json:"pay_provider" example:"on_chain"`
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
// Zero when the parent order was paid directly (no sub-order involved).
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string {
return "orders"
}
+48
View File
@@ -0,0 +1,48 @@
package mdb
const (
ProviderOrderStatusCreating = "creating"
ProviderOrderStatusPending = "pending"
ProviderOrderStatusPaid = "paid"
ProviderOrderStatusFailed = "failed"
ProviderOrderStatusExpired = "expired"
)
// ProviderOrder stores provider-specific checkout data for one internal order
// row. For OkPay this is expected to bind to the concrete child order created
// by switch-network, not to the parent merchant order.
//
// Binding rules:
// - trade_id always points to orders.trade_id
// - the bound orders row should use the same pay_provider value
// - provider identifies which downstream checkout created the provider row
// - provider_order_id is the provider's own order number (for OkPay this is
// the returned order_id from /payLink)
// - pay_url is the hosted payment URL returned by the provider
//
// The main orders table remains the source of truth for merchant-facing state,
// while this table keeps provider-specific identifiers and callback payloads.
type ProviderOrder struct {
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"T2026041612345678"`
// Provider values should match the bound Orders.PayProvider value.
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
// ProviderOrderID is the downstream provider's own order identifier for the
// bound concrete order row.
ProviderOrderID string `gorm:"column:provider_order_id;size:128;not null;default:'';index:provider_orders_provider_order_id_index" json:"provider_order_id" example:"ac7b86615fdb137576ae35879f7ed844"`
// PayURL is the hosted checkout URL returned by the downstream provider for
// the bound concrete order row.
PayURL string `gorm:"column:pay_url;size:512;not null;default:''" json:"pay_url" example:"https://pay.example.com/checkout/abc123"`
// Amount/Coin mirror the exact payload submitted to the provider, which may
// be useful when validating callbacks and troubleshooting mismatches.
Amount float64 `gorm:"column:amount" json:"amount" example:"14.2857"`
Coin string `gorm:"column:coin;size:16;not null;default:''" json:"coin" example:"USDT"`
Status string `gorm:"column:status;size:32;not null;default:pending;index:provider_orders_provider_status_index,priority:2" json:"status" example:"pending"`
// NotifyRaw stores the original provider callback payload for auditing and
// replay/debug use. It is provider-specific and intentionally opaque here.
NotifyRaw string `gorm:"column:notify_raw;type:text" json:"notify_raw"`
BaseModel
}
func (p *ProviderOrder) TableName() string {
return "provider_orders"
}
+36
View File
@@ -0,0 +1,36 @@
package mdb
import "github.com/dromara/carbon/v2"
const (
RpcNodeTypeHttp = "http"
RpcNodeTypeWs = "ws"
)
const (
RpcNodeStatusUnknown = "unknown"
RpcNodeStatusOk = "ok"
RpcNodeStatusDown = "down"
)
// RpcNode records one RPC endpoint for a network. Clients select a node
// by weight among rows where Enabled=true AND Status=ok. A periodic
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
type RpcNode struct {
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
// 连接类型 http=HTTP请求 ws=WebSocket长连接
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
// 健康状态 unknown=未知 ok=正常 down=异常
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
BaseModel
}
func (r *RpcNode) TableName() string {
return "rpc_nodes"
}
+73
View File
@@ -0,0 +1,73 @@
package mdb
// Setting stores non-credential runtime configuration as key/value pairs.
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
// secret_key) live in the api_keys table; notification configs live in
// NotificationChannel.
// Default rows for the epay/okpay groups are seeded on first startup (see
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
// empty and fall back to hardcoded defaults until an admin sets them.
// Only exception: system.jwt_secret is auto-generated on first startup.
const (
SettingGroupBrand = "brand"
SettingGroupRate = "rate"
SettingGroupSystem = "system"
SettingGroupEpay = "epay"
SettingGroupOkPay = "okpay"
)
const (
SettingTypeString = "string"
SettingTypeInt = "int"
SettingTypeBool = "bool"
SettingTypeJSON = "json"
)
const (
SettingKeyJwtSecret = "system.jwt_secret"
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
SettingKeyOrderExpiration = "system.order_expiration_time"
SettingKeyBrandCheckoutName = "brand.checkout_name"
SettingKeyBrandSiteName = "brand.site_name"
SettingKeyBrandLogoUrl = "brand.logo_url"
SettingKeyBrandSiteTitle = "brand.site_title"
SettingKeyBrandPageTitle = "brand.page_title"
SettingKeyBrandSuccessCopy = "brand.success_copy"
SettingKeyBrandPaySuccess = "brand.pay_success_text"
SettingKeyBrandSupportUrl = "brand.support_url"
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
SettingKeyRateAdjustPercent = "rate.adjust_percent"
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
SettingKeyRateApiUrl = "rate.api_url"
// EPAY route defaults — can be overridden via admin settings.
SettingKeyEpayDefaultToken = "epay.default_token"
SettingKeyEpayDefaultCurrency = "epay.default_currency"
SettingKeyEpayDefaultNetwork = "epay.default_network"
// OkPay hosted-checkout settings.
SettingKeyOkPayEnabled = "okpay.enabled"
SettingKeyOkPayShopID = "okpay.shop_id"
SettingKeyOkPayShopToken = "okpay.shop_token"
SettingKeyOkPayAPIURL = "okpay.api_url"
SettingKeyOkPayCallbackURL = "okpay.callback_url"
SettingKeyOkPayReturnURL = "okpay.return_url"
SettingKeyOkPayTimeoutSeconds = "okpay.timeout_seconds"
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
)
type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
BaseModel
}
func (s *Setting) TableName() string {
return "settings"
}
+20
View File
@@ -0,0 +1,20 @@
package mdb
import "time"
type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (t *TransactionLock) TableName() string {
return "transaction_lock"
}
+20 -4
View File
@@ -5,14 +5,30 @@ const (
TokenStatusDisable = 2
)
// WalletAddress 钱包表
const (
NetworkTron = "tron"
NetworkSolana = "solana"
NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
)
const (
WalletSourceManual = "manual"
WalletSourceImport = "import"
)
type WalletAddress struct {
Token string `gorm:"column:token" json:"token"` // 钱包token
Status int64 `gorm:"column:status" json:"status"` // 1:启用 2:禁用
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
// 状态 1=启用 2=禁用
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string {
return "wallet_address"
}
+4 -4
View File
@@ -8,8 +8,8 @@ const (
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
type BaseRequest struct {
Page int `json:"page"` // 页数
PageSize int `json:"page_size"` // 每页条数
OrderField string `json:"order_field"` // 排序字段
OrderFunc string `json:"order_func"` // 排序方法
Page int `json:"page" example:"1"` // 页数
PageSize int `json:"page_size" example:"20"` // 每页条数
OrderField string `json:"order_field" example:"created_at"` // 排序字段
OrderFunc string `json:"order_func" enums:"DESC,ASC" example:"DESC"` // 排序方法
}
+31 -5
View File
@@ -4,16 +4,24 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"`
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"`
RedirectUrl string `json:"redirect_url"`
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
Name string `json:"name" form:"name" example:"VIP月卡"`
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
}
func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{
"OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Network": "网络",
"Amount": "支付金额",
"NotifyUrl": "异步回调网址",
"Signature": "签名",
@@ -22,8 +30,26 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理
type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string
Network string
Amount float64
TradeId string
BlockTransactionId string
}
// SwitchNetworkRequest 切换支付网络
type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
}
func (r SwitchNetworkRequest) Translates() map[string]string {
return validate.MS{
"TradeId": "订单号",
"Token": "币种",
"Network": "网络",
}
}
+11
View File
@@ -0,0 +1,11 @@
package response
// ApiResponse is the standard JSON envelope used by all API endpoints.
// Used for swagger documentation only — the actual response is built
// by util/http.Resp.
type ApiResponse struct {
StatusCode int `json:"status_code" example:"200"`
Message string `json:"message" example:"success"`
Data interface{} `json:"data"`
RequestID string `json:"request_id" example:"req-123456"`
}
+33 -15
View File
@@ -2,23 +2,41 @@ package response
// CreateTransactionResponse 创建订单成功返回
type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
}
// OrderNotifyResponse 订单异步回调结构体
type OrderNotifyResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"2"`
}
// OrderNotifyResponseEpay epay订单异步回调结构体
type OrderNotifyResponseEpay struct {
PID int `json:"pid" example:"1001"` // 商户ID
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
Type string `json:"type" example:"usdt"` // 订单类型
Name string `json:"name" example:"VIP月卡"` // 商品名称
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
SignType string `json:"sign_type" example:"MD5"` // 签名类型
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
}
+15 -7
View File
@@ -1,14 +1,22 @@
package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"`
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"`
}
type CheckStatusResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
Status int `json:"status"`
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"1"`
}
+37
View File
@@ -0,0 +1,37 @@
package response
type NetworkTokenSupport struct {
Network string `json:"network" example:"tron"`
Tokens []string `json:"tokens" example:"USDT,USDC"`
}
type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:"usdt"`
DefaultCurrency string `json:"default_currency" example:"cny"`
DefaultNetwork string `json:"default_network" example:"tron"`
}
type SitePublicConfig struct {
CashierName string `json:"cashier_name" example:"Acme Cashier"`
LogoURL string `json:"logo_url" example:"https://cdn.example.com/logo.png"`
WebsiteTitle string `json:"website_title" example:"Acme Payments"`
SupportLink string `json:"support_link" example:"https://example.com/support"`
}
type OkPayPublicConfig struct {
Enabled bool `json:"enabled" example:"true"`
AllowTokens []string `json:"allow_tokens" example:"USDT,TRX"`
ShopID string `json:"shop_id,omitempty" example:"okpay-shop-test"`
ShopToken string `json:"shop_token,omitempty" example:"secret-token"`
APIURL string `json:"api_url,omitempty" example:"https://api.okaypay.me/shop/"`
CallbackURL string `json:"callback_url,omitempty" example:"https://pay.example.com/payments/okpay/v1/notify"`
ReturnURL string `json:"return_url,omitempty" example:"https://pay.example.com/success"`
TimeoutSeconds int `json:"timeout_seconds,omitempty" example:"10"`
}
type PublicConfigResponse struct {
SupportedAssets []NetworkTokenSupport `json:"supported_assets"`
Site SitePublicConfig `json:"site"`
Epay EpayPublicConfig `json:"epay"`
OkPay OkPayPublicConfig `json:"okpay"`
}
+278
View File
@@ -0,0 +1,278 @@
package service
import (
"crypto/md5"
"encoding/hex"
"fmt"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
)
type okPayCreateDepositResponse struct {
Status string `json:"status"`
Code int `json:"code"`
Data struct {
OrderID string `json:"order_id"`
PayURL string `json:"pay_url"`
} `json:"data"`
Msg string `json:"msg"`
}
type okPayDepositOrder struct {
ProviderOrderID string
PayURL string
}
type okPayNotifyPayload struct {
ID string `json:"id"`
Status string `json:"status"`
Code string `json:"code"`
OrderID string `json:"order_id"`
UniqueID string `json:"unique_id"`
PayUserID string `json:"pay_user_id"`
Amount string `json:"amount"`
Coin string `json:"coin"`
PayStatus string `json:"pay_status"`
NotifyType string `json:"notify_type"`
Sign string `json:"sign"`
RawFormData string `json:"raw_form_data"`
}
func okPaySign(form map[string]string, shopID string, shopToken string) map[string]string {
values := url.Values{}
for key, value := range form {
if strings.TrimSpace(value) == "" {
continue
}
values.Set(key, value)
}
values.Set("id", shopID)
query, _ := url.QueryUnescape(values.Encode())
sum := md5.Sum([]byte(query + "&token=" + shopToken))
signed := make(map[string]string, len(values)+1)
for key, vals := range values {
if len(vals) == 0 {
continue
}
signed[key] = vals[0]
}
signed["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
return signed
}
func createOkPayDepositOrder(uniqueID string, amount float64, coin string, returnURL string) (*okPayDepositOrder, error) {
shopID := data.GetOkPayShopID()
shopToken := data.GetOkPayShopToken()
apiURL := strings.TrimSpace(data.GetOkPayAPIURL())
callbackURL := data.GetOkPayCallbackURL()
if shopID == "" || shopToken == "" || apiURL == "" || callbackURL == "" {
return nil, fmt.Errorf("okpay config incomplete")
}
if returnURL == "" {
returnURL = data.GetOkPayReturnURL()
}
form := map[string]string{
"unique_id": uniqueID,
"name": uniqueID,
"amount": fmt.Sprintf("%.2f", amount),
"coin": strings.ToUpper(strings.TrimSpace(coin)),
"callback_url": callbackURL,
"return_url": returnURL,
}
form = okPaySign(form, shopID, shopToken)
base, err := url.Parse(apiURL)
if err != nil {
return nil, err
}
base.Path = path.Join(base.Path, "payLink")
client := http_client.GetHttpClient()
client.SetTimeout(time.Duration(data.GetOkPayTimeoutSeconds()) * time.Second)
var payload okPayCreateDepositResponse
resp, err := client.R().
SetFormData(form).
SetResult(&payload).
Post(base.String())
if err != nil {
log.Sugar.Warnf("[okpay] create order request failed unique_id=%s api_url=%s callback_url=%s return_url=%s err=%v", uniqueID, base.String(), callbackURL, returnURL, err)
return nil, err
}
if resp.StatusCode() >= 400 {
log.Sugar.Warnf("[okpay] create order http error unique_id=%s status=%s api_url=%s callback_url=%s return_url=%s body=%s", uniqueID, resp.Status(), base.String(), callbackURL, returnURL, resp.String())
return nil, fmt.Errorf("okpay http status: %s", resp.Status())
}
if payload.Data.OrderID == "" || payload.Data.PayURL == "" {
log.Sugar.Warnf("[okpay] create order rejected unique_id=%s api_url=%s callback_url=%s return_url=%s status=%s code=%d msg=%s", uniqueID, base.String(), callbackURL, returnURL, payload.Status, payload.Code, payload.Msg)
if payload.Msg != "" {
return nil, fmt.Errorf("okpay create order failed: %s (callback_url=%s)", payload.Msg, callbackURL)
}
return nil, fmt.Errorf("okpay create order failed (callback_url=%s)", callbackURL)
}
return &okPayDepositOrder{
ProviderOrderID: payload.Data.OrderID,
PayURL: payload.Data.PayURL,
}, nil
}
func verifyOkPayNotify(form map[string]string) bool {
shopID := data.GetOkPayShopID()
shopToken := data.GetOkPayShopToken()
signature := strings.TrimSpace(form["sign"])
if shopID == "" || shopToken == "" || signature == "" {
return false
}
if expected := okPayNotifySign(form, shopToken); expected != "" && strings.EqualFold(expected, signature) {
return true
}
unsigned := make(map[string]string, len(form))
for key, value := range form {
if strings.EqualFold(key, "sign") {
continue
}
unsigned[key] = value
}
signed := okPaySign(unsigned, shopID, shopToken)
return strings.EqualFold(strings.TrimSpace(signed["sign"]), signature)
}
func okPayNotifySign(form map[string]string, shopToken string) string {
orderedKeys := []string{
"code",
"data[order_id]",
"data[unique_id]",
"data[pay_user_id]",
"data[amount]",
"data[coin]",
"data[status]",
"data[type]",
"id",
"status",
}
pairs := make([]string, 0, len(orderedKeys))
for _, key := range orderedKeys {
value := strings.TrimSpace(form[key])
if value == "" {
continue
}
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
}
if len(pairs) == 0 {
return ""
}
query, err := url.QueryUnescape(strings.Join(pairs, "&"))
if err != nil {
return ""
}
sum := md5.Sum([]byte(query + "&token=" + shopToken))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func parseOkPayNotify(form map[string]string, rawFormData string) *okPayNotifyPayload {
return &okPayNotifyPayload{
ID: strings.TrimSpace(form["id"]),
Status: strings.TrimSpace(form["status"]),
Code: strings.TrimSpace(form["code"]),
OrderID: strings.TrimSpace(form["data[order_id]"]),
UniqueID: strings.TrimSpace(form["data[unique_id]"]),
PayUserID: strings.TrimSpace(form["data[pay_user_id]"]),
Amount: strings.TrimSpace(form["data[amount]"]),
Coin: strings.TrimSpace(form["data[coin]"]),
PayStatus: strings.TrimSpace(form["data[status]"]),
NotifyType: strings.TrimSpace(form["data[type]"]),
Sign: strings.TrimSpace(form["sign"]),
RawFormData: rawFormData,
}
}
func HandleOkPayNotify(form map[string]string, rawFormData string) error {
if !verifyOkPayNotify(form) {
return fmt.Errorf("invalid okpay notify sign")
}
payload := parseOkPayNotify(form, rawFormData)
if payload.ID == "" || payload.ID != data.GetOkPayShopID() {
return fmt.Errorf("okpay notify shop id mismatch: %s", payload.ID)
}
if payload.UniqueID == "" || payload.OrderID == "" {
return fmt.Errorf("missing okpay notify identifiers")
}
order, err := data.GetOrderInfoByTradeId(payload.UniqueID)
if err != nil {
return err
}
if order.ID <= 0 {
return fmt.Errorf("okpay notify order not found: %s", payload.UniqueID)
}
if order.PayProvider != "okpay" {
return fmt.Errorf("okpay notify order provider mismatch: %s", payload.UniqueID)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(payload.UniqueID, order.PayProvider)
if err != nil {
return err
}
if providerRow.ID <= 0 {
return fmt.Errorf("okpay provider order not found: %s", payload.UniqueID)
}
_ = data.SaveProviderOrderNotify(payload.UniqueID, order.PayProvider, payload.RawFormData)
if !strings.EqualFold(payload.Status, "success") || !strings.EqualFold(payload.NotifyType, "deposit") || payload.PayStatus != "1" {
log.Sugar.Infof("[okpay] notify ignored trade_id=%s status=%s pay_status=%s type=%s", payload.UniqueID, payload.Status, payload.PayStatus, payload.NotifyType)
return nil
}
if providerRow.ProviderOrderID != "" && providerRow.ProviderOrderID != payload.OrderID {
return fmt.Errorf("okpay provider order id mismatch: got=%s want=%s", payload.OrderID, providerRow.ProviderOrderID)
}
if !strings.EqualFold(strings.TrimSpace(order.Token), strings.TrimSpace(payload.Coin)) {
return fmt.Errorf("okpay coin mismatch: got=%s want=%s", payload.Coin, order.Token)
}
notifyAmount, err := strconv.ParseFloat(payload.Amount, 64)
if err != nil {
return fmt.Errorf("invalid okpay amount: %w", err)
}
if fmt.Sprintf("%.2f", notifyAmount) != fmt.Sprintf("%.2f", order.ActualAmount) {
return fmt.Errorf("okpay amount mismatch: got=%.2f want=%.2f", notifyAmount, order.ActualAmount)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: payload.OrderID,
})
if err != nil && err != constant.OrderBlockAlreadyProcess {
return err
}
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
return err
}
return nil
}
+495 -76
View File
@@ -1,52 +1,81 @@
package service
import (
"errors"
"fmt"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/math"
"github.com/golang-module/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"math/rand"
"strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/shopspring/decimal"
)
const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额
IncrementalMaximumNumber = 100 // 最大递增次数
CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100
)
var gCreateTransactionLock sync.Mutex
var (
gCreateTransactionLock sync.Mutex
gOrderProcessingLock sync.Mutex
)
// CreateTransaction 创建订单
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
// apiKeyID safely extracts the primary key from an ApiKey row.
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
if apiKey == nil {
return 0
}
return apiKey.ID
}
func normalizeOrderAddressByNetwork(network, address string) string {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return strings.ToLower(address)
default:
return address
}
}
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network))
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化USDT
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// Usdt是否可以满足最低支付金额
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil {
return nil, err
@@ -54,134 +83,244 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 {
return nil, constant.OrderAlreadyExists
}
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress()
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress)
tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
if availableToken == "" {
if availableAddress == "" {
return nil, constant.NotAvailableAmountErr
}
tx := dao.Mdb.Begin()
order := &mdb.Orders{
TradeId: GenerateCode(),
TradeId: tradeID,
OrderId: req.OrderId,
Amount: req.Amount,
Currency: currency,
ActualAmount: availableAmount,
Token: availableToken,
ReceiveAddress: availableAddress,
Token: token,
Network: network,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
}
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()))
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: ExpirationTime,
ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
}
// OrderProcessing 成功处理订单
// OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil {
tx.Rollback()
return err
}
if exist.ID > 0 {
tx.Rollback()
return constant.OrderBlockAlreadyProcess
}
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req)
updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil {
tx.Rollback()
return err
}
// 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount)
if err != nil {
if !updated {
tx.Rollback()
return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
return err
}
tx.Commit()
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
}
// Load order to check parent-child relationship
order, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
}
// Parent order paid directly: expire all sub-orders and release their locks
if order.ParentTradeId == "" {
subs, subErr := data.GetActiveSubOrders(order.TradeId)
if subErr != nil {
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
}
for _, sub := range subs {
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
}
if sub.PayProvider != "" && sub.PayProvider != mdb.PaymentProviderOnChain {
if err = data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil {
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
}
}
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
}
}
return nil
}
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := ""
availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := ""
for _, address := range walletAddress {
token := address.Token
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
if err != nil {
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
}
// Snapshot siblings for lock release after DB state transition commits.
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
finalizeTx := dao.Mdb.Begin()
// Mark parent as paid with sub-order's payment details
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
if markErr != nil {
finalizeTx.Rollback()
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
}
if !updatedParent {
finalizeTx.Rollback()
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
}
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
finalizeTx.Rollback()
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
if err = finalizeTx.Commit().Error; err != nil {
finalizeTx.Rollback()
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it only after the parent order is successfully finalized.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
}
// Release parent's own wallet lock
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
}
// Release sibling locks after their status transitions commit.
for _, sib := range siblings {
if sib.PayProvider != "" && sib.PayProvider != mdb.PaymentProviderOnChain {
if err = data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil {
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
}
}
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
}
}
return nil
}
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return normalizedAddress, nil
}
if errors.Is(err, data.ErrTransactionLocked) {
continue
}
return "", err
}
if result == "" {
availableWallet = token
break
}
}
return availableWallet, nil
return "", nil
}
for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount)
address, err := tryLockWalletFunc(availableAmount)
if err != nil {
return "", 0, err
}
// 拿不到可用钱包就累加金额
if token == "" {
if address == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue
}
availableToken = token
availableAddress = address
break
}
return availableToken, availableAmount, nil
return availableAddress, availableAmount, nil
}
// GenerateCode 订单号生成
// GenerateCode creates a unique trade id.
func GenerateCode() string {
date := time.Now().Format("20060102")
r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
}
// GetOrderInfoByTradeId 通过交易号获取订单
// GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
@@ -192,3 +331,283 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
}
return order, nil
}
const MaxSubOrders = 2
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
network := strings.ToLower(strings.TrimSpace(req.Network))
// 1. Load parent order
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return nil, err
}
if parent.ID <= 0 {
return nil, constant.OrderNotExists
}
if parent.ParentTradeId != "" {
return nil, constant.CannotSwitchSubOrder
}
if parent.Status != mdb.StatusWaitPay {
return nil, constant.OrderNotWaitPay
}
if network == mdb.PaymentProviderOkPay {
return switchToOkPay(parent, token)
}
// 2. Same token+network as parent → mark selected and return
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
_ = data.MarkOrderSelected(parent.TradeId)
parent.IsSelected = true
return buildCheckoutResponse(parent), nil
}
// 3. Existing active sub-order for this token+network → return it
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
if err != nil {
return nil, err
}
if existing.ID > 0 {
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
return buildCheckoutResponse(existing), nil
}
// 4. Check sub-order limit
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
if count >= MaxSubOrders {
return nil, constant.SubOrderLimitExceeded
}
// 5. Calculate amount for the new network
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 6. Find and lock wallet
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
subTradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
if availableAddress == "" {
return nil, constant.NotAvailableAmountErr
}
// 7. Create sub-order
tx := dao.Mdb.Begin()
subOrder := &mdb.Orders{
TradeId: subTradeID,
OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint)
ParentTradeId: parent.TradeId,
Amount: parent.Amount,
Currency: parent.Currency,
ActualAmount: availableAmount,
ReceiveAddress: availableAddress,
Token: token,
Network: network,
Status: mdb.StatusWaitPay,
IsSelected: true,
NotifyUrl: "",
RedirectUrl: parent.RedirectUrl,
Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
}
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(subTradeID)
return nil, err
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
_ = data.UnLockTransactionByTradeId(subTradeID)
return nil, err
}
// Mark parent as selected and refresh its expiration to match the sub-order
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
return buildCheckoutResponse(subOrder), nil
}
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
return &response.CheckoutCounterResponse{
TradeId: order.TradeId,
Amount: order.Amount,
ActualAmount: order.ActualAmount,
Token: order.Token,
Currency: order.Currency,
ReceiveAddress: order.ReceiveAddress,
Network: order.Network,
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
CreatedAt: order.CreatedAt.TimestampMilli(),
IsSelected: order.IsSelected,
}
}
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
if !data.GetOkPayEnabled() {
return nil, constant.PaymentProviderNotEnabled
}
if data.GetOkPayShopID() == "" || data.GetOkPayShopToken() == "" || data.GetOkPayAPIURL() == "" || data.GetOkPayCallbackURL() == "" {
return nil, constant.PaymentProviderConfigErr
}
if !okPayTokenAllowed(token) {
return nil, constant.PaymentProviderNotSupport
}
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
if err != nil {
return nil, err
}
if existing.ID > 0 {
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(existing.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
return nil, err
}
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
return nil, constant.SystemErr
}
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
resp := buildCheckoutResponse(existing)
resp.PaymentUrl = providerRow.PayURL
return resp, nil
}
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
if count >= MaxSubOrders {
return nil, constant.SubOrderLimitExceeded
}
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
subTradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
returnURL := strings.TrimSpace(parent.RedirectUrl)
if returnURL == "" {
returnURL = data.GetOkPayReturnURL()
}
if returnURL == "" {
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
}
tx := dao.Mdb.Begin()
subOrder := &mdb.Orders{
TradeId: subTradeID,
OrderId: subTradeID,
ParentTradeId: parent.TradeId,
Amount: parent.Amount,
Currency: parent.Currency,
ActualAmount: amount,
ReceiveAddress: "OKPAY",
Token: token,
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
IsSelected: true,
NotifyUrl: "",
RedirectUrl: parent.RedirectUrl,
Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOkPay,
ApiKeyID: parent.ApiKeyID,
}
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
tx.Rollback()
return nil, err
}
providerRow := &mdb.ProviderOrder{
TradeId: subTradeID,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "",
PayURL: "",
Amount: amount,
Coin: token,
Status: mdb.ProviderOrderStatusCreating,
}
if err = data.CreateProviderOrderWithTransaction(tx, providerRow); err != nil {
tx.Rollback()
return nil, err
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
return nil, err
}
okpayOrder, err := createOkPayDepositOrder(subTradeID, amount, token, returnURL)
if err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
resp := buildCheckoutResponse(subOrder)
resp.PaymentUrl = okpayOrder.PayURL
return resp, nil
}
func okPayTokenAllowed(token string) bool {
for _, item := range data.GetOkPayAllowTokens() {
if strings.EqualFold(item, token) {
return true
}
}
return false
}
+784
View File
@@ -0,0 +1,784 @@
package service
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/go-resty/resty/v2"
)
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
return &request.CreateTransactionRequest{
OrderId: orderID,
Currency: "CNY",
Token: "USDT",
Network: "tron",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
t.Helper()
oldFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
client := resty.NewWithClient(&http.Client{Transport: handler})
client.SetTimeout(10 * time.Second)
return client
}
t.Cleanup(func() {
http_client.ClientFactory = oldFactory
})
}
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), nil)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp1.ActualAmount); got != "1.00" {
t.Fatalf("first actual amount = %s, want 1.00", got)
}
if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" {
t.Fatalf("second actual amount = %s, want 1.01", got)
}
if resp1.ReceiveAddress != "wallet_1" || resp2.ReceiveAddress != "wallet_1" {
t.Fatalf("unexpected receive addresses: %s, %s", resp1.ReceiveAddress, resp2.ReceiveAddress)
}
if resp1.Token != "USDT" || resp2.Token != "USDT" {
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
}
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
if err != nil {
t.Fatalf("get first runtime lock: %v", err)
}
if tradeID1 != resp1.TradeId {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
}
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
if err != nil {
t.Fatalf("get second runtime lock: %v", err)
}
if tradeID2 != resp2.TradeId {
t.Fatalf("second runtime lock = %s, want %s", tradeID2, resp2.TradeId)
}
}
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/cny.json" {
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
Request: r,
}, nil
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
t.Fatalf("set rate.api_url: %v", err)
}
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
t.Fatalf("actual amount = %s, want 1.46", got)
}
}
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err)
}
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
t.Fatalf("clear rate.api_url: %v", err)
}
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
if err != constant.RateAmountErr {
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
}
}
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: mixedAddress,
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed mixed-case wallet: %v", err)
}
req := newCreateTransactionRequest("order_evm_1", 1)
req.Network = mdb.NetworkEthereum
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
expectedAddress := strings.ToLower(mixedAddress)
if resp.ReceiveAddress != expectedAddress {
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("load order: %v", err)
}
if order.ReceiveAddress != expectedAddress {
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("lookup runtime lock: %v", err)
}
if tradeID != resp.TradeId {
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.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), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
})
if err != nil {
t.Fatalf("order processing: %v", err)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("get order by trade id: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("callback confirm = %d, want %d", order.CallBackConfirm, mdb.CallBackConfirmNo)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("get runtime lock after processing: %v", err)
}
if tradeID != "" {
t.Fatalf("runtime lock still exists: %s", tradeID)
}
}
func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
req := &request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
}
if err = OrderProcessing(req); err != nil {
t.Fatalf("first order processing: %v", err)
}
err = OrderProcessing(req)
if err != constant.OrderBlockAlreadyProcess {
t.Fatalf("second order processing error = %v, want %v", err, constant.OrderBlockAlreadyProcess)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload order after duplicate block: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status after duplicate block = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("order block transaction id after duplicate block = %s, want block_1", order.BlockTransactionId)
}
}
func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if err = dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", resp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force order expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_expired",
})
if err != constant.OrderStatusConflict {
t.Fatalf("order processing error = %v, want %v", err, constant.OrderStatusConflict)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload expired order: %v", err)
}
if order.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", order.Status, mdb.StatusExpired)
}
if order.BlockTransactionId != "" {
t.Fatalf("expired order block transaction id = %s, want empty", order.BlockTransactionId)
}
}
func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
if _, err := data.AddWalletAddress("wallet_2"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
for _, tc := range []struct {
address string
token string
tradeID string
amount float64
}{
{address: resp1.ReceiveAddress, token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount},
{address: resp2.ReceiveAddress, token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount},
} {
wg.Add(1)
go func(address, token, tradeID string, amount float64) {
defer wg.Done()
<-start
errs <- OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
Network: "tron",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: "shared_block",
})
}(tc.address, tc.token, tc.tradeID, tc.amount)
}
close(start)
wg.Wait()
close(errs)
var successCount int
var duplicateCount int
for err := range errs {
switch err {
case nil:
successCount++
case constant.OrderBlockAlreadyProcess:
duplicateCount++
default:
t.Fatalf("unexpected order processing error: %v", err)
}
}
if successCount != 1 || duplicateCount != 1 {
t.Fatalf("success=%d duplicate=%d, want 1 and 1", successCount, duplicateCount)
}
orders := []struct {
tradeID string
}{
{tradeID: resp1.TradeId},
{tradeID: resp2.TradeId},
}
var paidCount int
var pendingCount int
for _, item := range orders {
order, err := data.GetOrderInfoByTradeId(item.tradeID)
if err != nil {
t.Fatalf("reload order %s: %v", item.tradeID, err)
}
switch order.Status {
case mdb.StatusPaySuccess:
paidCount++
if order.BlockTransactionId != "shared_block" {
t.Fatalf("paid order block transaction id = %s, want shared_block", order.BlockTransactionId)
}
case mdb.StatusWaitPay:
pendingCount++
if order.BlockTransactionId != "" {
t.Fatalf("pending order block transaction id = %s, want empty", order.BlockTransactionId)
}
default:
t.Fatalf("unexpected order status for %s: %d", item.tradeID, order.Status)
}
}
if paidCount != 1 || pendingCount != 1 {
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
}
}
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", parentResp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force parent expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_not_wait",
})
if err == nil {
t.Fatal("expected error when parent order is not wait-pay")
}
}
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
t.Fatalf("delete parent order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_missing",
})
if err == nil {
t.Fatal("expected error when parent order is missing")
}
}
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
// when a sub-order is paid, the parent order is marked as paid but its own
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Snapshot the parent's original fields before any payment.
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("load original parent: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
// Load the sub-order to get its DB primary-key ID.
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("load sub-order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_paid",
})
if err != nil {
t.Fatalf("order processing sub-order: %v", err)
}
// Sub-order must be paid with the block hash and no pending callback.
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("reload sub-order: %v", err)
}
if sub.Status != mdb.StatusPaySuccess {
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
}
if sub.BlockTransactionId != "block_sub_paid" {
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
}
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
}
// Parent must be paid but its own fields must be unchanged.
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("reload parent order: %v", err)
}
if parent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
}
if parent.BlockTransactionId != "" {
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
}
if parent.ReceiveAddress != originalParent.ReceiveAddress {
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
}
if parent.ActualAmount != originalParent.ActualAmount {
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
}
if parent.PayBySubId != subOrder.ID {
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
}
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
}
}
func TestOrderProcessingParentDirectPayExpiresOkPayProviderOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_direct_okpay_expire", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subOrder := &mdb.Orders{
TradeId: "okpay_sub_parent_direct_expire",
OrderId: "okpay_sub_parent_direct_expire",
ParentTradeId: parentResp.TradeId,
Amount: parentResp.Amount,
Currency: parentResp.Currency,
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "",
CallBackConfirm: mdb.CallBackConfirmOk,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(subOrder).Error; err != nil {
t.Fatalf("create okpay sub-order: %v", err)
}
providerRow := &mdb.ProviderOrder{
TradeId: subOrder.TradeId,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "okp-parent-direct-expire",
PayURL: "https://t.me/ExampleWalletBot?start=shop_deposit--okpay-order-parent-direct-expire",
Amount: subOrder.ActualAmount,
Coin: subOrder.Token,
Status: mdb.ProviderOrderStatusPending,
}
if err := dao.Mdb.Create(providerRow).Error; err != nil {
t.Fatalf("create provider row: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: parentResp.ReceiveAddress,
Token: strings.ToUpper(parentResp.Token),
Network: mdb.NetworkTron,
TradeId: parentResp.TradeId,
Amount: parentResp.ActualAmount,
BlockTransactionId: "block_parent_direct_okpay_expire",
})
if err != nil {
t.Fatalf("order processing parent direct pay: %v", err)
}
expiredSub, err := data.GetOrderInfoByTradeId(subOrder.TradeId)
if err != nil {
t.Fatalf("reload sub-order: %v", err)
}
if expiredSub.Status != mdb.StatusExpired {
t.Fatalf("sub-order status = %d, want %d", expiredSub.Status, mdb.StatusExpired)
}
expiredProviderRow, err := data.GetProviderOrderByTradeIDAndProvider(subOrder.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("reload provider row: %v", err)
}
if expiredProviderRow.Status != mdb.ProviderOrderStatusExpired {
t.Fatalf("provider row status = %q, want %q", expiredProviderRow.Status, mdb.ProviderOrderStatusExpired)
}
}
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
// well as the parent's lock) are released.
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
t.Fatalf("add bsc wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Create two sub-orders on different networks.
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch to ethereum sub-order: %v", err)
}
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkBsc,
})
if err != nil {
t.Fatalf("switch to bsc sub-order: %v", err)
}
// Pay the Ethereum sub-order.
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subEthResp.ReceiveAddress,
Token: strings.ToUpper(subEthResp.Token),
Network: strings.ToLower(subEthResp.Network),
TradeId: subEthResp.TradeId,
Amount: subEthResp.ActualAmount,
BlockTransactionId: "block_sib_eth",
})
if err != nil {
t.Fatalf("order processing eth sub-order: %v", err)
}
// BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
if err != nil {
t.Fatalf("reload bsc sub-order: %v", err)
}
if subBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
}
// Parent runtime lock must be released.
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
if err != nil {
t.Fatalf("check parent runtime lock: %v", err)
}
if parentLock != "" {
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
}
// BSC sibling runtime lock must be released.
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
if err != nil {
t.Fatalf("check bsc sibling runtime lock: %v", err)
}
if sibLock != "" {
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
}
// Ethereum sub-order runtime lock must also be released.
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
if err != nil {
t.Fatalf("check eth sub-order runtime lock: %v", err)
}
if ethLock != "" {
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
}
}
+17 -7
View File
@@ -2,27 +2,37 @@ package service
import (
"errors"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/response"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
)
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单
var ErrOrder = errors.New("不存在待支付订单或已过期")
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
return nil, err
}
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, errors.New("不存在待支付订单或已过期!")
return nil, ErrOrder
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampWithMillisecond(),
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
}
return resp, nil
}
+659
View File
@@ -0,0 +1,659 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"strings"
"sync"
"time"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
var gProcessedSignatures sync.Map // sig -> unix timestamp
type TransferInfo struct {
Source string // Source address (for SOL) or source ATA (for SPL tokens)
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers)
Amount float64 // Human-readable amount (adjusted for decimals)
RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals)
Decimals *int // Optional decimals from the transaction, if available
BlockTime int64 // Block time of the transfer unit is seconds since epoch
}
// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。
func SolCallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err)
}
}()
// Load enabled Solana tokens from chain_tokens. The contract_address
// column holds the mint for SPL tokens; a special entry with
// symbol=SOL and empty contract_address enables native SOL payments.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
return
}
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
mint := strings.TrimSpace(tokens[i].ContractAddress)
if sym == "SOL" && mint == "" {
nativeSolToken = &tokens[i]
continue
}
if mint == "" {
continue
}
mintTokens[mint] = &tokens[i]
}
// Clean up old entries from processed cache (older than 1 hour)
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
gProcessedSignatures.Range(func(key, value interface{}) bool {
if ts, ok := value.(int64); ok && ts < cleanupCutoff {
gProcessedSignatures.Delete(key)
}
return true
})
limit := 1000
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
queryAddrs := []string{address}
for mint := range mintTokens {
ata, err := FindATAAddress(address, mint)
if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
continue
}
queryAddrs = append(queryAddrs, ata)
}
// 拉取签名并去重
seen := make(map[string]bool)
var result []solSignatureResult
for _, queryAddr := range queryAddrs {
respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "")
if err != nil {
log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err)
continue
}
resultBody := gjson.GetBytes(respBody, "result")
if !resultBody.Exists() || !resultBody.IsArray() {
log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody))
continue
}
var batch []solSignatureResult
err = json.Unmarshal([]byte(resultBody.Raw), &batch)
if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err)
continue
}
for _, sig := range batch {
if !seen[sig.Signature] {
seen[sig.Signature] = true
result = append(result, sig)
}
}
}
if len(result) == 0 {
log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address)
return
}
// 按 blockTime 降序排列
sort.Slice(result, func(i, j int) bool {
if result[i].BlockTime == nil {
return false
}
if result[j].BlockTime == nil {
return true
}
return *result[i].BlockTime > *result[j].BlockTime
})
// 时间截止线:订单过期时间 + 5 分钟
cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix()
log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d",
address, len(result), len(queryAddrs), cutoffTime)
// Process each transaction signature
for sigIdx, txSig := range result {
sig := txSig.Signature
// Skip failed transactions
if txSig.Err != nil {
continue
}
// 超过截止时间的旧签名不再处理
if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime {
log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan",
address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime)
break
}
// 跳过已处理的签名
if _, ok := gProcessedSignatures.Load(sig); ok {
continue
}
log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot)
txData, err := SolGetTransaction(sig)
if err != nil {
log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err)
continue
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions))
for instrIdx, instruction := range instructions {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType)
transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData)
if err != nil {
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err)
continue
}
if transferInfo == nil {
continue
}
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d",
address, sig, instrIdx,
transferInfo.Source, transferInfo.Destination, transferInfo.Mint,
transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime)
if !isTransferToAddress(transferInfo, address) {
continue
}
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if token == "" || amount <= 0 {
continue
}
log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f",
address, sig, instrIdx, token, amount, address, token, amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
continue
}
if tradeID == "" {
log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)",
address, sig, address, token, amount)
continue
}
log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f",
address, tradeID, sig, token, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
continue
}
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli())
// blockTime 秒 → 毫秒,与订单创建时间对齐
blockTimestamp := transferInfo.BlockTime * 1000
createTime := order.CreatedAt.TimestampMilli()
log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d",
address, sig, blockTimestamp, createTime, blockTimestamp-createTime)
if blockTimestamp < createTime {
log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)",
address, sig, blockTimestamp, createTime-blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
Network: mdb.NetworkSolana,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: sig,
}
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
address, tradeID, sig, token, amount)
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
continue
}
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
continue
}
log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification",
address, tradeID, sig, token, amount)
sendPaymentNotification(order)
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
}
// 标记已处理
gProcessedSignatures.Store(sig, time.Now().Unix())
}
}
// solSignatureResult getSignaturesForAddress 返回结构
type solSignatureResult struct {
Signature string `json:"signature"`
Slot uint64 `json:"slot"`
Err interface{} `json:"err"`
BlockTime *int64 `json:"blockTime"`
}
func resolveSolanaRpcURL() (string, error) {
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
if err != nil {
return "", err
}
if node == nil || node.ID == 0 {
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimSpace(node.Url)
if rpcURL == "" {
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, nil
}
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New()
client.SetRetryCount(5)
client.SetRetryWaitTime(2 * time.Second)
client.SetRetryMaxWaitTime(10 * time.Second)
client.AddRetryCondition(func(r *resty.Response, err error) bool {
if err != nil {
return true
}
if r.StatusCode() >= 429 || r.StatusCode() >= 500 {
return true
}
return false
})
rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetBody(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}).
Post(rpcUrl)
if err != nil {
return nil, err
}
respBody := resp.Body()
return respBody, nil
}
func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) {
opts := map[string]interface{}{
"commitment": "finalized",
"limit": limit,
}
if untilSig != "" {
opts["until"] = untilSig
}
if beforeSig != "" {
opts["before"] = beforeSig
}
bodyData, err := SolRetryClient("getSignaturesForAddress",
[]interface{}{address, opts})
if err != nil {
return nil, err
}
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
return nil, err
}
_, ok := result["result"].([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response format: %v", result)
}
return bodyData, nil
}
func SolGetTransaction(sig string) ([]byte, error) {
txData, err := SolRetryClient("getTransaction", []interface{}{
sig,
map[string]interface{}{
"encoding": "jsonParsed",
"commitment": "confirmed",
"maxSupportedTransactionVersion": 0, // suport
},
})
if err != nil {
log.Sugar.Errorf("SolRetryClient failed: %v", err)
return nil, err
}
errField := gjson.GetBytes(txData, "result.meta.err")
if errField.Exists() && errField.Type != gjson.Null {
log.Sugar.Warnf("Transaction failed: %v", errField.String())
return nil, fmt.Errorf("transaction failed: %s", errField.String())
}
return txData, nil
}
// isTransferToAddress 判断转账目标是否为指定钱包地址
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
// Native SOL transfer - check destination directly
if transfer.Mint == "SOL" {
return strings.EqualFold(transfer.Destination, targetAddress)
}
// Skip Transfer instruction without mint info (use TransferChecked instead)
if transfer.Mint == "" {
return false
}
// SPL Token transfer - check if destination ATA matches
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
}
// resolveSolTokenAndAmount identifies the token from a transfer using
// the admin-configured chain_tokens list. mintTokens maps SPL mint
// addresses to their ChainToken config; nativeSolToken is non-nil when a
// native SOL entry exists in chain_tokens. Returns ("", 0) for
// transfers whose mint is not configured or below min_amount.
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
mint := transfer.Mint
// Native SOL
if mint == "SOL" {
if nativeSolToken == nil {
return "", 0
}
amount := transfer.Amount
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
}
cfg, ok := mintTokens[mint]
if !ok || cfg == nil {
return "", 0
}
decimals := cfg.Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
if decimals <= 0 {
decimals = 6
}
amount := ADJustAmount(transfer.RawAmount, decimals)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
}
const (
// Mint token
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
USDT_Decimals = 6
USDC_Decimals = 6
SOL_Decimals = 9
)
const (
// SPL Token instructions
InstructionTransfer = 3
InstructionTransferChecked = 12
)
const (
// System Program
SystemProgramID = "11111111111111111111111111111111"
InstructionSystemTransfer = 2
)
const (
TokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
)
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
func ADJustAmount(amount uint64, decimals int) float64 {
if amount == 0 {
return 0
}
decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0)
// 10^decimals
decimalDivisor := decimal.New(1, int32(decimals))
adjustedAmount := decimalAmount.Div(decimalDivisor)
// Round to 2 decimal places
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
}
func MatchUsdtAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDT_Mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func MatchAtaAddress(address string, mint string, ataTo string) bool {
ata, err := FindATAAddress(address, mint)
if err != nil {
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
return strings.EqualFold(ata, ataTo)
}
func FindATAAddress(owner, mint string) (string, error) {
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
if err != nil {
return "", fmt.Errorf("invalid owner public key: %w", err)
}
mintPubKey, err := solana.PublicKeyFromBase58(mint)
if err != nil {
return "", fmt.Errorf("invalid mint public key: %w", err)
}
ata, _, err := solana.FindAssociatedTokenAddress(ownerPubKey, mintPubKey)
if err != nil {
return "", fmt.Errorf("find associated token address failed: %w", err)
}
return ata.String(), nil
}
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
programID := instruction.Get("programId").String()
parsedType := instruction.Get("parsed.type").String()
if programID == SystemProgramID && parsedType == "transfer" {
return parseSystemTransfer(instruction, txData)
}
if programID == TokenProgramID || programID == Token2022ProgramID {
switch parsedType {
case "transfer":
return parseSplTransfer(instruction, txData)
case "transferChecked":
return parseSplTransferChecked(instruction, txData)
}
}
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
return nil, nil
}
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
lamports := info.Get("lamports").Uint()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
return &TransferInfo{
Source: source,
Destination: destination,
Mint: "SOL",
Amount: ADJustAmount(lamports, SOL_Decimals),
RawAmount: lamports,
BlockTime: blockTime,
}, nil
}
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
amountStr := info.Get("amount").String()
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
// Look up mint and decimals from postTokenBalances using the destination ATA
mint, decimals, found := findMintFromTokenBalances(destination, txData)
if !found {
// Try source ATA as fallback
mint, decimals, found = findMintFromTokenBalances(source, txData)
}
if !found {
return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
}
d := decimals
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &d,
BlockTime: blockTime,
}, nil
}
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
info := instruction.Get("parsed.info")
source := info.Get("source").String()
destination := info.Get("destination").String()
mint := info.Get("mint").String()
amountStr := info.Get("tokenAmount.amount").String()
decimals := int(info.Get("tokenAmount.decimals").Int())
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
if !ok {
return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
return &TransferInfo{
Source: source,
Destination: destination,
Mint: mint,
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
RawAmount: rawAmount.Uint64(),
Decimals: &decimals,
BlockTime: blockTime,
}, nil
}
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
accountIndex := -1
for i, key := range accountKeys {
if key.Get("pubkey").String() == ataAddress {
accountIndex = i
break
}
}
if accountIndex == -1 {
return "", 0, false
}
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
for _, balance := range balances {
if int(balance.Get("accountIndex").Int()) == accountIndex {
mint := balance.Get("mint").String()
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
return mint, decimals, true
}
}
return "", 0, false
}
+401
View File
@@ -0,0 +1,401 @@
package service
import (
"encoding/json"
"os"
"testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/tidwall/gjson"
)
func setupSolanaRPCNode(t *testing.T, url string) func() {
t.Helper()
cleanup := testutil.SetupTestDatabases(t)
node := &mdb.RpcNode{
Network: mdb.NetworkSolana,
Url: url,
Type: mdb.RpcNodeTypeHttp,
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
cleanup()
t.Fatalf("seed solana rpc_node: %v", err)
}
return cleanup
}
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if got, err := resolveSolanaRpcURL(); err == nil {
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
}
}
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
defer cleanup()
got, err := resolveSolanaRpcURL()
if err != nil {
t.Fatalf("resolveSolanaRpcURL(): %v", err)
}
if got != "https://solana.example.com" {
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
}
}
func TestSolClientHealthy(t *testing.T) {
requireSolanaIntegration(t)
bodyData, err := SolRetryClient("getHealth", nil)
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
status, ok := result["result"].(string)
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("RPC Health Status: %s", status)
if status != "ok" {
t.Errorf("Expected health status 'ok', got '%s'", status)
}
}
func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t)
// Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
var result map[string]interface{}
err = json.Unmarshal(bodyData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
signatures, ok := result["result"].([]interface{})
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("Found %d signatures for address %s", len(signatures), address)
}
func TestSolClientGetTransaction(t *testing.T) {
requireSolanaIntegration(t)
// Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
if err != nil {
t.Fatalf("SolRetryClient failed: %v", err)
}
var result map[string]interface{}
err = json.Unmarshal(txData, &result)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
txInfo, ok := result["result"].(map[string]interface{})
if !ok {
t.Fatalf("Unexpected response format: %v", result)
}
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
}
func requireSolanaIntegration(t *testing.T) {
t.Helper()
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
}
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
t.Cleanup(cleanup)
}
func TestFindATAAddress(t *testing.T) {
tests := []struct {
name string
owner string
mint string
want string
}{
{
name: "RAY token ATA",
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ata, err := FindATAAddress(tt.owner, tt.mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", tt.owner)
t.Logf("Mint: %s", tt.mint)
t.Logf("ATA: %s", ata)
if tt.want != "" && ata != tt.want {
t.Errorf("Expected ATA %s, got %s", tt.want, ata)
}
})
}
}
func TestMatchATAAddress(t *testing.T) {
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
ok := MatchAtaAddress(owner, mint, expectedATA)
t.Logf("Owner: %s", owner)
t.Logf("Mint: %s", mint)
t.Logf("Expected ATA: %s", expectedATA)
t.Logf("Match result: %v", ok)
if !ok {
t.Error("Expected ATA to match, but it didn't")
}
}
func TestMatchUsdtAtaAddress(t *testing.T) {
// Example wallet address (replace with actual test address)
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
ata, err := FindATAAddress(owner, USDT_Mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", owner)
t.Logf("USDT Mint: %s", USDT_Mint)
t.Logf("USDT ATA: %s", ata)
ok := MatchUsdtAtaAddress(owner, ata)
if !ok {
t.Error("Expected USDT ATA to match")
}
}
func TestMatchUsdcAtaAddress(t *testing.T) {
// Example wallet address (replace with actual test address)
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
ata, err := FindATAAddress(owner, USDC_Mint)
if err != nil {
t.Fatalf("FindATAAddress failed: %v", err)
}
t.Logf("Owner: %s", owner)
t.Logf("USDC Mint: %s", USDC_Mint)
t.Logf("USDC ATA: %s", ata)
ok := MatchUsdcAtaAddress(owner, ata)
if !ok {
t.Error("Expected USDC ATA to match")
}
}
func TestAdjustAmount(t *testing.T) {
tests := []struct {
name string
amount uint64
decimals int
want float64
}{
{
name: "USDT amount (6 decimals)",
amount: 123456789,
decimals: 6,
want: 123.46,
},
{
name: "USDC amount (6 decimals)",
amount: 1000000,
decimals: 6,
want: 1.0,
},
{
name: "SOL amount (9 decimals)",
amount: 1000000000,
decimals: 9,
want: 1.0,
},
{
name: "Zero amount",
amount: 0,
decimals: 6,
want: 0,
},
{
name: "Small amount",
amount: 1,
decimals: 6,
want: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adjusted := ADJustAmount(tt.amount, tt.decimals)
t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
if adjusted != tt.want {
t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
}
})
}
}
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
requireSolanaIntegration(t)
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
var found bool
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
found = true
t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint == "" {
t.Error("Expected mint to be resolved from postTokenBalances")
}
if info.RawAmount != 50000 {
t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if !found {
t.Error("No transfer instruction found in transaction")
}
}
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
requireSolanaIntegration(t)
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
var found bool
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
found = true
t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint != USDT_Mint {
t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
}
if info.RawAmount != 300000 {
t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
}
if info.Amount != 0.3 {
t.Errorf("Expected amount 0.3, got %f", info.Amount)
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if !found {
t.Error("No transferChecked instruction found in transaction")
}
}
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
requireSolanaIntegration(t)
// System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
txData, err := SolGetTransaction(sig)
if err != nil {
t.Fatalf("SolGetTransaction failed: %v", err)
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
transferCount := 0
for _, inst := range instructions {
info, err := ParseTransferInfoFromInstruction(inst, txData)
if err != nil {
t.Logf("parse error (ok to skip): %v", err)
continue
}
if info == nil {
continue
}
transferCount++
t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
if info.Mint != "SOL" {
t.Errorf("Expected mint SOL, got %s", info.Mint)
}
if info.RawAmount == 0 {
t.Error("Expected non-zero raw amount")
}
if info.BlockTime == 0 {
t.Error("Expected non-zero blockTime")
}
}
if transferCount == 0 {
t.Error("No system transfer instruction found")
}
t.Logf("Found %d system transfers", transferCount)
}

Some files were not shown because too many files have changed in this diff Show More