53 Commits

Author SHA1 Message Date
line-6000 33cc2e1cfb feat: support ARC_WSS env var to override Arc WebSocket URL 2026-05-21 01:19:26 +08:00
line-6000 f23f47a1e8 feat: add ARC Testnet 2026-05-15 20:02:25 +08:00
line-6000 864eb5969c Merge pull request #66 from GMWalletApp/dev 2026-05-13 17:00:39 +08:00
line-6000 b0093c48c7 fix: update service worker precache manifest and improve module registration 2026-05-13 16:59:06 +08:00
line-6000 4a69b7c863 Merge pull request #65 from GMWalletApp/dev
update readme.md
2026-05-12 15:20:43 +08:00
line-6000 52b171b49b update readme.md 2026-05-12 15:19:36 +08:00
line-6000 4dc1ba88cd Merge pull request #64 from GMWalletApp/dev
Dev
2026-05-11 18:07:56 +08:00
line-6000 7a905ba307 update www 2026-05-11 18:06:53 +08:00
line-6000 07d1d8345f update readme.md 2026-05-11 17:23:18 +08:00
line-6000 8d2ddbd045 feat: add configurable payment amount precision
- add system.amount_precision setting with 2-6 validation
2026-05-11 17:11:43 +08:00
line-6000 1c805ed061 feat: support background_color background_image_url 2026-05-11 16:03:32 +08:00
line-6000 84972ea0f0 fix: resolve chain tokens dynamically in block listeners
- resolve EVM ERC20 tokens by chain_tokens contract_address instead of hardcoded contracts
2026-05-11 13:40:07 +08:00
line-6000 7c9e3e123e fix: (update tron logic) 2026-05-11 13:16:42 +08:00
line-6000 3e0be9bb20 docs: split README into English and Simplified Chinese versions 2026-05-11 12:45:18 +08:00
line-6000 a8a3b8697f fix: print initial admin credentials to console on first bootstrap 2026-05-11 12:20:36 +08:00
Astra3456 2d546e3478 Revise disclaimer to add terms of use
Updated disclaimer section to include terms of use.
2026-05-10 03:27:25 +10:00
Astra3456 1a35cab919 Update README with project disclaimer and risk information
Added a disclaimer regarding the use and risks associated with EPusdt, clarifying its non-profit and open-source nature.
2026-05-10 03:26:50 +10:00
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
334 changed files with 5286 additions and 3181 deletions
+26 -15
View File
@@ -5,28 +5,40 @@ on:
push: push:
branches: branches:
- "main" - "main"
- "master"
tags:
- "v*"
env: env:
DOCKERHUB_NAMESPACE: gmwallet
IMAGE_NAME: epusdt IMAGE_NAME: epusdt
jobs: jobs:
push-docker-hub: push-docker-hub:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Compute Build Metadata - name: Set Up QEMU
id: meta uses: docker/setup-qemu-action@v3
shell: bash
run: |
echo "version=main-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "commit=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login - name: 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 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -38,9 +50,8 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64
build-args: | build-args: |
VERSION=${{ steps.meta.outputs.version }} API_RATE_URL=${{ vars.API_RATE_URL }}
COMMIT=${{ steps.meta.outputs.commit }} tags: ${{ steps.meta.outputs.tags }}
DATE=${{ steps.meta.outputs.date }} labels: ${{ steps.meta.outputs.labels }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:alpine
+7
View File
@@ -4,6 +4,12 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
inputs:
release_tag:
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
required: true
type: string
permissions: permissions:
contents: write contents: write
@@ -25,6 +31,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
- name: Ensure Tag Commit Is On Default Branch - name: Ensure Tag Commit Is On Default Branch
env: env:
+6 -3
View File
@@ -5,7 +5,7 @@ ENV CGO_ENABLED=0
WORKDIR /app WORKDIR /app
RUN git clone https://github.com/GMwalletApp/epusdt.git . COPY . /app
WORKDIR /app/src WORKDIR /app/src
RUN go mod download RUN go mod download
@@ -14,10 +14,13 @@ RUN go build -o /app/epusdt .
FROM alpine:latest AS runner FROM alpine:latest AS runner
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
ARG API_RATE_URL=""
WORKDIR /app WORKDIR /app
COPY --from=builder /app/src/static /app/static COPY --from=builder /app/src/.env.example /app/.env
COPY --from=builder /app/src/static /static 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 . COPY --from=builder /app/epusdt .
VOLUME /app/conf VOLUME /app/conf
+176
View File
@@ -0,0 +1,176 @@
# Epusdt — Easy Payment USDT
<p align="center">
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p>
<p align="center">
<strong>Open-source multi-chain crypto payment gateway · Top real-world adoption</strong>
</p>
<p align="center">
<a href="./README.en.md">English</a> |
<a href="./README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/Docs-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-Channel-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-Group-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
</p>
<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 started as a TRC20-only solution and evolved into a broader **multi-chain receiving platform**, allowing websites and applications to accept crypto payments across multiple networks and token types. No third-party custody, no platform fees — funds go directly into your wallet.
> **GitHub Star 3000+** · **10+ supported platform integrations** · **Top real-world adoption among crypto payment tools**
Deploy it privately, integrate with the HTTP API, and start receiving crypto payments in minutes.
### Supported Networks and Tokens
| Network | Tokens |
|---------|--------|
| **TRC20** (Tron) | USDT, TRX |
| **ERC20** (Ethereum) | USDT, USDC, ETH |
| **Solana** | USDT, USDC |
| **BEP20** (BSC) | USDT, USDC, BNB |
| **Polygon** | USDT, USDC |
| **More** | Expanding continuously |
> Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com).
---
## Broad Compatibility, Plug and Play
No matter what kind of platform you operate, Epusdt can fit into existing payment integration patterns with **minimal or no business logic refactor**, giving you a fast path to crypto payments for global use cases:
| Category | Supported Systems |
|----------|-------------------|
| **AI Distribution** | [OneAPI](https://github.com/songquanpeng/one-api), [NewAPI](https://github.com/QuantumNous/new-api) |
| **Card/Voucher Platforms** | [Dujiaoka](https://dujiao-next.com/), [ACG Faka](https://github.com/lizhipay/acg-faka) |
| **Proxy Panels** | [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) |
| **Website Ecosystem** | [WordPress](https://wordpress.com/), [WHMCS](https://www.whmcs.com/) |
| **Epay-Compatible Flow** | Works with platforms already using Epay-style payment interfaces |
| **More** | Simple HTTP API, often integrated within 10 minutes |
---
## Core Features
- **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, and more
- **Self-hosted** — full control of funds and wallet infrastructure
- **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis
- **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac
- **Multi-wallet rotation** — rotates receiving addresses to improve concurrency handling
- **Async callback queue** — handles merchant notifications efficiently
- **HTTP API** — easy integration from any language or framework
- **Telegram Bot** — instant payment notifications and lightweight operations
---
## Documentation and Guides
Full documentation: **[epusdt.com](https://epusdt.com)**
Quick-start links:
| Guide | Description |
|-------|-------------|
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
| [Developer API Docs](https://epusdt.com) | Integration reference |
---
## Project Structure
```text
Epusdt
├── src/ Core project source code
└── wiki/ Documentation assets and knowledge base
```
---
## How It Works
Epusdt listens to supported blockchain APIs or RPC nodes across multiple networks such as TRC20, ERC20, BEP20, and Polygon. It captures incoming token transfers in real time and matches ownership using **amount differentiation** and **time-bounded locks**:
```text
Workflow:
1. A customer initiates payment and needs to send 20.05 USDT
2. The system searches for an available wallet-address + amount combination
3. If address_1:20.05 is free -> lock it for 10 minutes and return it
4. If already occupied -> add 0.0001 and try the next amount combination
5. Background listeners keep watching wallet deposits and mark the order paid once the amount matches
```
![Epusdt Payment Flow](wiki/img/implementation_principle.jpg)
---
## Community and Support
If you hit an issue, please open a [GitHub Issue](https://github.com/GMWalletApp/epusdt/issues) first. That is the best way to get attention and help.
| Channel | Link |
|---------|------|
| Telegram Channel | [https://t.me/epusdt](https://t.me/epusdt) |
| Telegram Group | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
| Official Docs | [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>
---
## License
Epusdt is released under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license.
---
## Disclaimer and Terms of Use
EPusdt is developed and published by Good Morning Technology, LLC as free, open-source, non-profit, and non-custodial software for learning, research, and technical communication. Nothing in this project constitutes investment, financial, legal, tax, compliance, or any other professional advice, nor should it be interpreted as a guarantee regarding any asset, transaction outcome, revenue, fund safety, technical availability, or fitness for a particular purpose.
Good Morning Technology, LLC is an entity organized under United States law and will comply with applicable legal and regulatory obligations as required. Under U.S. regulatory frameworks, whether virtual currencies, digital assets, or related technical services constitute money transmission, a money services business (MSB), or another regulated activity generally depends on the specific business model, including whether the operator receives, holds, controls, or transmits funds or value, provides custody on behalf of users, or engages in exchange, payment, clearing, settlement, or other intermediary financial services.
As a free, open-source, non-profit, and non-custodial software project, the source code, documentation, technical communication, and related development activities of EPusdt should not be interpreted as authorization, endorsement, control, participation, warranty, or commitment by Good Morning Technology, LLC or its contributors regarding any user's downstream use, modification, deployment, integration, distribution, or secondary development.
Users independently determine how they use this project, for what purpose, and in what context. Good Morning Technology, LLC and its contributors cannot control, review, or restrict such conduct. Users are solely responsible for ensuring that their use, modification, deployment, integration, distribution, or derivative development complies with applicable laws, regulations, sanctions rules, and third-party rights in the relevant jurisdictions, and they bear all resulting risks, liabilities, and consequences.
Crypto assets are a high-risk emerging asset class. Stablecoins and other digital assets may experience sharp volatility, de-pegging, insufficient liquidity, technical failures, regulatory changes, or total loss of value. All code, documentation, and related materials in this project are provided on an "as is" and "as available" basis. Except where mandatory law provides otherwise, Good Morning Technology, LLC and its contributors are not liable for any direct or indirect loss resulting from use, inability to use, misuse, unlawful use, modification, deployment, integration, distribution, derivative development, or reliance on this project.
---
<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 · Dujiaoka Payment · ACG Faka Payment ·
V2Board Payment · XBoard Payment · SSPanel Payment Interface ·
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
Epusdt · Easy Payment USDT · Open Source Payment Gateway · Multi-chain Collection
</sub>
</p>
+58 -52
View File
@@ -1,13 +1,18 @@
# Epusdt — Easy Payment USDT # Epusdt — Easy Payment USDT
<p align="center"> <p align="center">
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120"> <img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
</p> </p>
<p align="center"> <p align="center">
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong> <strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
</p> </p>
<p align="center">
<a href="./README.en.md">English</a> |
<a href="./README.md">简体中文</a>
</p>
<p align="center"> <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://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"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
@@ -15,38 +20,38 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/assimon/epusdt/stargazers"><img src="https://img.shields.io/github/stars/assimon/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a> <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://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://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
<a href="https://github.com/assimon/epusdt/releases"><img src="https://img.shields.io/github/v/release/assimon/epusdt?style=flat-square&color=green" alt="Latest Release"></a> <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> </p>
--- ---
## 🌍 What is Epusdt? ## 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. **Epusdt** (Easy Payment USDT) 是一个基于 Go 构建、支持私有化部署的 **多链多币种 Crypto 支付网关**。它从最初的 TRC20 单链方案逐步演进为完整的 **多链收款平台**,让任意网站或应用都能快速接入多条链、多种代币的加密支付能力。没有第三方托管,没有平台抽成,资金直接进入你的钱包。
> **GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1** > **GitHub Star 3000+** · **已支持站点解决方案 10+** · **Crypto 支付工具实际采用率 Top 1**
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉 私有部署,按 HTTP API 接入,几分钟内就可以开始接收 **Crypto Payments**
### 🔗 已支持网络与代币 ### 已支持网络与代币
| 网络 | 代币 | | 网络 | 代币 |
|------|------| |------|------|
| **TRC20** (Tron) | USDT、TRX| | **TRC20** (Tron) | USDT、TRX |
| **ERC20** (Ethereum) | USDT、USDC、ETH | | **ERC20** (Ethereum) | USDT、USDC、ETH |
| **Solana** | USDT、USDC | | **Solana** | USDT、USDC |
| **BEP20** (BSC) | USDT、USDC、BNB | | **BEP20** (BSC) | USDT、USDC、BNB |
| **Polygon** | USDT、USDC | | **Polygon** | USDT、USDC |
| **更多** | 持续扩展中… | | **更多** | 持续扩展中… |
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 > 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
--- ---
## 🔌 广泛兼容,即插即用 ## 广泛兼容,即插即用
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景: 无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
@@ -56,29 +61,27 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
| **发卡系统** | [独角数卡(Dujiaoka](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) | | **发卡系统** | [独角数卡(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) | | **代理面板** | [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/) | | **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 | | **Epay 兼容** | 兼容各类支持 Epay 易支付接口的平台 |
| **更多** | 简易HTTP API 10分钟内接入 | | **更多** | 简易 HTTP API10 分钟内接入 |
👉 查看更多集成列表与插件:[plugins/](plugins/)
--- ---
## 核心特性 ## 核心特性
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链 - **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控 - **私有化部署** — 资金完全自主掌控
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护 - **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备 - **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力 - **多钱包轮询** — 自动轮换收款地址,提高并发处理能力
- **异步队列** — 高性能消息回调,优雅处理高并发场景 - **异步队列** — 高性能消息回调,适配高并发场景
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成 - **HTTP API** — 标准化接口,任何语言 / 框架都能快速集成
- **Telegram Bot** — 实时支付通知,快捷管理与监控 - **Telegram Bot** — 实时支付通知,快捷管理与监控
--- ---
## 📖 文档与教程 ## 文档与教程
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)** 完整文档请访问**[epusdt.com](https://epusdt.com)**
快速入门: 快速入门:
@@ -91,29 +94,26 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
--- ---
## 🏗️ 项目结构 ## 项目结构
``` ```text
Epusdt Epusdt
├── plugins/ → 已集成的系统插件(独角数卡等) ├── src/ 项目核心代码
── src/ → 项目核心代码 ── wiki/ 文档与知识库
├── sdk/ → 接入 SDK
├── sql/ → 数据库安装 / 升级脚本
└── wiki/ → 文档与知识库
``` ```
--- ---
## 🔧 实现原理 ## 实现原理
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
``` ```text
工作流程: 工作流程:
1. 客户发起支付,需支付 20.05 USDT 1. 客户发起支付,需支付 20.05 USDT
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合 2. 系统在哈希表中查找可用的钱包地址 + 金额组合
3. 若 address_1:20.05 未被占用 锁定该组合(有效期 10 分钟),返回给客户 3. 若 address_1:20.05 未被占用 -> 锁定该组合(有效期 10 分钟),返回给客户
4. 若已被占用 自动累加 0.0001 尝试下一个金额组合(最多 100 次) 4. 若已被占用 -> 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功 5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
``` ```
@@ -121,17 +121,15 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
--- ---
## 💬 社区与支持 ## 社区与支持
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。 **遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会优先处理反馈。
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
| 渠道 | 链接 | | 渠道 | 链接 |
|------|------| |------|------|
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) | | Epusdt 频道 | [https://t.me/epusdt](https://t.me/epusdt) |
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) | | Epusdt 交流群 | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) | | 官方文档站 | [https://epusdt.com](https://epusdt.com) |
--- ---
@@ -147,24 +145,32 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
--- ---
## 📜 开源协议 ## 开源协议
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。 Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
--- ---
## ⚠️ 免责声明 ## 免责声明及使用条款
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士 EPusdt 由 Good Morning Technology, LLC 以免费、开源、非盈利及非托管性质开发和披露,仅供学习、研究与技术交流使用。项目本身不构成投资、金融、法律、税务、合规或任何其他专业建议,也不应被视为对任何资产、交易结果、收益、资金安全、技术可用性或特定用途作出任何保证
Good Morning Technology, LLC 为依据美国法律设立的主体,并将在适用法律法规范围内履行相应合规义务。就美国监管框架而言,虚拟货币、数字资产及相关技术服务是否构成 money transmission、money services businessMSB)或其他受监管活动,通常取决于具体业务模式,包括项目方是否接收、持有、控制或传输资金或价值,是否代表用户托管资产,以及是否从事兑换、支付、清算、结算或其他中介性金融服务。
作为免费、开源、非盈利、非托管的软件项目,EPusdt 的代码披露、文档说明、技术交流及相关开发活动,本身不应被理解为 Good Morning Technology, LLC 或其贡献者对任何用户后续使用、修改、部署、集成、分发或二次开发行为的授权、背书、控制、参与、保证或承诺。
用户对本项目的实际使用方式、使用目的及后续行为由其自行决定,Good Morning Technology, LLC 及其贡献者无法控制、审查或限制该等行为。用户应自行确保其使用、修改、部署、集成、分发或二次开发行为符合所在地适用法律法规、监管要求、制裁规则及第三方权利,并独立承担由此产生的全部风险、责任与后果。
加密资产属于高风险新兴资产类别,包括稳定币在内的数字资产均可能发生剧烈波动、脱锚、流动性不足、技术故障、监管变化或价值归零等风险。本项目所有代码、文档及相关材料均按“现状”和“可用状态”提供。除适用法律另有强制规定外,Good Morning Technology, LLC 及其贡献者不因用户使用、无法使用、错误使用、违法使用、修改、部署、集成、分发、二次开发或依赖本项目而产生的任何直接或间接损失承担责任。
--- ---
<p align="center"> <p align="center">
<sub> <sub>
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment · <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 · 独角数卡支付 · 异次元发卡支付方式 · Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
V2Board Payment · XBoard Payment · SSPanel 支付接口 · V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT · WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款 Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
</sub> </sub>
</p> </p>
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
epusdt: epusdt:
image: gmwallet/epusdt:alpine image: gmwallet/epusdt:latest
restart: always restart: always
build: build:
context: . context: .
View File
-12
View File
@@ -1,12 +0,0 @@
## 使用方法
### 注意此插件仅适用于独角数卡2.0.4版本以下,2.0.4版本或以上的独角数卡已经内置此插件,无需配置
1.将`app``routes`目录覆盖到网站根目录。
2.在独角数卡后台添加一个支付方式。
| 支付选项 | 商户id | 商户key | 商户密钥 | 备注 |
| :-----| :----- | :----- | :----- |:-----|
| Epusdt | api接口认证token | 空 | epusdt收银台地址+/api/v1/order/create-transaction| 如果独角数卡和epusdt在同一服务器则填写`127.0.0.1`不要填域名,例如`http://127.0.0.1:8000/api/v1/order/create-transaction` |
示例:
![示例配置](../../wiki/img/dujiaoka_epusdt.png)
@@ -1,101 +0,0 @@
<?php
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @link http://utf8.hk/
*/
namespace App\Http\Controllers\Pay;
use App\Exceptions\RuleValidationException;
use App\Http\Controllers\PayController;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Http\Request;
class EpusdtController extends PayController
{
public function gateway(string $payway, string $orderSN)
{
try {
// 加载网关
$this->loadGateWay($orderSN, $payway);
//构造要请求的参数数组,无需改动
$parameter = [
"amount" => (float)$this->order->actual_price,//原价
"order_id" => $this->order->order_sn, //可以是用户ID,站内商户订单号,用户名
'redirect_url' => route('epusdt-return', ['order_id' => $this->order->order_sn]),
'notify_url' => url($this->payGateway->pay_handleroute . '/notify_url'),
];
$parameter['signature'] = $this->epusdtSign($parameter, $this->payGateway->merchant_id);
$client = new Client([
'headers' => [ 'Content-Type' => 'application/json' ]
]);
$response = $client->post($this->payGateway->merchant_pem, ['body' => json_encode($parameter)]);
$body = json_decode($response->getBody()->getContents(), true);
if (!isset($body['status_code']) || $body['status_code'] != 200) {
return $this->err(__('dujiaoka.prompt.abnormal_payment_channel') . $body['message']);
}
return redirect()->away($body['data']['payment_url']);
} catch (RuleValidationException $exception) {
} catch (GuzzleException $exception) {
return $this->err($exception->getMessage());
}
}
private function epusdtSign(array $parameter, string $signKey)
{
ksort($parameter);
reset($parameter); //内部指针指向数组中的第一个元素
$sign = '';
$urls = '';
foreach ($parameter as $key => $val) {
if ($val == '') continue;
if ($key != 'signature') {
if ($sign != '') {
$sign .= "&";
$urls .= "&";
}
$sign .= "$key=$val"; //拼接为url参数形式
$urls .= "$key=" . urlencode($val); //拼接为url参数形式
}
}
$sign = md5($sign . $signKey);//密码追加进入开始MD5签名
return $sign;
}
public function notifyUrl(Request $request)
{
$data = $request->all();
$order = $this->orderService->detailOrderSN($data['order_id']);
if (!$order) {
return 'fail';
}
$payGateway = $this->payService->detail($order->pay_id);
if (!$payGateway) {
return 'fail';
}
$signature = $this->epusdtSign($data, $payGateway->merchant_id);
if ($data['signature'] != $signature) { //不合法的数据
return 'fail'; //返回失败 继续补单
} else {
//合法的数据
//业务处理
$this->orderProcessService->completedOrder($data['order_id'], $data['amount'], $data['trade_id']);
return 'ok';
}
}
public function returnUrl(Request $request)
{
$oid = $request->get('order_id');
// 异步通知还没到就跳转了,所以这里休眠2秒
sleep(2);
return redirect(url('detail-order-sn', ['orderSN' => $oid]));
}
}
-56
View File
@@ -1,56 +0,0 @@
<?php
/**
* The file was created by Assimon.
*
* @author assimon<ashang@utf8.hk>
* @copyright assimon<ashang@utf8.hk>
* @link http://utf8.hk/
*/
use Illuminate\Support\Facades\Route;
Route::get('pay-gateway/{handle}/{payway}/{orderSN}', 'PayController@redirectGateway');
// 支付相关
Route::group(['prefix' => 'pay', 'namespace' => 'Pay', 'middleware' => ['dujiaoka.pay_gate_way']], function () {
// 支付宝
Route::get('alipay/{payway}/{orderSN}', 'AlipayController@gateway');
Route::post('alipay/notify_url', 'AlipayController@notifyUrl');
// 微信
Route::get('wepay/{payway}/{orderSN}', 'WepayController@gateway');
Route::post('wepay/notify_url', 'WepayController@notifyUrl');
// 码支付
Route::get('mapay/{payway}/{orderSN}', 'MapayController@gateway');
Route::post('mapay/notify_url', 'MapayController@notifyUrl');
// Paysapi
Route::get('paysapi/{payway}/{orderSN}', 'PaysapiController@gateway');
Route::post('paysapi/notify_url', 'PaysapiController@notifyUrl');
Route::get('paysapi/return_url', 'PaysapiController@returnUrl')->name('paysapi-return');
// payjs
Route::get('payjs/{payway}/{orderSN}', 'PayjsController@gateway');
Route::post('payjs/notify_url', 'PayjsController@notifyUrl');
// 易支付
Route::get('yipay/{payway}/{orderSN}', 'YipayController@gateway');
Route::get('yipay/notify_url', 'YipayController@notifyUrl');
Route::get('yipay/return_url', 'YipayController@returnUrl')->name('yipay-return');
// paypal
Route::get('paypal/{payway}/{orderSN}', 'PaypalPayController@gateway');
Route::get('paypal/return_url', 'PaypalPayController@returnUrl')->name('paypal-return');
Route::any('paypal/notify_url', 'PaypalPayController@notifyUrl');
// V免签
Route::get('vpay/{payway}/{orderSN}', 'VpayController@gateway');
Route::get('vpay/notify_url', 'VpayController@notifyUrl');
Route::get('vpay/return_url', 'VpayController@returnUrl')->name('vpay-return');
// stripe
Route::get('stripe/{payway}/{oid}','StripeController@gateway');
Route::get('stripe/return_url','StripeController@returnUrl');
Route::get('stripe/check','StripeController@check');
Route::get('stripe/charge','StripeController@charge');
// Coinbase
Route::get('coinbase/{payway}/{orderSN}', 'CoinbaseController@gateway');
Route::post('coinbase/notify_url', 'CoinbaseController@notifyUrl');
// epusdt
Route::get('epusdt/{payway}/{orderSN}', 'EpusdtController@gateway');
Route::post('epusdt/notify_url', 'EpusdtController@notifyUrl');
Route::get('epusdt/return_url', 'EpusdtController@returnUrl')->name('epusdt-return');
});
-44
View File
@@ -1,44 +0,0 @@
-- auto-generated definition
create table orders
(
id int auto_increment
primary key,
trade_id varchar(32) not null comment 'epusdt订单号',
order_id varchar(32) not null comment '客户交易id',
block_transaction_id varchar(128) null comment '区块唯一编号',
actual_amount decimal(19, 4) not null comment '订单实际需要支付的金额,保留4位小数',
amount decimal(19, 4) not null comment '订单金额,保留4位小数',
token varchar(50) not null comment '所属钱包地址',
status int default 1 not null comment '1:等待支付,2:支付成功,3:已过期',
notify_url varchar(128) not null comment '异步回调地址',
redirect_url varchar(128) null comment '同步回调地址',
callback_num int default 0 null comment '回调次数',
callback_confirm int default 2 null comment '回调是否已确认? 1是 2否',
created_at timestamp null,
updated_at timestamp null,
deleted_at timestamp null,
constraint orders_order_id_uindex
unique (order_id),
constraint orders_trade_id_uindex
unique (trade_id)
);
create index orders_block_transaction_id_index
on orders (block_transaction_id);
-- auto-generated definition
create table wallet_address
(
id int auto_increment
primary key,
token varchar(50) not null comment '钱包token',
status int default 1 not null comment '1:启用 2:禁用',
created_at timestamp null,
updated_at timestamp null,
deleted_at timestamp null
)
comment '钱包表';
create index wallet_address_token_index
on wallet_address (token);
-21
View File
@@ -20,27 +20,6 @@ db_type=sqlite
sqlite_database_filename= sqlite_database_filename=
sqlite_table_prefix= sqlite_table_prefix=
# postgres config
postgres_host=127.0.0.1
postgres_port=3306
postgres_user=mysql_user
postgres_passwd=mysql_password
postgres_database=database_name
postgres_table_prefix=
postgres_max_idle_conns=10
postgres_max_open_conns=100
postgres_max_life_time=6
# mysql config
mysql_host=127.0.0.1
mysql_port=3306
mysql_user=mysql_user
mysql_passwd=mysql_password
mysql_database=database_name
mysql_table_prefix=
mysql_max_idle_conns=10
mysql_max_open_conns=100
mysql_max_life_time=6
# sqlite runtime store config # sqlite runtime store config
runtime_sqlite_filename=epusdt-runtime.db runtime_sqlite_filename=epusdt-runtime.db
+15 -16
View File
@@ -3,14 +3,14 @@ package bootstrap
import ( import (
"sync" "sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/mq" "github.com/GMWalletApp/epusdt/mq"
"github.com/assimon/luuu/task" "github.com/GMWalletApp/epusdt/task"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
) )
@@ -22,7 +22,7 @@ func InitApp() {
log.Init() log.Init()
dao.Init() dao.Init()
// Wire settings-table lookups into the config package so // Wire settings-table lookups into the config package so
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides. // GetRateApiUrl / GetUsdtRate can consult admin-configured values.
config.SettingsGetString = func(key string) string { config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "") return data.GetSettingString(key, "")
} }
@@ -36,20 +36,19 @@ func InitApp() {
} }
} }
} }
// config.Init() computes RateApiUrl before SettingsGetString is
// installed, so refresh the cache once DB-backed settings are available.
config.RateApiUrl = config.GetRateApiUrl()
// Seed admin account and JWT secret so the management console is // Seed admin account and JWT secret so the management console is
// immediately usable on a fresh install. Both are idempotent. // immediately usable on a fresh install. Both are idempotent.
adminPwd, isNew, err := data.EnsureDefaultAdmin() initialPassword, isNew, err := data.EnsureDefaultAdmin()
if err != nil { if err != nil {
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err) color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
} }
if isNew { if isNew {
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗") color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
color.Yellow.Println("║ Default admin account created. Change the password via admin console! ║") color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
color.Yellow.Printf("║ Username: admin ║\n") color.Yellow.Printf("║ Username: %-54s║\n", "admin")
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd) color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝") color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
} }
if _, err := appjwt.EnsureSecret(); err != nil { if _, err := appjwt.EnsureSecret(); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
package command package command
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/gookit/color" "github.com/gookit/color"
) )
+18 -8
View File
@@ -6,16 +6,17 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/assimon/luuu/bootstrap" "github.com/GMWalletApp/epusdt/bootstrap"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/install" "github.com/GMWalletApp/epusdt/install"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/route" "github.com/GMWalletApp/epusdt/route"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
luluHttp "github.com/assimon/luuu/util/http" luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -72,6 +73,15 @@ func HttpServerStart() {
} }
} }
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{ 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, HTML5: true,
Index: "index.html", Index: "index.html",
Root: wwwRoot, Root: wwwRoot,
+1 -1
View File
@@ -1,7 +1,7 @@
package command package command
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package command
import ( import (
"fmt" "fmt"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+105 -32
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/url" "net/url"
"os" "os"
@@ -11,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -34,9 +35,6 @@ var (
TgBotToken string TgBotToken string
TgProxy string TgProxy string
TgManage int64 TgManage int64
UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
BuildVersion = "0.0.0-dev" BuildVersion = "0.0.0-dev"
BuildCommit = "none" BuildCommit = "none"
BuildDate = "unknown" BuildDate = "unknown"
@@ -65,6 +63,9 @@ func Init() {
LogLevel = normalizeLogLevel(viper.GetString("log_level")) LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path")) StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/")) 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")) RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs")) LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath) mustMkdir(RuntimePath)
@@ -77,9 +78,6 @@ func Init() {
TgBotToken = viper.GetString("tg_bot_token") TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy") TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
} }
func mustMkdir(path string) { func mustMkdir(path string) {
@@ -88,6 +86,93 @@ func mustMkdir(path string) {
} }
} }
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 { func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) { switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error": case "debug", "info", "warn", "error":
@@ -277,13 +362,14 @@ func GetRateForCoin(coin string, base string) float64 {
if usdtRate > 0 { if usdtRate > 0 {
return 1 / usdtRate return 1 / usdtRate
} }
return 0
} }
} }
return getRateForCoinFromAPI(coin, base)
}
baseURL := RateApiUrl func getRateForCoinFromAPI(coin string, base string) float64 {
if baseURL == "" { baseURL := GetRateApiUrl()
baseURL = GetRateApiUrl()
}
if baseURL == "" { if baseURL == "" {
log.Printf("rate api url is empty") log.Printf("rate api url is empty")
return 0.0 return 0.0
@@ -315,19 +401,18 @@ func GetRateForCoin(coin string, base string) float64 {
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
// Prefer the DB-backed override (admin-configurable). Fall back to // Only the admin setting can force the USDT/CNY rate. When the
// the legacy .env var, then the hardcoded 6.4 default. // setting is unset, zero, or negative, fall back to the rate API.
if forced := settingsForcedUsdtRate(); forced > 0 { if forced := settingsForcedUsdtRate(); forced > 0 {
return forced return forced
} }
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 { apiRate := getRateForCoinFromAPI("usdt", "cny")
return forcedUsdtRate if apiRate > 0 {
return 1 / apiRate
} }
if UsdtRate <= 0 { log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
return 6.4 return 0
}
return UsdtRate
} }
func GetOrderExpirationTime() int { func GetOrderExpirationTime() int {
@@ -398,15 +483,3 @@ func GetCallbackRetryBaseDuration() time.Duration {
} }
return time.Duration(seconds) * time.Second return time.Duration(seconds) * time.Second
} }
func GetSolanaRpcUrl() string {
rpcUrl := viper.GetString("solana_rpc_url")
if rpcUrl == "" {
return "https://api.mainnet-beta.solana.com"
}
return rpcUrl
}
func GetEthereumWsUrl() string {
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
}
+219
View File
@@ -1,11 +1,52 @@
package config package config
import ( import (
"io"
"math"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "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) { func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
t.Helper() t.Helper()
@@ -135,3 +176,181 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
t.Fatalf("config path = %s, want %s", 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)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package config
// create a cycle via `model/dao -> config`. // create a cycle via `model/dao -> config`.
// //
// If unset (e.g. during early startup or tests) the getters return the // If unset (e.g. during early startup or tests) the getters return the
// zero string / 0 and callers fall through to the .env / default path. // zero string / 0 and callers apply their own fallback behavior.
// SettingsGetString is installed by bootstrap.Init with a closure that // SettingsGetString is installed by bootstrap.Init with a closure that
// reads from the settings table. Runtime-only. // reads from the settings table. Runtime-only.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+48 -6
View File
@@ -3,9 +3,9 @@ package admin
import ( import (
"errors" "errors"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -34,6 +34,12 @@ type MeResponse struct {
PasswordIsDefault bool `json:"password_is_default" example:"true"` 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. // Login verifies credentials, stamps last_login_at, returns a signed JWT.
// @Summary Admin login // @Summary Admin login
// @Description Verify credentials and return a signed JWT token // @Description Verify credentials and return a signed JWT token
@@ -77,6 +83,42 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
}) })
} }
// 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 // Logout is a no-op stub — tokens are stateless and the frontend
// discards them. Kept for API symmetry with the documented spec. // discards them. Kept for API symmetry with the documented spec.
// @Summary Admin logout // @Summary Admin logout
@@ -111,9 +153,9 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
if user.ID == 0 { if user.ID == 0 {
return c.FailJson(ctx, errors.New("user not found")) return c.FailJson(ctx, errors.New("user not found"))
} }
// Warn the frontend when the operator hasn't changed the default // Warn the frontend when the operator hasn't changed the seeded
// password so the UI can show a prominent reminder. // initial password.
isDefault := data.VerifyPassword(user.PasswordHash, "admin") isDefault := data.IsUsingInitialAdminPassword()
return c.SucJson(ctx, MeResponse{ return c.SucJson(ctx, MeResponse{
AdminUser: *user, AdminUser: *user,
PasswordIsDefault: isDefault, PasswordIsDefault: isDefault,
+2 -2
View File
@@ -1,8 +1,8 @@
package admin package admin
import ( import (
"github.com/assimon/luuu/controller" "github.com/GMWalletApp/epusdt/controller"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
package admin package admin
import ( import (
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -4,8 +4,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -6,10 +6,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/notify" "github.com/GMWalletApp/epusdt/notify"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/task" "github.com/GMWalletApp/epusdt/task"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+60 -16
View File
@@ -1,12 +1,14 @@
package admin package admin
import ( import (
"fmt"
"strconv"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/telegram" "github.com/GMWalletApp/epusdt/telegram"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -14,8 +16,8 @@ import (
// Supported groups and keys: // Supported groups and keys:
// //
// - group=rate: // - group=rate:
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api) // rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
// rate.api_url (string) — external 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.adjust_percent (float) — rate adjustment percentage
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed // rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
// //
@@ -24,17 +26,33 @@ import (
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (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) // 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: // - group=brand:
// brand.site_name (string) — site display name // brand.checkout_name (string) — cashier display name (preferred)
// brand.logo_url (string) — logo image URL // brand.logo_url (string) — logo image URL
// brand.page_title (string) — payment page title // brand.site_title (string) — payment page title / website title (preferred)
// brand.pay_success_text (string) — text shown on payment success // brand.success_copy (string) — text shown on payment success (preferred)
// brand.support_url (string) — support / help URL // brand.support_url (string) — support / help URL
// brand.background_color (string) — checkout background color
// brand.background_image_url (string) — checkout background image 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: // - group=system:
// system.order_expiration_time (int) — order expiry in minutes // system.order_expiration_time (int) — order expiry in minutes
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
type SettingUpsertItem struct { type SettingUpsertItem struct {
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"` Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
Key string `json:"key" example:"epay.default_network"` Key string `json:"key" example:"epay.default_network"`
Value string `json:"value" example:"tron"` Value string `json:"value" example:"tron"`
Type string `json:"type" enums:"string,int,bool,json" example:"string"` Type string `json:"type" enums:"string,int,bool,json" example:"string"`
@@ -48,12 +66,12 @@ type SettingsUpsertRequest struct {
// ListSettings returns all rows, optionally filtered by group. // ListSettings returns all rows, optionally filtered by group.
// @Summary List settings // @Summary List settings
// @Description Returns all settings, optionally filtered by group. // @Description Returns all settings, optionally filtered by group.
// @Description Available groups: brand, rate, system, epay. // @Description Available groups: brand, rate, system, epay, okpay.
// @Description See SettingUpsertItem for the full list of supported keys per group. // @Description See SettingUpsertItem for the full list of supported keys per group.
// @Tags Admin Settings // @Tags Admin Settings
// @Security AdminJWT // @Security AdminJWT
// @Produce json // @Produce json
// @Param group query string false "Group filter (brand|rate|system|epay)" // @Param group query string false "Group filter (brand|rate|system|epay|okpay)"
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting} // @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse
// @Router /admin/api/v1/settings [get] // @Router /admin/api/v1/settings [get]
@@ -71,11 +89,12 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// ones. Errors are returned per-key so the UI can surface them. // ones. Errors are returned per-key so the UI can surface them.
// @Summary Upsert settings // @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status. // @Description Batch insert/update settings. Returns per-key status.
// @Description Supported groups: brand, rate, system, epay. // @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 epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
// @Description rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @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 brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url. // @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 system group keys: system.order_expiration_time. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_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, system.amount_precision (int, 2-6, default 2).
// @Tags Admin Settings // @Tags Admin Settings
// @Security AdminJWT // @Security AdminJWT
// @Accept json // @Accept json
@@ -104,6 +123,14 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
out = append(out, result{Key: item.Key, OK: false, Error: "key required"}) out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
continue continue
} }
if err := validateSettingItem(item.Group, key, item.Value); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue
}
if key == mdb.SettingKeyAmountPrecision {
item.Group = mdb.SettingGroupSystem
item.Type = mdb.SettingTypeInt
}
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil { if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue continue
@@ -132,6 +159,23 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
return c.SucJson(ctx, out) return c.SucJson(ctx, out)
} }
func validateSettingItem(group, key, value string) error {
switch key {
case mdb.SettingKeyAmountPrecision:
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
return fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
}
precision, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("%s must be an integer", key)
}
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
return fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
}
}
return nil
}
// DeleteSetting removes one row. The next read of that key will fall // DeleteSetting removes one row. The next read of that key will fall
// back to the hardcoded default (see settings_data.GetSetting*). // back to the hardcoded default (see settings_data.GetSetting*).
// @Summary Delete setting // @Summary Delete setting
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+1 -1
View File
@@ -2,7 +2,7 @@ package controller
import ( import (
"errors" "errors"
"github.com/assimon/luuu/util/http" "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/validate" "github.com/gookit/validate"
"github.com/gookit/validate/locales/zhcn" "github.com/gookit/validate/locales/zhcn"
"github.com/gookit/validate/locales/zhtw" "github.com/gookit/validate/locales/zhtw"
+1 -1
View File
@@ -1,6 +1,6 @@
package comm package comm
import "github.com/assimon/luuu/controller" import "github.com/GMWalletApp/epusdt/controller"
var Ctrl = &BaseCommController{} 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, "=")
}
+10 -8
View File
@@ -4,12 +4,12 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/service" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -60,12 +60,14 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
// SwitchNetwork 切换支付网络,创建或返回子订单 // SwitchNetwork 切换支付网络,创建或返回子订单
// @Summary Switch payment network // @Summary Switch payment network
// @Description Switch to a different payment network, creating or returning a sub-order // @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 // @Tags Payment
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body request.SwitchNetworkRequest true "Switch network payload" // @Param request body request.SwitchNetworkRequest true "Switch network payload"
// @Success 200 {object} response.ApiResponse // @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse
// @Router /pay/switch-network [post] // @Router /pay/switch-network [post]
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
+8 -20
View File
@@ -1,24 +1,21 @@
package comm package comm
import ( import (
"html/template"
"net/http" "net/http"
"path/filepath"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/service"
"github.com/assimon/luuu/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// CheckoutCounter 收银台 // CheckoutCounter 收银台
// @Summary Checkout counter page // @Summary Checkout counter page
// @Description Render the payment checkout counter HTML page for a given trade // @Description Render the payment checkout counter JSON response for a given trade
// @Tags Payment // @Tags Payment
// @Produce html // @Produce json
// @Param trade_id path string true "Trade ID" // @Param trade_id path string true "Trade ID"
// @Success 200 {string} string "HTML page" // @Success 200 {object} response.CheckoutCounterResponse
// @Router /pay/checkout-counter/{trade_id} [get] // @Router /pay/checkout-counter-resp/{trade_id} [get]
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
tradeId := ctx.Param("trade_id") tradeId := ctx.Param("trade_id")
resp, err := service.GetCheckoutCounterByTradeId(tradeId) resp, err := service.GetCheckoutCounterByTradeId(tradeId)
@@ -26,22 +23,13 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
if err == service.ErrOrder { if err == service.ErrOrder {
// Unknown trade id: render the page with empty payload // Unknown trade id: render the page with empty payload
// (client side shows a friendly "order not found" screen). // (client side shows a friendly "order not found" screen).
tmpl, tmplErr := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if tmplErr != nil {
return ctx.String(http.StatusInternalServerError, tmplErr.Error())
}
ctx.Response().Status = http.StatusNotFound
emptyResp := response.CheckoutCounterResponse{} emptyResp := response.CheckoutCounterResponse{}
return tmpl.Execute(ctx.Response(), emptyResp) return c.SucJson(ctx, emptyResp)
} }
return ctx.String(http.StatusInternalServerError, err.Error()) return ctx.String(http.StatusInternalServerError, err.Error())
} }
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
if err != nil {
return ctx.String(http.StatusInternalServerError, err.Error())
}
return tmpl.Execute(ctx.Response(), resp) return c.SucJson(ctx, resp)
} }
// CheckStatus 支付状态检测 // CheckStatus 支付状态检测
@@ -5,38 +5,24 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台 func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
// 的 chains + chain_tokens + wallet_address 三张表的实时查询,admin
// 启用/禁用链或代币后,这个接口在下次调用时就会反映新状态(无缓存)。
// 仅返回:链 enabled=true、代币 enabled=true、且该链上至少有一个可用
// 钱包地址的组合。
// @Summary List supported (network, tokens) groupings
// @Description Public endpoint, Auth required in /admin/api/v1/supported-assets
// @Tags Supported Assets
// @Produce json
// @Success 200 {object} response.ApiResponse{data=response.SupportedAssetsResponse}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets [get]
// @Router /admin/api/v1/supported-assets [get]
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
chains, err := data.ListEnabledChains() chains, err := data.ListEnabledChains()
if err != nil { if err != nil {
return c.FailJson(ctx, err) return nil, err
} }
wallets, err := data.GetAvailableWalletAddress() wallets, err := data.GetAvailableWalletAddress()
if err != nil { if err != nil {
return c.FailJson(ctx, err) return nil, err
} }
// A chain only qualifies if it has at least one wallet available —
// without a recipient address, payments on that chain can't be
// processed even if the chain + token config says "enabled".
networkHasWallet := make(map[string]struct{}) networkHasWallet := make(map[string]struct{})
for _, w := range wallets { for _, w := range wallets {
networkHasWallet[strings.ToLower(w.Network)] = struct{}{} networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
@@ -50,7 +36,7 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
} }
tokens, err := data.ListEnabledChainTokensByNetwork(network) tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil { if err != nil {
return c.FailJson(ctx, err) return nil, err
} }
if len(tokens) == 0 { if len(tokens) == 0 {
continue continue
@@ -73,18 +59,71 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
}) })
} }
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports}) // ARC testnet tokens are hardcoded until the chain is formally added to
// the database. Show ARC in the list only if there is at least one active
// wallet address for the network.
arcWallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
if err == nil && len(arcWallets) > 0 {
supports = append(supports, response.NetworkTokenSupport{
Network: mdb.NetworkArc,
Tokens: []string{"EURC", "USDC"},
})
}
return supports, nil
} }
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。 // GetPublicConfig returns payment/site config consumed by cashier/frontends.
// @Summary List supported asset records // The public route returns brand/site display config plus non-sensitive
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network. // epay/okpay knobs, while the authenticated admin route also includes OkPay
// @Tags Supported Assets // 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 // @Produce json
// @Param network query string false "Network filter (e.g. tron, ethereum)" // @Success 200 {object} response.ApiResponse{data=response.PublicConfigResponse}
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse // @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets/records [get] // @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(),
BackgroundColor: data.GetBrandBackgroundColor(),
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
},
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 { func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
network := ctx.QueryParam("network") network := ctx.QueryParam("network")
list, err := data.ListEnabledChainTokens(network) list, err := data.ListEnabledChainTokens(network)
@@ -94,15 +133,8 @@ func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
return c.SucJson(ctx, list) return c.SucJson(ctx, list)
} }
// GetSupportedAsset 查询单条支持项(无需鉴权)。 // GetSupportedAsset is currently not wired to a public route.
// @Summary Get a supported asset // Kept as an internal helper candidate for future admin/debug use.
// @Description Public endpoint. Returns one chain_token row by ID. Returns 404 if the token is disabled.
// @Tags Supported Assets
// @Produce json
// @Param id path int true "ChainToken ID"
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
// @Failure 400 {object} response.ApiResponse
// @Router /payments/gmpay/v1/supported-assets/{id} [get]
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error { func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil { if err != nil {
+23
View File
@@ -1,7 +1,9 @@
package crypto package crypto
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"fmt"
"github.com/shengdoushi/base58" "github.com/shengdoushi/base58"
) )
@@ -28,3 +30,24 @@ func EncodeCheck(input []byte) string {
return Encode(inputCheck) return Encode(inputCheck)
} }
// DecodeCheck decodes a Base58Check string and validates its 4-byte checksum.
func DecodeCheck(input string) ([]byte, error) {
raw, err := base58.Decode(input, base58.BitcoinAlphabet)
if err != nil {
return nil, err
}
if len(raw) < 4 {
return nil, fmt.Errorf("base58check payload too short")
}
payload := raw[:len(raw)-4]
checksum := raw[len(raw)-4:]
h0 := sha256.Sum256(payload)
h1 := sha256.Sum256(h0[:])
if !bytes.Equal(checksum, h1[:4]) {
return nil, fmt.Errorf("base58check checksum mismatch")
}
return payload, nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/assimon/luuu module github.com/GMWalletApp/epusdt
go 1.25.0 go 1.25.0
+56 -28
View File
@@ -28,6 +28,7 @@ import (
"text/template" "text/template"
"time" "time"
luluHttp "github.com/GMWalletApp/epusdt/util/http"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@@ -78,6 +79,60 @@ type installHandler struct {
done chan struct{} 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. // GetDefaults returns default values for the install form.
// //
// @Summary Install — get default values // @Summary Install — get default values
@@ -171,34 +226,7 @@ func RunInstallServer(listenAddr, envFilePath string) {
listenAddr = DefaultInstallAddr listenAddr = DefaultInstallAddr
} }
h := &installHandler{ e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
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)
// 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(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Index: "index.html",
Root: wwwRoot,
}))
// Build a human-readable URL for the console hint. // Build a human-readable URL for the console hint.
installHost := listenAddr installHost := listenAddr
+49
View File
@@ -95,6 +95,55 @@ func TestInstallAPIDefaults(t *testing.T) {
} }
} }
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) { func TestInstallAPISubmit(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")
+24 -6
View File
@@ -4,10 +4,10 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
appLog "github.com/assimon/luuu/util/log" appLog "github.com/GMWalletApp/epusdt/util/log"
"github.com/libtnb/sqlite" "github.com/libtnb/sqlite"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
@@ -20,7 +20,6 @@ func SetupTestDatabases(t testing.TB) func() {
t.Helper() t.Helper()
viper.Reset() viper.Reset()
viper.Set("forced_usdt_rate", 1.0)
viper.Set("app_uri", "https://example.com") viper.Set("app_uri", "https://example.com")
viper.Set("order_expiration_time", 10) viper.Set("order_expiration_time", 10)
viper.Set("order_notice_max_retry", 2) viper.Set("order_notice_max_retry", 2)
@@ -31,7 +30,6 @@ func SetupTestDatabases(t testing.TB) func() {
config.HTTPAccessLog = false config.HTTPAccessLog = false
config.SQLDebug = false config.SQLDebug = false
config.LogLevel = "error" config.LogLevel = "error"
config.UsdtRate = 0
appLog.Sugar = zap.NewNop().Sugar() appLog.Sugar = zap.NewNop().Sugar()
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db")) mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
@@ -39,6 +37,7 @@ func SetupTestDatabases(t testing.TB) func() {
mustMigrate(t, mainDB, mustMigrate(t, mainDB,
&mdb.Orders{}, &mdb.Orders{},
&mdb.ProviderOrder{},
&mdb.WalletAddress{}, &mdb.WalletAddress{},
&mdb.ApiKey{}, &mdb.ApiKey{},
&mdb.Setting{}, &mdb.Setting{},
@@ -52,6 +51,16 @@ func SetupTestDatabases(t testing.TB) func() {
dao.Mdb = mainDB dao.Mdb = mainDB
dao.RuntimeDB = runtimeDB 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. // Seed all standard chains as enabled so IsChainEnabled checks pass.
for _, network := range []string{ for _, network := range []string{
@@ -74,12 +83,21 @@ func SetupTestDatabases(t testing.TB) func() {
Pid: "1001", SecretKey: "test-token", Pid: "1001", SecretKey: "test-token",
Status: mdb.ApiKeyStatusEnable, 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() { return func() {
closeDB(t, runtimeDB) closeDB(t, runtimeDB)
closeDB(t, mainDB) closeDB(t, mainDB)
dao.Mdb = nil dao.Mdb = nil
dao.RuntimeDB = nil dao.RuntimeDB = nil
config.SettingsGetString = nil
viper.Reset() viper.Reset()
} }
} }
+1 -10
View File
@@ -8,13 +8,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/command" "github.com/GMWalletApp/epusdt/command"
"github.com/gookit/color" "github.com/gookit/color"
) )
//go:embed all:static
var staticDir embed.FS
//go:embed all:www //go:embed all:www
var wwwDir embed.FS var wwwDir embed.FS
@@ -92,12 +89,6 @@ func releaseStatic(fs embed.FS, target string) (string, error) {
} }
func main() { func main() {
staticPath, err := releaseStatic(staticDir, "static")
if err != nil {
panic(err)
}
fmt.Println("static released to:", staticPath)
wwwwPath, err := releaseStatic(wwwDir, "www") wwwwPath, err := releaseStatic(wwwDir, "www")
if err != nil { if err != nil {
panic(err) panic(err)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
appjwt "github.com/assimon/luuu/util/jwt" appjwt "github.com/GMWalletApp/epusdt/util/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/json" "github.com/GMWalletApp/epusdt/util/json"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
+2 -2
View File
@@ -3,8 +3,8 @@ package dao
import ( import (
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"github.com/spf13/viper" "github.com/spf13/viper"
+13 -1
View File
@@ -6,7 +6,8 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color" "github.com/gookit/color"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -35,6 +36,7 @@ func MdbTableInit() {
{"Chain", &mdb.Chain{}}, {"Chain", &mdb.Chain{}},
{"ChainToken", &mdb.ChainToken{}}, {"ChainToken", &mdb.ChainToken{}},
{"RpcNode", &mdb.RpcNode{}}, {"RpcNode", &mdb.RpcNode{}},
{"ProviderOrder", &mdb.ProviderOrder{}},
} }
for _, m := range migrations { for _, m := range migrations {
if err := Mdb.AutoMigrate(m.model); err != nil { if err := Mdb.AutoMigrate(m.model); err != nil {
@@ -132,10 +134,20 @@ func seedRpcNodes() {
// seedDefaultSettings inserts built-in default settings. // seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts. // Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() { func seedDefaultSettings() {
okPayCallbackURL := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if okPayCallbackURL != "" {
okPayCallbackURL += "/payments/okpay/v1/notify"
}
defaults := []mdb.Setting{ defaults := []mdb.Setting{
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString}, {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.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", 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 { 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) color.Red.Printf("[store_db] seed default settings err=%s\n", err)
+6 -3
View File
@@ -1,9 +1,9 @@
package dao package dao
import ( import (
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/gookit/color" "github.com/gookit/color"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -44,6 +44,9 @@ func RuntimeInit() error {
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil { if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
return err return err
} }
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_network_address_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil { if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err) color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err return err
+217 -6
View File
@@ -2,16 +2,38 @@ package data
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"errors"
"fmt"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const defaultAdminUsername = "admin" 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 // EnsureDefaultAdmin seeds an initial admin account when no admin user
// exists. The password is randomly generated and returned so the caller // exists. The password is randomly generated and returned so the caller
@@ -35,7 +57,15 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
PasswordHash: hash, PasswordHash: hash,
Status: mdb.AdminUserStatusEnable, Status: mdb.AdminUserStatusEnable,
} }
return password, true, dao.Mdb.Create(user).Error 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 { func randomAdminPassword() string {
@@ -44,6 +74,141 @@ func randomAdminPassword() string {
return hex.EncodeToString(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. // HashPassword bcrypts a plaintext password.
func HashPassword(plain string) (string, error) { func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
@@ -80,9 +245,55 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
if err != nil { if err != nil {
return err return err
} }
return dao.Mdb.Model(&mdb.AdminUser{}). changedCacheValue := ""
Where("id = ?", id). err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
Update("password_hash", hash).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. // TouchAdminUserLastLogin stamps last_login_at to now.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
+5 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListChains returns all chain rows ordered by id. // ListChains returns all chain rows ordered by id.
@@ -36,6 +36,9 @@ func GetChainByNetwork(network string) (*mdb.Chain, error) {
// missing row is treated as disabled — scanners must be able to // missing row is treated as disabled — scanners must be able to
// short-circuit even before the admin creates the chain entry. // short-circuit even before the admin creates the chain entry.
func IsChainEnabled(network string) bool { func IsChainEnabled(network string) bool {
if strings.ToLower(strings.TrimSpace(network)) == mdb.NetworkArc {
return true
}
row, err := GetChainByNetwork(network) row, err := GetChainByNetwork(network)
if err != nil || row.ID == 0 { if err != nil || row.ID == 0 {
return false return false
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network. // ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// ListNotificationChannels returns all rows, optionally filtered by type. // ListNotificationChannels returns all rows, optionally filtered by type.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm" "gorm.io/gorm"
) )
+92 -33
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,9 +23,14 @@ type PendingCallbackOrder struct {
UpdatedAt carbon.Time `gorm:"column:updated_at"` UpdatedAt carbon.Time `gorm:"column:updated_at"`
} }
func normalizeAmount(amount float64, precision int) (int64, string) {
precision = NormalizeAmountPrecision(precision)
value := decimal.NewFromFloat(amount).Round(int32(precision))
return value.Shift(int32(precision)).IntPart(), value.StringFixed(int32(precision))
}
func normalizeLockAmount(amount float64) (int64, string) { func normalizeLockAmount(amount float64) (int64, string) {
value := decimal.NewFromFloat(amount).Round(2) return normalizeAmount(amount, GetAmountPrecision())
return value.Shift(2).IntPart(), value.StringFixed(2)
} }
func normalizeLockNetwork(network string) string { func normalizeLockNetwork(network string) string {
@@ -53,6 +58,30 @@ func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
return tx.Where("address = ?", address) return tx.Where("address = ?", address)
} }
func activeLocksForAddress(tx *gorm.DB, network, address, token string, now time.Time) *gorm.DB {
query := tx.Model(&mdb.TransactionLock{}).
Where("network = ?", normalizeLockNetwork(network)).
Where("token = ?", normalizeLockToken(token)).
Where("expires_at > ?", now)
return applyLockAddressFilter(query, network, address)
}
func lockMatchesAmount(lock mdb.TransactionLock, amount float64) bool {
precision := NormalizeAmountPrecision(lock.AmountPrecision)
scaledAmount, _ := normalizeAmount(amount, precision)
return lock.AmountScaled == scaledAmount
}
func lockAmountDecimal(lock mdb.TransactionLock) decimal.Decimal {
if strings.TrimSpace(lock.AmountText) != "" {
if value, err := decimal.NewFromString(lock.AmountText); err == nil {
return value
}
}
precision := NormalizeAmountPrecision(lock.AmountPrecision)
return decimal.NewFromInt(lock.AmountScaled).Shift(int32(-precision))
}
// GetOrderInfoByOrderId fetches an order by merchant order id. // GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
@@ -202,6 +231,18 @@ func MarkOrderSelected(tradeId string) error {
Update("is_selected", true).Error 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
}
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts. // RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
// Called on the parent order when a sub-order is created or returned. // Called on the parent order when a sub-order is created or returned.
func RefreshOrderExpiration(tradeId string) error { func RefreshOrderExpiration(tradeId string) error {
@@ -266,45 +307,43 @@ func ExpireOrderByTradeId(tradeId string) error {
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) { func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
network = normalizeLockNetwork(network) network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address) address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount) var locks []mdb.TransactionLock
var lock mdb.TransactionLock err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}). Order("created_at ASC").
Where("network = ?", network). Find(&locks).Error
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 { if err != nil {
return "", err return "", err
} }
if lock.ID <= 0 { for _, lock := range locks {
return "", nil if lockMatchesAmount(lock, amount) {
return lock.TradeId, nil
}
} }
return lock.TradeId, nil return "", nil
} }
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration. // 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 { func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
network = normalizeLockNetwork(network) network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address) address = normalizeLockAddress(network, address)
scaledAmount, amountText := normalizeLockAmount(amount) precision := GetAmountPrecision()
scaledAmount, amountText := normalizeAmount(amount, precision)
normalizedToken := normalizeLockToken(token) normalizedToken := normalizeLockToken(token)
now := time.Now() now := time.Now()
lock := &mdb.TransactionLock{ lock := &mdb.TransactionLock{
Network: network, Network: network,
Address: address, Address: address,
Token: normalizedToken, Token: normalizedToken,
AmountScaled: scaledAmount, AmountScaled: scaledAmount,
AmountText: amountText, AmountText: amountText,
TradeId: tradeID, AmountPrecision: precision,
ExpiresAt: now.Add(expirationTime), TradeId: tradeID,
ExpiresAt: now.Add(expirationTime),
} }
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
expiredQuery := tx.Where("network = ?", network). expiredQuery := tx.Where("network = ?", network).
Where("token = ?", normalizedToken). Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now) Where("expires_at <= ?", now)
expiredQuery = applyLockAddressFilter(expiredQuery, network, address) expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil { if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
@@ -314,6 +353,18 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
return err return err
} }
var existing []mdb.TransactionLock
if err := activeLocksForAddress(tx, network, address, normalizedToken, now).
Find(&existing).Error; err != nil {
return err
}
candidateAmount := lockAmountDecimal(*lock)
for _, item := range existing {
if lockAmountDecimal(item).Equal(candidateAmount) {
return ErrTransactionLocked
}
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock) result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
@@ -329,13 +380,21 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
func UnLockTransaction(network string, address string, token string, amount float64) error { func UnLockTransaction(network string, address string, token string, amount float64) error {
network = normalizeLockNetwork(network) network = normalizeLockNetwork(network)
address = normalizeLockAddress(network, address) address = normalizeLockAddress(network, address)
scaledAmount, _ := normalizeLockAmount(amount) var locks []mdb.TransactionLock
query := dao.RuntimeDB. if err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
Where("network = ?", network). Find(&locks).Error; err != nil {
Where("token = ?", normalizeLockToken(token)). return err
Where("amount_scaled = ?", scaledAmount) }
query = applyLockAddressFilter(query, network, address) ids := make([]uint64, 0, len(locks))
return query.Delete(&mdb.TransactionLock{}).Error for _, lock := range locks {
if lockMatchesAmount(lock, amount) {
ids = append(ids, lock.ID)
}
}
if len(ids) == 0 {
return nil
}
return dao.RuntimeDB.Where("id IN ?", ids).Delete(&mdb.TransactionLock{}).Error
} }
func UnLockTransactionByTradeId(tradeID string) error { func UnLockTransactionByTradeId(tradeID string) error {
+2 -2
View File
@@ -3,8 +3,8 @@ package data
import ( import (
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
// DailyStat is one bucket of day-level aggregation used by the // DailyStat is one bucket of day-level aggregation used by the
+48 -2
View File
@@ -1,12 +1,13 @@
package data package data
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) { func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
@@ -41,6 +42,51 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
} }
} }
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
t.Fatalf("set precision 2: %v", err)
}
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-old", 1.23, time.Hour); err != nil {
t.Fatalf("lock old transaction: %v", err)
}
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
t.Fatalf("set precision 4: %v", err)
}
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-equivalent", 1.2300, time.Hour); !errors.Is(err, ErrTransactionLocked) {
t.Fatalf("equivalent lock error = %v, want %v", err, ErrTransactionLocked)
}
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-new", 1.2301, time.Hour); err != nil {
t.Fatalf("distinct precision lock: %v", err)
}
}
func TestTransactionLockLookupUsesStoredPrecision(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
t.Fatalf("set precision 4: %v", err)
}
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress002", "USDT", "trade-precise", 1.2345, time.Hour); err != nil {
t.Fatalf("lock precise transaction: %v", err)
}
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
t.Fatalf("set precision 2: %v", err)
}
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, "TPrecisionAddress002", "USDT", 1.2345)
if err != nil {
t.Fatalf("lookup transaction lock: %v", err)
}
if gotTradeID != "trade-precise" {
t.Fatalf("trade id = %q, want trade-precise", gotTradeID)
}
}
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) { func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
+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
}
+14 -7
View File
@@ -4,8 +4,8 @@ import (
"math/rand" "math/rand"
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
) )
@@ -47,8 +47,8 @@ func DeleteRpcNodeByID(id uint64) error {
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair. // SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
// Strategy: weighted random among rows where enabled=true AND status=ok. // Strategy: weighted random among rows where enabled=true AND status=ok.
// Falls back to any enabled row when none are healthy yet (bootstrap case // Falls back to enabled rows with status=unknown when no health check has
// where the health check hasn't run). Returns nil if nothing is defined. // run yet. Explicitly down rows are not selected.
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) { func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
var rows []mdb.RpcNode var rows []mdb.RpcNode
err := dao.Mdb.Model(&mdb.RpcNode{}). err := dao.Mdb.Model(&mdb.RpcNode{}).
@@ -62,15 +62,22 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
if len(rows) == 0 { if len(rows) == 0 {
return nil, nil return nil, nil
} }
healthy := rows[:0] healthy := make([]mdb.RpcNode, 0, len(rows))
bootstrap := make([]mdb.RpcNode, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if r.Status == mdb.RpcNodeStatusOk { switch r.Status {
case mdb.RpcNodeStatusOk:
healthy = append(healthy, r) healthy = append(healthy, r)
case "", mdb.RpcNodeStatusUnknown:
bootstrap = append(bootstrap, r)
} }
} }
candidates := healthy candidates := healthy
if len(candidates) == 0 { if len(candidates) == 0 {
candidates = rows // bootstrap: no health check has run yet candidates = bootstrap
}
if len(candidates) == 0 {
return nil, nil
} }
return pickWeighted(candidates), nil return pickWeighted(candidates), nil
} }
+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)
}
}
+117 -2
View File
@@ -5,8 +5,9 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -118,6 +119,25 @@ func GetSettingBool(key string, fallback bool) bool {
return b return b
} }
const (
DefaultAmountPrecision = 2
MinAmountPrecision = 2
MaxAmountPrecision = 6
)
// NormalizeAmountPrecision clamps external precision values to the supported
// range used by order creation and transaction matching.
func NormalizeAmountPrecision(precision int) int {
if precision < MinAmountPrecision || precision > MaxAmountPrecision {
return DefaultAmountPrecision
}
return precision
}
func GetAmountPrecision() int {
return NormalizeAmountPrecision(GetSettingInt(mdb.SettingKeyAmountPrecision, DefaultAmountPrecision))
}
// SetSetting upserts a setting row and refreshes the cache entry. // SetSetting upserts a setting row and refreshes the cache entry.
func SetSetting(group, key, value, valueType string) error { func SetSetting(group, key, value, valueType string) error {
if valueType == "" { if valueType == "" {
@@ -151,6 +171,101 @@ func DeleteSetting(key string) error {
// sensitiveSettingKeys lists keys that must never be returned to API callers. // sensitiveSettingKeys lists keys that must never be returned to API callers.
var sensitiveSettingKeys = []string{ var sensitiveSettingKeys = []string{
mdb.SettingKeyJwtSecret, 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, ""))
}
func GetBrandBackgroundColor() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundColor, ""))
}
func GetBrandBackgroundImageURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundImageUrl, ""))
}
// 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), // ListSettingsByGroup returns all rows for a given group (empty group = all),
+4 -4
View File
@@ -3,9 +3,9 @@ package data
import ( import (
"strings" "strings"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
) )
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加) // AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
@@ -16,7 +16,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
// isEVMNetwork 判断是否是 EVM 网络 // isEVMNetwork 判断是否是 EVM 网络
func isEVMNetwork(network string) bool { func isEVMNetwork(network string) bool {
switch network { switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
return true return true
} }
return false return false
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
) )
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) { func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
+24 -1
View File
@@ -12,6 +12,19 @@ const (
PaymentTypeEpay = "Epay" 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 { type Orders struct {
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"` 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"` OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
@@ -33,7 +46,17 @@ type Orders struct {
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"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"` IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"` PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"` // 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. // 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). // 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"` PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
+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"
}
+37 -15
View File
@@ -1,9 +1,10 @@
package mdb package mdb
// Setting stores non-credential runtime configuration as key/value pairs. // Setting stores non-credential runtime configuration as key/value pairs.
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key) // Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
// live in the api_keys table; notification configs live in NotificationChannel. // secret_key) live in the api_keys table; notification configs live in
// Default rows for the epay group are seeded on first startup (see // 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 // model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
// empty and fall back to hardcoded defaults until an admin sets them. // empty and fall back to hardcoded defaults until an admin sets them.
// Only exception: system.jwt_secret is auto-generated on first startup. // Only exception: system.jwt_secret is auto-generated on first startup.
@@ -12,6 +13,7 @@ const (
SettingGroupRate = "rate" SettingGroupRate = "rate"
SettingGroupSystem = "system" SettingGroupSystem = "system"
SettingGroupEpay = "epay" SettingGroupEpay = "epay"
SettingGroupOkPay = "okpay"
) )
const ( const (
@@ -22,26 +24,46 @@ const (
) )
const ( const (
SettingKeyJwtSecret = "system.jwt_secret" SettingKeyJwtSecret = "system.jwt_secret"
SettingKeyOrderExpiration = "system.order_expiration_time" SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
SettingKeyBrandSiteName = "brand.site_name" SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
SettingKeyBrandLogoUrl = "brand.logo_url" SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
SettingKeyBrandPageTitle = "brand.page_title" SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
SettingKeyBrandPaySuccess = "brand.pay_success_text" SettingKeyOrderExpiration = "system.order_expiration_time"
SettingKeyBrandSupportUrl = "brand.support_url" SettingKeyAmountPrecision = "system.amount_precision"
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate" SettingKeyBrandCheckoutName = "brand.checkout_name"
SettingKeyRateAdjustPercent = "rate.adjust_percent" SettingKeyBrandSiteName = "brand.site_name"
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled" SettingKeyBrandLogoUrl = "brand.logo_url"
SettingKeyRateApiUrl = "rate.api_url" SettingKeyBrandSiteTitle = "brand.site_title"
SettingKeyBrandPageTitle = "brand.page_title"
SettingKeyBrandSuccessCopy = "brand.success_copy"
SettingKeyBrandPaySuccess = "brand.pay_success_text"
SettingKeyBrandSupportUrl = "brand.support_url"
SettingKeyBrandBackgroundColor = "brand.background_color"
SettingKeyBrandBackgroundImageUrl = "brand.background_image_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. // EPAY route defaults — can be overridden via admin settings.
SettingKeyEpayDefaultToken = "epay.default_token" SettingKeyEpayDefaultToken = "epay.default_token"
SettingKeyEpayDefaultCurrency = "epay.default_currency" SettingKeyEpayDefaultCurrency = "epay.default_currency"
SettingKeyEpayDefaultNetwork = "epay.default_network" 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 { type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"` 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"` 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"` 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"` Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
+11 -10
View File
@@ -3,16 +3,17 @@ package mdb
import "time" import "time"
type TransactionLock struct { type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` 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"` 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"` 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"` 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"` 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"` AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"` AmountPrecision int `gorm:"column:amount_precision;default:2;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:5" json:"amount_precision"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"` TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_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 { func (t *TransactionLock) TableName() string {
+1
View File
@@ -12,6 +12,7 @@ const (
NetworkBsc = "bsc" NetworkBsc = "bsc"
NetworkPolygon = "polygon" NetworkPolygon = "polygon"
NetworkPlasma = "plasma" NetworkPlasma = "plasma"
NetworkArc = "arc-testnet"
) )
const ( const (
+1 -1
View File
@@ -43,7 +43,7 @@ type OrderProcessingRequest struct {
type SwitchNetworkRequest struct { type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"` TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
Token string `json:"token" validate:"required" example:"USDT"` Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"ethereum"` Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
} }
func (r SwitchNetworkRequest) Translates() map[string]string { func (r SwitchNetworkRequest) Translates() map[string]string {
+25 -25
View File
@@ -2,41 +2,41 @@ package response
// CreateTransactionResponse 创建订单成功返回 // CreateTransactionResponse 创建订单成功返回
type CreateTransactionResponse struct { type CreateTransactionResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD...... Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址 PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
} }
// OrderNotifyResponse 订单异步回调结构体 // OrderNotifyResponse 订单异步回调结构体
type OrderNotifyResponse struct { type OrderNotifyResponse struct {
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签 Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key) Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
// 订单状态 1=等待支付 2=支付成功 3=已过期 // 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"2"` Status int `json:"status" enums:"1,2,3" example:"2"`
} }
// OrderNotifyResponseEpay epay订单异步回调结构体 // OrderNotifyResponseEpay epay订单异步回调结构体
type OrderNotifyResponseEpay struct { type OrderNotifyResponseEpay struct {
PID int `json:"pid" example:"1001"` // 商户ID PID int `json:"pid" example:"1001"` // 商户ID
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号 TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号 OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
Type string `json:"type" example:"usdt"` // 订单类型 Type string `json:"type" example:"usdt"` // 订单类型
Name string `json:"name" example:"VIP月卡"` // 商品名称 Name string `json:"name" example:"VIP月卡"` // 商品名称
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数 Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名 Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
SignType string `json:"sign_type" example:"MD5"` // 签名类型 SignType string `json:"sign_type" example:"MD5"` // 签名类型
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态 TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
} }
+9 -8
View File
@@ -1,16 +1,17 @@
package response package response
type CheckoutCounterResponse struct { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号 TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额 Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额 ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ... Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Network string `json:"network" example:"tron"` // 网络 TRON ETH ... Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳 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"` IsSelected bool `json:"is_selected" example:"false"`
} }
+31 -2
View File
@@ -5,6 +5,35 @@ type NetworkTokenSupport struct {
Tokens []string `json:"tokens" example:"USDT,USDC"` Tokens []string `json:"tokens" example:"USDT,USDC"`
} }
type SupportedAssetsResponse struct { type EpayPublicConfig struct {
Supports []NetworkTokenSupport `json:"supports"` 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"`
BackgroundColor string `json:"background_color" example:"#0f172a"`
BackgroundImageURL string `json:"background_image_url" example:"https://cdn.example.com/background.png"`
}
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"`
} }
+279
View File
@@ -0,0 +1,279 @@
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("%.*f", data.GetAmountPrecision(), 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)
}
precision := data.GetAmountPrecision()
if fmt.Sprintf("%.*f", precision, notifyAmount) != fmt.Sprintf("%.*f", precision, order.ActualAmount) {
return fmt.Errorf("okpay amount mismatch: got=%.*f want=%.*f", precision, notifyAmount, precision, 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
}
+167 -17
View File
@@ -8,15 +8,15 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -24,7 +24,6 @@ import (
const ( const (
CnyMinimumPaymentAmount = 0.01 CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01 UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100 IncrementalMaximumNumber = 100
) )
@@ -46,7 +45,7 @@ func normalizeOrderAddressByNetwork(network, address string) string {
network = strings.ToLower(strings.TrimSpace(network)) network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address) address = strings.TrimSpace(address)
switch network { switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
return strings.ToLower(address) return strings.ToLower(address)
default: default:
return address return address
@@ -61,7 +60,8 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
token := strings.ToUpper(strings.TrimSpace(req.Token)) token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency)) currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network)) network := strings.ToLower(strings.TrimSpace(req.Network))
payAmount := math.MustParsePrecFloat64(req.Amount, 2) amountPrecision := data.GetAmountPrecision()
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 { if rate <= 0 {
return nil, constant.RateAmountErr return nil, constant.RateAmountErr
@@ -96,7 +96,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
} }
tradeID := GenerateCode() tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), amountPrecision)
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress) availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -109,7 +109,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: tradeID, TradeId: tradeID,
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: payAmount,
Currency: currency, Currency: currency,
ActualAmount: availableAmount, ActualAmount: availableAmount,
ReceiveAddress: availableAddress, ReceiveAddress: availableAddress,
@@ -120,6 +120,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
RedirectUrl: req.RedirectUrl, RedirectUrl: req.RedirectUrl,
Name: req.Name, Name: req.Name,
PaymentType: req.PaymentType, PaymentType: req.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey), ApiKeyID: apiKeyID(apiKey),
} }
if err = data.CreateOrderWithTransaction(tx, order); err != nil { if err = data.CreateOrderWithTransaction(tx, order); err != nil {
@@ -199,6 +200,11 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil { if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err) 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 { 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) log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
} }
@@ -257,6 +263,11 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
// Release sibling locks after their status transitions commit. // Release sibling locks after their status transitions commit.
for _, sib := range siblings { 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 { 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) log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
} }
@@ -269,6 +280,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := "" availableAddress := ""
availableAmount := amount availableAmount := amount
amountPrecision := data.GetAmountPrecision()
tryLockWalletFunc := func(targetAmount float64) (string, error) { tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress { for _, address := range walletAddress {
@@ -292,8 +304,8 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
} }
if address == "" { if address == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) decimalIncr := decimal.New(1, int32(-amountPrecision))
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() availableAmount = math.MustParsePrecFloat64(decimalOldAmount.Add(decimalIncr).InexactFloat64(), amountPrecision)
continue continue
} }
availableAddress = address availableAddress = address
@@ -346,6 +358,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return nil, constant.OrderNotWaitPay return nil, constant.OrderNotWaitPay
} }
if network == mdb.PaymentProviderOkPay {
return switchToOkPay(parent, token)
}
// 2. Same token+network as parent → mark selected and return // 2. Same token+network as parent → mark selected and return
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) { if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
_ = data.MarkOrderSelected(parent.TradeId) _ = data.MarkOrderSelected(parent.TradeId)
@@ -399,7 +415,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
} }
subTradeID := GenerateCode() subTradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress) availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -427,6 +443,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
Name: parent.Name, Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType, PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
} }
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
@@ -458,7 +475,140 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Network: order.Network, Network: order.Network,
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl, RedirectUrl: order.RedirectUrl,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
CreatedAt: order.CreatedAt.TimestampMilli(), CreatedAt: order.CreatedAt.TimestampMilli(),
IsSelected: order.IsSelected, 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(), data.GetAmountPrecision())
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
}
+212 -6
View File
@@ -2,16 +2,21 @@ package service
import ( import (
"fmt" "fmt"
"io"
"net/http"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/util/constant" "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 { func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
@@ -25,6 +30,26 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
} }
} }
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) { func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -72,6 +97,112 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
} }
} }
func TestCreateTransactionUsesConfiguredAmountPrecision(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
t.Fatalf("set amount precision: %v", err)
}
if _, err := data.AddWalletAddress("wallet_precision_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_precision_1", 1), nil)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_precision_2", 1), nil)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
if got := fmt.Sprintf("%.4f", resp1.ActualAmount); got != "1.0000" {
t.Fatalf("first actual amount = %s, want 1.0000", got)
}
if got := fmt.Sprintf("%.4f", resp2.ActualAmount); got != "1.0001" {
t.Fatalf("second actual amount = %s, want 1.0001", got)
}
}
func TestCreateTransactionStoresNormalizedMerchantAmount(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_normalized_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_normalized_1", 100.129), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp.Amount); got != "100.13" {
t.Fatalf("response amount = %s, want 100.13", got)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("load order: %v", err)
}
if got := fmt.Sprintf("%.2f", order.Amount); got != "100.13" {
t.Fatalf("stored amount = %s, want 100.13", got)
}
}
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) { func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -534,6 +665,81 @@ func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
} }
} }
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 // TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as // sub-order is paid all sibling sub-orders are expired and their runtime locks (as
// well as the parent's lock) are released. // well as the parent's lock) are released.
+4 -4
View File
@@ -3,10 +3,10 @@ package service
import ( import (
"errors" "errors"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
) )
var ErrOrder = errors.New("不存在待支付订单或已过期") var ErrOrder = errors.New("不存在待支付订单或已过期")
+28 -11
View File
@@ -10,13 +10,13 @@ import (
"sync" "sync"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/math" "github.com/GMWalletApp/epusdt/util/math"
"github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -281,6 +281,21 @@ type solSignatureResult struct {
BlockTime *int64 `json:"blockTime"` 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 请求,自动重试 // SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
func SolRetryClient(method string, params []interface{}) ([]byte, error) { func SolRetryClient(method string, params []interface{}) ([]byte, error) {
client := resty.New() client := resty.New()
@@ -297,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
return false return false
}) })
rpcUrl := config.GetSolanaRpcUrl() rpcUrl, err := resolveSolanaRpcURL()
if err != nil {
return nil, err
}
resp, err := client.R(). resp, err := client.R().
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
@@ -453,7 +471,7 @@ const (
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
) )
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数 // ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留到系统支持的最大匹配精度
func ADJustAmount(amount uint64, decimals int) float64 { func ADJustAmount(amount uint64, decimals int) float64 {
if amount == 0 { if amount == 0 {
return 0 return 0
@@ -462,8 +480,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
// 10^decimals // 10^decimals
decimalDivisor := decimal.New(1, int32(decimals)) decimalDivisor := decimal.New(1, int32(decimals))
adjustedAmount := decimalAmount.Div(decimalDivisor) adjustedAmount := decimalAmount.Div(decimalDivisor)
// Round to 2 decimal places return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), data.MaxAmountPrecision)
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
} }
func MatchUsdtAtaAddress(address string, ataTo string) bool { func MatchUsdtAtaAddress(address string, ataTo string) bool {
+65 -13
View File
@@ -2,13 +2,59 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "os"
"testing" "testing"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/tidwall/gjson" "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) { func TestSolClientHealthy(t *testing.T) {
requireSolanaIntegration(t)
bodyData, err := SolRetryClient("getHealth", nil) bodyData, err := SolRetryClient("getHealth", nil)
if err != nil { if err != nil {
t.Fatalf("SolRetryClient failed: %v", err) t.Fatalf("SolRetryClient failed: %v", err)
@@ -33,6 +79,8 @@ func TestSolClientHealthy(t *testing.T) {
} }
func TestSolClientGetSignaturesForAddress(t *testing.T) { func TestSolClientGetSignaturesForAddress(t *testing.T) {
requireSolanaIntegration(t)
// Example wallet address (replace with actual test address) // Example wallet address (replace with actual test address)
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu" address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
@@ -57,6 +105,8 @@ func TestSolClientGetSignaturesForAddress(t *testing.T) {
} }
func TestSolClientGetTransaction(t *testing.T) { func TestSolClientGetTransaction(t *testing.T) {
requireSolanaIntegration(t)
// Example transaction signature (replace with actual test signature) // Example transaction signature (replace with actual test signature)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -64,7 +114,6 @@ func TestSolClientGetTransaction(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SolRetryClient failed: %v", err) t.Fatalf("SolRetryClient failed: %v", err)
} }
fmt.Printf("%v\n", string(txData))
var result map[string]interface{} var result map[string]interface{}
err = json.Unmarshal(txData, &result) err = json.Unmarshal(txData, &result)
@@ -80,6 +129,15 @@ func TestSolClientGetTransaction(t *testing.T) {
t.Logf("Transaction Info for signature %s: %v", sig, txInfo) 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) { func TestFindATAAddress(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -178,7 +236,7 @@ func TestAdjustAmount(t *testing.T) {
name: "USDT amount (6 decimals)", name: "USDT amount (6 decimals)",
amount: 123456789, amount: 123456789,
decimals: 6, decimals: 6,
want: 123.46, want: 123.456789,
}, },
{ {
name: "USDC amount (6 decimals)", name: "USDC amount (6 decimals)",
@@ -202,7 +260,7 @@ func TestAdjustAmount(t *testing.T) {
name: "Small amount", name: "Small amount",
amount: 1, amount: 1,
decimals: 6, decimals: 6,
want: 0.0, want: 0.000001,
}, },
} }
@@ -219,9 +277,7 @@ func TestAdjustAmount(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) { func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances) // SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN" sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
@@ -261,9 +317,7 @@ func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) { func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// SPL Token "transferChecked" (has mint and tokenAmount in instruction) // SPL Token "transferChecked" (has mint and tokenAmount in instruction)
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5" sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
@@ -306,9 +360,7 @@ func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
} }
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) { func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
if testing.Short() { requireSolanaIntegration(t)
t.Skip("Skipping integration test")
}
// System program SOL transfer // System program SOL transfer
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ" sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
+187 -310
View File
@@ -1,328 +1,176 @@
package service package service
import ( import (
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"net/http"
"strings" "strings"
"sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/model/data"
tron "github.com/assimon/luuu/crypto" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/request"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/notify"
"github.com/assimon/luuu/model/request" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/notify" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/math"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tidwall/gjson"
) )
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON. func resolveTronNode() (string, string, error) {
const tronNodeDefault = "https://api.trongrid.io"
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
// if none exists it falls back to the hard-coded default URL and the
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
func resolveTronNode() (string, string) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp) node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err == nil && node != nil && node.ID > 0 { if err != nil {
return strings.TrimRight(node.Url, "/"), node.ApiKey return "", "", err
} }
return tronNodeDefault, config.TRON_GRID_API_KEY if node == nil || node.ID == 0 {
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
}
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
if rpcURL == "" {
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
}
return rpcURL, node.ApiKey, nil
} }
func Trc20CallBack(address string, wg *sync.WaitGroup) { func ResolveTronNode() (string, string, error) {
defer wg.Done() return resolveTronNode()
}
func TryProcessTronTRC20Transfer(token mdb.ChainToken, toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
log.Sugar.Error(err) log.Sugar.Errorf("[TRC20-%s][%s] TryProcessTronTRC20Transfer panic: %v", tokenSym, toAddr, err)
} }
}() }()
var innerWg sync.WaitGroup addr := strings.TrimSpace(toAddr)
innerWg.Add(2) if tokenSym == "" || addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
go checkTrxTransfers(address, &innerWg) return
go checkTrc20Transfers(address, &innerWg) }
innerWg.Wait() decimals := token.Decimals
if decimals < 0 {
decimals = 0
}
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(decimals))).InexactFloat64(), data.MaxAmountPrecision)
if amount <= 0 {
return
}
if token.MinAmount > 0 && amount < token.MinAmount {
log.Sugar.Debugf("[TRC20-%s][%s] skip below min amount hash=%s amount=%.2f min=%.2f", tokenSym, addr, txHash, amount, token.MinAmount)
return
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, tokenSym, amount)
if err != nil {
log.Sugar.Warnf("[TRC20-%s][%s] lock lookup: %v", tokenSym, addr, err)
return
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20-%s][%s] skip unmatched tx hash=%s amount=%.2f", tokenSym, addr, txHash, amount)
return
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Warnf("[TRC20-%s][%s] load order: %v", tokenSym, addr, err)
return
}
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTron {
log.Sugar.Warnf("[TRC20-%s][%s] skip trade_id=%s network=%q", tokenSym, addr, tradeID, order.Network)
return
}
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
log.Sugar.Warnf("[TRC20-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, addr, tradeID, order.Token)
return
}
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[TRC20-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: addr,
Token: tokenSym,
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20-%s][%s] skip resolved transfer trade_id=%s hash=%s err=%v", tokenSym, addr, tradeID, txHash, err)
return
}
log.Sugar.Errorf("[TRC20-%s][%s] OrderProcessing trade_id=%s hash=%s: %v", tokenSym, addr, tradeID, txHash, err)
return
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRC20-%s][%s] payment processed trade_id=%s hash=%s", tokenSym, addr, tradeID, txHash)
} }
func checkTrxTransfers(address string, wg *sync.WaitGroup) { func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
defer wg.Done() defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
}
}()
// Native TRX is gated by a chain_tokens row with empty addr := strings.TrimSpace(toAddr)
// contract_address and symbol=TRX. Admin can disable this row to if addr == "" || rawSun <= 0 {
// stop scanning native transfers without touching the chain toggle. return
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX") }
decimalQuant := decimal.NewFromInt(rawSun)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), data.MaxAmountPrecision)
if amount <= 0 {
return
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err) log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
return return
} }
if trxCfg == nil || trxCfg.ID == 0 { if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address) log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return return
} }
trxDecimals := trxCfg.Decimals
if trxDecimals <= 0 {
trxDecimals = 6
}
client := http_client.GetHttpClient() order, err := data.GetOrderInfoByTradeId(tradeID)
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err) log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
return return
} }
if resp.StatusCode() != http.StatusOK { if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode()) log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return return
} }
success := gjson.GetBytes(resp.Body(), "success").Bool() req := &request.OrderProcessingRequest{
if !success { ReceiveAddress: addr,
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address) Token: "TRX",
return Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txHash,
} }
err = OrderProcessing(req)
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue
}
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue
}
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
}
if tron.EncodeCheck(toBytes) != address {
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
continue
}
if amount <= 0 {
continue
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
}
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
continue
}
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
}
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
// Build contract -> token map for the TRON network. If nothing is
// configured, skip — preserves the previous behavior of only watching
// admin-approved tokens.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err) if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
return log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
} return
if len(tokens) == 0 {
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
return
}
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
for i := range tokens {
c := strings.TrimSpace(tokens[i].ContractAddress)
if c == "" {
continue
} }
contractTokens[c] = &tokens[i] log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
}
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
return
}
if resp.StatusCode() != http.StatusOK {
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
return return
} }
success := gjson.GetBytes(resp.Body(), "success").Bool() sendPaymentNotification(order)
if !success { log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
return
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
if len(transfers) == 0 {
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
return
}
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
contractAddr := transfer.Get("token_info.address").String()
cfg, ok := contractTokens[contractAddr]
if !ok {
continue
}
if transfer.Get("to").String() != address {
continue
}
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
if tokenDecimals <= 0 {
tokenDecimals = int64(cfg.Decimals)
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
continue
}
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
continue
}
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: tokenSym,
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
sendPaymentNotification(order)
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
}
} }
func evmChainLogLabel(chainNetwork string) string { func evmChainLogLabel(chainNetwork string) string {
@@ -335,14 +183,13 @@ func evmChainLogLabel(chainNetwork string) string {
return "POLYGON" return "POLYGON"
case mdb.NetworkPlasma: case mdb.NetworkPlasma:
return "PLASMA" return "PLASMA"
case mdb.NetworkArc:
return "ARC-CHAIN"
default: default:
return "EVM" return "EVM"
} }
} }
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
@@ -351,41 +198,56 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}() }()
net := evmChainLogLabel(chainNetwork) net := evmChainLogLabel(chainNetwork)
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex()) tokenConfig, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
if err != nil { if err != nil {
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex()) log.Sugar.Warnf("[%s-WS] load chain token contract=%s: %v", net, contract.Hex(), err)
return return
} }
if token == nil || token.ID == 0 { if tokenConfig == nil || tokenConfig.ID == 0 {
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex()) log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
return return
} }
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol)) tryProcessConfiguredEvmERC20Transfer(chainNetwork, *tokenConfig, toAddr, rawValue, txHash, blockTsMs)
if tokenSym == "" { }
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
return
}
decimals := token.Decimals
if decimals <= 0 {
decimals = 6
}
func TryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[%s-WS] TryProcessConfiguredEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
}
}()
tryProcessConfiguredEvmERC20Transfer(chainNetwork, tokenConfig, toAddr, rawValue, txHash, blockTsMs)
}
func tryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
net := evmChainLogLabel(chainNetwork)
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
if tokenSym == "" {
log.Sugar.Warnf("[%s-WS] skip transfer with empty token symbol")
return
}
walletAddr := strings.ToLower(toAddr.Hex()) walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 { if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr) log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return return
} }
divisor := decimal.New(1, int32(decimals)) decimals := tokenConfig.Decimals
decimalQuant := decimal.NewFromBigInt(rawValue, 0) if decimals < 0 {
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2) decimals = 0
if token.MinAmount > 0 && amount < token.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
return
} }
pow := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), data.MaxAmountPrecision)
if amount <= 0 { if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount) log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return return
} }
if tokenConfig.MinAmount > 0 && amount < tokenConfig.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip below min amount hash=%s amount=%.2f min=%.2f", net, tokenSym, walletAddr, txHash, amount, tokenConfig.MinAmount)
return
}
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount) log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
@@ -413,8 +275,7 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
return return
} }
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() { if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d", log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return return
} }
@@ -441,11 +302,25 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
} }
func sendPaymentNotification(order *mdb.Orders) { func sendPaymentNotification(order *mdb.Orders) {
if order == nil {
return
}
if strings.TrimSpace(order.TradeId) != "" {
latest, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
} else if latest != nil && latest.TradeId != "" {
order = latest
}
}
precision := data.GetAmountPrecision()
amountFormat := fmt.Sprintf("%%.%df", precision)
msg := fmt.Sprintf( msg := fmt.Sprintf(
"🎉 <b>收款成功通知</b>\n\n"+ "🎉 <b>收款成功通知</b>\n\n"+
"💰 <b>金额信息</b>\n"+ "💰 <b>金额信息</b>\n"+
"├ 订单金额:<code>%.2f %s</code>\n"+ "├ 订单金额:<code>"+amountFormat+" %s</code>\n"+
"└ 实际到账:<code>%.2f %s</code>\n\n"+ "└ 实际到账:<code>"+amountFormat+" %s</code>\n\n"+
"📋 <b>订单信息</b>\n"+ "📋 <b>订单信息</b>\n"+
"├ 交易号:<code>%s</code>\n"+ "├ 交易号:<code>%s</code>\n"+
"├ 订单号:<code>%s</code>\n"+ "├ 订单号:<code>%s</code>\n"+
@@ -463,7 +338,7 @@ func sendPaymentNotification(order *mdb.Orders) {
networkDisplay(order.Network), networkDisplay(order.Network),
order.ReceiveAddress, order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(), order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(), order.UpdatedAt.ToDateTimeString(),
) )
notify.Dispatch(mdb.NotifyEventPaySuccess, msg) notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
} }
@@ -482,6 +357,8 @@ func networkDisplay(n string) string {
return "Polygon" return "Polygon"
case mdb.NetworkPlasma: case mdb.NetworkPlasma:
return "Plasma" return "Plasma"
case mdb.NetworkArc:
return "Arc-Chain"
default: default:
if n == "" { if n == "" {
return "Tron" return "Tron"
+146
View File
@@ -0,0 +1,146 @@
package service
import (
"math/big"
"strings"
"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/notify"
"github.com/ethereum/go-ethereum/common"
)
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const channelType = "test-pay-success-time"
got := make(chan string, 1)
notify.RegisterSender(channelType, func(config, text string) error {
got <- text
return nil
})
if err := dao.Mdb.Create(&mdb.NotificationChannel{
Type: channelType,
Name: "test",
Config: "{}",
Events: `{"pay_success":true}`,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed notification channel: %v", err)
}
order := &mdb.Orders{
TradeId: "T202604270001",
OrderId: "ORD202604270001",
Amount: 100,
Currency: "cny",
ActualAmount: 14.28,
Token: "USDT",
Network: mdb.NetworkTron,
ReceiveAddress: "TTestAddress",
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
const createdAt = "2026-04-27 09:00:00"
const staleUpdatedAt = "2026-04-27 09:01:00"
const paidAt = "2026-04-27 10:20:30"
if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
t.Fatalf("set initial timestamps: %v", err)
}
var staleOrderModel mdb.Orders
if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
t.Fatalf("load stale order: %v", err)
}
if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
t.Fatalf("set paid timestamp: %v", err)
}
sendPaymentNotification(&staleOrderModel)
select {
case text := <-got:
if !strings.Contains(text, "支付时间:"+paidAt) {
t.Fatalf("notification payment time = %q, want %s", text, paidAt)
}
if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
t.Fatalf("notification used stale payment time: %q", text)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for notification")
}
}
func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
tradeID = "T202605110001"
orderID = "ORD202605110001"
tokenSym = "FOO"
amount = 12.34
)
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
receiveAddress := common.HexToAddress("0x2222222222222222222222222222222222222222")
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkEthereum,
Symbol: tokenSym,
ContractAddress: contract.Hex(),
Decimals: 8,
Enabled: true,
}).Error; err != nil {
t.Fatalf("seed chain token: %v", err)
}
order := &mdb.Orders{
TradeId: tradeID,
OrderId: orderID,
Amount: 100,
Currency: "CNY",
ActualAmount: amount,
Token: tokenSym,
Network: mdb.NetworkEthereum,
ReceiveAddress: strings.ToLower(receiveAddress.Hex()),
Status: mdb.StatusWaitPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkEthereum, order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock transaction: %v", err)
}
rawValue := big.NewInt(1_234_000_000) // 12.34 with 8 decimals
TryProcessEvmERC20Transfer(mdb.NetworkEthereum, contract, receiveAddress, rawValue, "0xfoo-hash", time.Now().UnixMilli())
paid, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load paid order: %v", err)
}
if paid.Status != mdb.StatusPaySuccess {
t.Fatalf("order status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
}
if paid.BlockTransactionId != "0xfoo-hash" {
t.Fatalf("block transaction id = %q, want %q", paid.BlockTransactionId, "0xfoo-hash")
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, receiveAddress.Hex(), tokenSym, amount)
if err != nil {
t.Fatalf("lookup released lock: %v", err)
}
if lockTradeID != "" {
t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID)
}
}
+14 -27
View File
@@ -3,26 +3,19 @@ package service
import ( import (
"testing" "testing"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/mdb"
) )
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the // TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
// hard-coded default when the rpc_nodes table has no TRON row. // TRON row from rpc_nodes.
func TestResolveTronNode_NoRow(t *testing.T) { func TestResolveTronNode_NoRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key" if gotURL, gotKey, err := resolveTronNode(); err == nil {
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
} }
} }
@@ -32,8 +25,6 @@ func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
// Insert an enabled TRON http node. // Insert an enabled TRON http node.
node := &mdb.RpcNode{ node := &mdb.RpcNode{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
@@ -48,7 +39,10 @@ func TestResolveTronNode_WithRow(t *testing.T) {
t.Fatalf("seed rpc_node: %v", err) t.Fatalf("seed rpc_node: %v", err)
} }
gotURL, gotKey := resolveTronNode() gotURL, gotKey, err := resolveTronNode()
if err != nil {
t.Fatalf("resolveTronNode(): %v", err)
}
if gotURL != "https://custom-tron.example.com" { if gotURL != "https://custom-tron.example.com" {
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL) t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
} }
@@ -57,14 +51,11 @@ func TestResolveTronNode_WithRow(t *testing.T) {
} }
} }
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored and // TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
// the fallback is used.
func TestResolveTronNode_DisabledRow(t *testing.T) { func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
node := &mdb.RpcNode{ node := &mdb.RpcNode{
Network: mdb.NetworkTron, Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com", Url: "https://disabled-tron.example.com",
@@ -82,11 +73,7 @@ func TestResolveTronNode_DisabledRow(t *testing.T) {
t.Fatalf("disable rpc_node: %v", err) t.Fatalf("disable rpc_node: %v", err)
} }
gotURL, gotKey := resolveTronNode() if gotURL, gotKey, err := resolveTronNode(); err == nil {
if gotURL != tronNodeDefault { t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package mq
import ( import (
"sync" "sync"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
) )
var ( var (
+8 -8
View File
@@ -8,14 +8,14 @@ import (
"strings" "strings"
"time" "time"
"github.com/assimon/luuu/config" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/assimon/luuu/util/http_client" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
) )
// resolveOrderApiKey returns the api_keys row that signed the order. // resolveOrderApiKey returns the api_keys row that signed the order.
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/assimon/luuu/internal/testutil" "github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
) )
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) { func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"encoding/json" "encoding/json"
"sync" "sync"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/util/log"
) )
// Sender delivers an already-rendered text message via a channel row. // Sender delivers an already-rendered text message via a channel row.
+268 -4
View File
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -118,6 +118,14 @@ func doPutAdmin(e *echo.Echo, path string, body map[string]interface{}, token st
return rec return rec
} }
// doGet sends an unauthenticated GET request.
func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// assertOK asserts the response status is 200 and the status_code field is 200. // assertOK asserts the response status is 200 and the status_code field is 200.
func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { func assertOK(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
t.Helper() t.Helper()
@@ -273,6 +281,84 @@ func TestAdminChangePassword(t *testing.T) {
} }
} }
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
// and hash-based "password changed" detection flow.
func TestAdminInitPasswordFlow(t *testing.T) {
e := setupTestEnv(t)
const initPassword = "init-pass-123456"
adminHash, err := data.HashPassword(initPassword)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
dao.Mdb.Create(&mdb.AdminUser{
Username: testAdminUsername,
PasswordHash: adminHash,
Status: mdb.AdminUserStatusEnable,
})
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordPlain, initPassword, mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordHash, data.HashInitialAdminPassword(initPassword), mdb.SettingTypeString)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordFetched, "false", mdb.SettingTypeBool)
_ = data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyInitAdminPasswordChanged, "false", mdb.SettingTypeBool)
recHash := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash := assertOK(t, recHash)
hashData, _ := respHash["data"].(map[string]interface{})
if got := hashData["password_hash"]; got != data.HashInitialAdminPassword(initPassword) {
t.Fatalf("expected init hash %s, got %v", data.HashInitialAdminPassword(initPassword), got)
}
if got, _ := hashData["password_changed"].(bool); got {
t.Fatalf("expected password_changed=false before change, got true")
}
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
respFetch := assertOK(t, recFetch)
fetchData, _ := respFetch["data"].(map[string]interface{})
if fetchData["username"] != testAdminUsername {
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
}
if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
}
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest {
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
}
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
}
token := adminLogin(t, e, testAdminUsername, initPassword)
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe1 := assertOK(t, recMe1)
meData1, _ := respMe1["data"].(map[string]interface{})
if got, _ := meData1["password_is_default"].(bool); !got {
t.Fatalf("expected password_is_default=true before change, got %v", got)
}
recChange := doPostAdmin(e, "/admin/api/v1/auth/password", map[string]interface{}{
"old_password": initPassword,
"new_password": "new-pass-789",
}, token)
assertOK(t, recChange)
recHash2 := doGet(e, "/admin/api/v1/auth/init-password-hash")
respHash2 := assertOK(t, recHash2)
hashData2, _ := respHash2["data"].(map[string]interface{})
if got, _ := hashData2["password_changed"].(bool); !got {
t.Fatalf("expected password_changed=true after change, got %v", got)
}
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
respMe2 := assertOK(t, recMe2)
meData2, _ := respMe2["data"].(map[string]interface{})
if got, _ := meData2["password_is_default"].(bool); got {
t.Fatalf("expected password_is_default=false after change, got %v", got)
}
}
// ─── API Keys ──────────────────────────────────────────────────────────────── // ─── API Keys ────────────────────────────────────────────────────────────────
// TestAdminApiKeys_CRUD verifies list, create, update, status change, secret, // TestAdminApiKeys_CRUD verifies list, create, update, status change, secret,
@@ -596,10 +682,31 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
func TestAdminSettings_ListAndUpsert(t *testing.T) { func TestAdminSettings_ListAndUpsert(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-visible-in-settings", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
// List settings. // List settings.
rec := doGetAdmin(e, "/admin/api/v1/settings", token) rec := doGetAdmin(e, "/admin/api/v1/settings", token)
t.Logf("ListSettings: %s", rec.Body.String()) t.Logf("ListSettings: %s", rec.Body.String())
assertOK(t, rec) resp := assertOK(t, rec)
rows, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("expected settings array, got %T", resp["data"])
}
foundToken := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyOkPayShopToken {
foundToken = true
if item["value"] != "token-visible-in-settings" {
t.Fatalf("okpay.shop_token value = %v, want token-visible-in-settings", item["value"])
}
}
}
if !foundToken {
t.Fatalf("expected admin settings list to expose %s", mdb.SettingKeyOkPayShopToken)
}
// Upsert a setting. // Upsert a setting.
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
@@ -611,6 +718,63 @@ func TestAdminSettings_ListAndUpsert(t *testing.T) {
assertOK(t, rec) assertOK(t, rec)
} }
func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "4", "type": "int"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
}
result, _ := results[0].(map[string]interface{})
if result["ok"] != true {
t.Fatalf("amount precision upsert result = %v", result)
}
rec = doGetAdmin(e, "/admin/api/v1/settings?group=system", token)
resp = assertOK(t, rec)
rows, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("expected settings array, got %T", resp["data"])
}
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyAmountPrecision {
found = true
if item["value"] != "4" {
t.Fatalf("amount precision value = %v, want 4", item["value"])
}
}
}
if !found {
t.Fatalf("expected settings list to include %s", mdb.SettingKeyAmountPrecision)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "7", "type": "int"},
{"group": "system", "key": mdb.SettingKeyAmountPrecision, "value": "abc", "type": "int"},
},
}, token)
resp = assertOK(t, rec)
results, ok = resp["data"].([]interface{})
if !ok || len(results) != 2 {
t.Fatalf("expected two results, got %T %v", resp["data"], resp["data"])
}
for _, item := range results {
result, _ := item.(map[string]interface{})
if result["ok"] != false {
t.Fatalf("invalid amount precision result = %v, want ok=false", result)
}
}
}
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting. // TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
func TestAdminSettings_DeleteNonExistent(t *testing.T) { func TestAdminSettings_DeleteNonExistent(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -622,6 +786,106 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
} }
} }
func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) {
e, token := setupAdminTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.checkout_name: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-admin-1", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-admin-1", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/notify", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.callback_url: %v", err)
}
rec := doGetAdmin(e, "/admin/api/v1/config", token)
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
site, ok := dataObj["site"].(map[string]interface{})
if !ok {
t.Fatalf("expected site object, got %T", dataObj["site"])
}
if site["cashier_name"] != "admin cashier" {
t.Fatalf("cashier_name = %v, want admin cashier", site["cashier_name"])
}
okpay, ok := dataObj["okpay"].(map[string]interface{})
if !ok {
t.Fatalf("expected okpay object, got %T", dataObj["okpay"])
}
if okpay["shop_id"] != "shop-admin-1" {
t.Fatalf("shop_id = %v, want shop-admin-1", okpay["shop_id"])
}
if okpay["shop_token"] != "token-admin-1" {
t.Fatalf("shop_token = %v, want token-admin-1", okpay["shop_token"])
}
if okpay["callback_url"] != "https://example.com/notify" {
t.Fatalf("callback_url = %v", okpay["callback_url"])
}
}
func TestAdminSettings_UpsertBrandReflectsInConfig(t *testing.T) {
e, token := setupAdminTestEnv(t)
updateBrand := func(cashier, logo, title, support, bgColor, bgImage string) {
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandCheckoutName, "value": cashier, "type": mdb.SettingTypeString},
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandLogoUrl, "value": logo, "type": mdb.SettingTypeString},
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandSiteTitle, "value": title, "type": mdb.SettingTypeString},
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandSupportUrl, "value": support, "type": mdb.SettingTypeString},
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandBackgroundColor, "value": bgColor, "type": mdb.SettingTypeString},
{"group": mdb.SettingGroupBrand, "key": mdb.SettingKeyBrandBackgroundImageUrl, "value": bgImage, "type": mdb.SettingTypeString},
},
}, token)
assertOK(t, rec)
}
assertSite := func(path, wantCashier, wantLogo, wantTitle, wantSupport, wantBgColor, wantBgImage string) {
rec := doGet(e, path)
if strings.HasPrefix(path, "/admin/") {
rec = doGetAdmin(e, path, token)
}
resp := assertOK(t, rec)
dataObj, _ := resp["data"].(map[string]interface{})
site, ok := dataObj["site"].(map[string]interface{})
if !ok {
t.Fatalf("%s expected site object, got %T", path, dataObj["site"])
}
if site["cashier_name"] != wantCashier {
t.Fatalf("%s cashier_name=%v, want %s", path, site["cashier_name"], wantCashier)
}
if site["logo_url"] != wantLogo {
t.Fatalf("%s logo_url=%v, want %s", path, site["logo_url"], wantLogo)
}
if site["website_title"] != wantTitle {
t.Fatalf("%s website_title=%v, want %s", path, site["website_title"], wantTitle)
}
if site["support_link"] != wantSupport {
t.Fatalf("%s support_link=%v, want %s", path, site["support_link"], wantSupport)
}
if site["background_color"] != wantBgColor {
t.Fatalf("%s background_color=%v, want %s", path, site["background_color"], wantBgColor)
}
if site["background_image_url"] != wantBgImage {
t.Fatalf("%s background_image_url=%v, want %s", path, site["background_image_url"], wantBgImage)
}
}
updateBrand("cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
assertSite("/payments/gmpay/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
assertSite("/admin/api/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1", "#111111", "https://cdn.example.com/bg-v1.png")
updateBrand("cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
assertSite("/payments/gmpay/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
assertSite("/admin/api/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2", "#222222", "https://cdn.example.com/bg-v2.png")
}
// ─── Notification Channels ─────────────────────────────────────────────────── // ─── Notification Channels ───────────────────────────────────────────────────
// TestAdminNotificationChannels_CRUD verifies notification channel routes. // TestAdminNotificationChannels_CRUD verifies notification channel routes.
+25 -14
View File
@@ -9,25 +9,33 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/assimon/luuu/controller/admin" "github.com/GMWalletApp/epusdt/controller/admin"
"github.com/assimon/luuu/controller/comm" "github.com/GMWalletApp/epusdt/controller/comm"
"github.com/assimon/luuu/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/constant" "github.com/GMWalletApp/epusdt/util/constant"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/sign"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
) )
// RegisterRoute 路由注册 // RegisterRoute 路由注册
func RegisterRoute(e *echo.Echo) { func RegisterRoute(e *echo.Echo) {
e.Any("/", func(c echo.Context) error { e.POST("/", func(c echo.Context) error {
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt") return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
}) })
payRoute := e.Group("/pay") payRoute := e.Group("/pay")
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/checkout-counter/:trade_id", func(ctx echo.Context) error {
tradeId := ctx.Param("trade_id")
targetURL := fmt.Sprintf("/cashier/%s", tradeId)
return ctx.Redirect(http.StatusMovedPermanently, targetURL)
})
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
@@ -37,9 +45,11 @@ func RegisterRoute(e *echo.Echo) {
// gmpay v1 routes // gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1") gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign()) gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
// gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
// gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset) // okpay v1 routes
okpayV1 := paymentRoute.Group("/okpay/v1")
okpayV1.POST("/notify", comm.Ctrl.OkPayNotify)
// epay v1 routes // epay v1 routes
// //
@@ -188,6 +198,8 @@ func registerAdminRoutes(e *echo.Echo) {
// Public (no JWT) // Public (no JWT)
adminV1.POST("/auth/login", admin.Ctrl.Login) adminV1.POST("/auth/login", admin.Ctrl.Login)
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
// Authenticated // Authenticated
authed := adminV1.Group("", middleware.CheckAdminJWT()) authed := adminV1.Group("", middleware.CheckAdminJWT())
@@ -212,8 +224,7 @@ func registerAdminRoutes(e *echo.Echo) {
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus) authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel) authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) authed.GET("/config", comm.Ctrl.GetPublicConfig) // wrap for admin console, same payload as public endpoint
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
// Chains // Chains
authed.GET("/chains", admin.Ctrl.ListChains) authed.GET("/chains", admin.Ctrl.ListChains)
+659 -20
View File
@@ -1,8 +1,11 @@
package route package route
import ( import (
"crypto/md5"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -10,11 +13,14 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/assimon/luuu/model/dao" "github.com/GMWalletApp/epusdt/config"
"github.com/assimon/luuu/model/data" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/GMWalletApp/epusdt/model/data"
"github.com/assimon/luuu/util/log" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/assimon/luuu/util/sign" "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/go-resty/resty/v2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -33,7 +39,6 @@ func setupTestEnv(t *testing.T) *echo.Echo {
viper.Set("app_uri", "http://localhost:8080") viper.Set("app_uri", "http://localhost:8080")
viper.Set("order_expiration_time", 10) viper.Set("order_expiration_time", 10)
viper.Set("api_rate_url", "") viper.Set("api_rate_url", "")
viper.Set("forced_usdt_rate", 7.0)
viper.Set("runtime_root_path", tmpDir) viper.Set("runtime_root_path", tmpDir)
viper.Set("log_save_path", tmpDir) viper.Set("log_save_path", tmpDir)
viper.Set("sqlite_database_filename", tmpDir+"/test.db") viper.Set("sqlite_database_filename", tmpDir+"/test.db")
@@ -64,10 +69,20 @@ func setupTestEnv(t *testing.T) *echo.Echo {
&mdb.Chain{}, &mdb.Chain{},
&mdb.ChainToken{}, &mdb.ChainToken{},
&mdb.RpcNode{}, &mdb.RpcNode{},
&mdb.ProviderOrder{},
) )
// reset the settings cache so stale entries from a prior test don't leak // reset the settings cache so stale entries from a prior test don't leak
_ = data.ReloadSettings() _ = data.ReloadSettings()
config.SettingsGetString = func(key string) string {
return data.GetSettingString(key, "")
}
t.Cleanup(func() {
config.SettingsGetString = nil
})
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
}
// seed wallet addresses // seed wallet addresses
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable}) dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
@@ -127,6 +142,47 @@ func signBody(body map[string]interface{}) map[string]interface{} {
return body return body
} }
func signOkPayNotifyPayload(shopToken string, body map[string]interface{}) string {
data, _ := body["data"].(map[string]interface{})
ordered := []struct {
key string
value string
}{
{"code", stringifyOkPayTestValue(body["code"])},
{"data[order_id]", stringifyOkPayTestValue(data["order_id"])},
{"data[unique_id]", stringifyOkPayTestValue(data["unique_id"])},
{"data[pay_user_id]", stringifyOkPayTestValue(data["pay_user_id"])},
{"data[amount]", stringifyOkPayTestValue(data["amount"])},
{"data[coin]", stringifyOkPayTestValue(data["coin"])},
{"data[status]", stringifyOkPayTestValue(data["status"])},
{"data[type]", stringifyOkPayTestValue(data["type"])},
{"id", stringifyOkPayTestValue(body["id"])},
{"status", stringifyOkPayTestValue(body["status"])},
}
pairs := make([]string, 0, len(ordered))
for _, item := range ordered {
if item.value == "" {
continue
}
pairs = append(pairs, url.QueryEscape(item.key)+"="+url.QueryEscape(item.value))
}
query, _ := url.QueryUnescape(strings.Join(pairs, "&"))
sum := md5.Sum([]byte(query + "&token=" + shopToken))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func stringifyOkPayTestValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder { func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
jsonBytes, _ := json.Marshal(body) jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes))) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
@@ -158,6 +214,21 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
return rec return rec
} }
func TestRootPostRoute(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if body := rec.Body.String(); body != "hello epusdt, https://github.com/GMwalletApp/epusdt" {
t.Fatalf("unexpected body: %q", body)
}
}
// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network. // TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
func TestCreateOrderGmpayV1Solana(t *testing.T) { func TestCreateOrderGmpayV1Solana(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -287,11 +358,11 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
t.Logf("Form-data order created: trade_id=%v", data["trade_id"]) t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
} }
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/supported-assets // getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/config
// and returns a map of network → []token for easy assertions. // and returns a map of network → []token for easy assertions.
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string { func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil) req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/config", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.ServeHTTP(rec, req) e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -299,7 +370,7 @@ func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
} }
resp := parseResp(t, rec) resp := parseResp(t, rec)
rawData, _ := resp["data"].(map[string]interface{}) rawData, _ := resp["data"].(map[string]interface{})
rawSupports, _ := rawData["supports"].([]interface{}) rawSupports, _ := rawData["supported_assets"].([]interface{})
result := make(map[string][]string, len(rawSupports)) result := make(map[string][]string, len(rawSupports))
for _, item := range rawSupports { for _, item := range rawSupports {
row := item.(map[string]interface{}) row := item.(map[string]interface{})
@@ -316,7 +387,7 @@ func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
// TestGetSupportedAssets_ChainTokenToggle verifies that: // TestGetSupportedAssets_ChainTokenToggle verifies that:
// - disabling a chain_token removes that token (and possibly the whole network) // - disabling a chain_token removes that token (and possibly the whole network)
// from the /supported-assets response // from the /config supported_assets response
// - re-enabling it brings it back // - re-enabling it brings it back
func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) { func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -324,7 +395,7 @@ func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
// Baseline: tron should appear with USDT and TRX. // Baseline: tron should appear with USDT and TRX.
before := getSupportedNetworks(t, e) before := getSupportedNetworks(t, e)
if _, ok := before["tron"]; !ok { if _, ok := before["tron"]; !ok {
t.Fatal("expected tron in baseline supported-assets") t.Fatal("expected tron in baseline config.supported_assets")
} }
// Disable the tron USDT chain_token. // Disable the tron USDT chain_token.
@@ -348,7 +419,7 @@ func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
afterAllDisabled := getSupportedNetworks(t, e) afterAllDisabled := getSupportedNetworks(t, e)
if _, ok := afterAllDisabled["tron"]; ok { if _, ok := afterAllDisabled["tron"]; ok {
t.Fatal("tron should disappear from supported-assets when all its chain_tokens are disabled") t.Fatal("tron should disappear from config.supported_assets when all its chain_tokens are disabled")
} }
t.Logf("After disabling all tron tokens: networks = %v", afterAllDisabled) t.Logf("After disabling all tron tokens: networks = %v", afterAllDisabled)
@@ -376,7 +447,7 @@ func TestGetSupportedAssets_ChainTokenToggle(t *testing.T) {
// TestGetSupportedAssets_WalletAddressToggle verifies that: // TestGetSupportedAssets_WalletAddressToggle verifies that:
// - disabling ALL wallet addresses for a network removes that network from // - disabling ALL wallet addresses for a network removes that network from
// the /supported-assets response (even if chain + tokens are still enabled) // the /config supported_assets response (even if chain + tokens are still enabled)
// - re-enabling any address brings the network back // - re-enabling any address brings the network back
func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) { func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
@@ -384,7 +455,7 @@ func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
// Baseline: solana should be present. // Baseline: solana should be present.
before := getSupportedNetworks(t, e) before := getSupportedNetworks(t, e)
if _, ok := before["solana"]; !ok { if _, ok := before["solana"]; !ok {
t.Fatal("expected solana in baseline supported-assets") t.Fatal("expected solana in baseline config.supported_assets")
} }
// Disable the only solana wallet address. // Disable the only solana wallet address.
@@ -394,7 +465,7 @@ func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
after := getSupportedNetworks(t, e) after := getSupportedNetworks(t, e)
if _, ok := after["solana"]; ok { if _, ok := after["solana"]; ok {
t.Fatal("solana should disappear from supported-assets when all its wallet addresses are disabled") t.Fatal("solana should disappear from config.supported_assets when all its wallet addresses are disabled")
} }
t.Logf("After disabling solana wallets: networks = %v", after) t.Logf("After disabling solana wallets: networks = %v", after)
@@ -410,10 +481,28 @@ func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
t.Logf("After re-enabling solana wallets: solana tokens = %v", restored["solana"]) t.Logf("After re-enabling solana wallets: solana tokens = %v", restored["solana"])
} }
func TestGetSupportedAssetsPublic(t *testing.T) { func TestGetPublicConfig(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "asd", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.checkout_name: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandLogoUrl, "https://cdn.example.com/logo.png", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.logo_url: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSiteTitle, "asd title", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.site_title: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSupportUrl, "https://example.com/support", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.support_url: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandBackgroundColor, "#123456", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.background_color: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandBackgroundImageUrl, "https://cdn.example.com/bg.png", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.background_image_url: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil) req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/config", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.ServeHTTP(rec, req) e.ServeHTTP(rec, req)
@@ -426,17 +515,63 @@ func TestGetSupportedAssetsPublic(t *testing.T) {
t.Fatalf("expected status_code=200, got %v", resp["status_code"]) t.Fatalf("expected status_code=200, got %v", resp["status_code"])
} }
data, ok := resp["data"].(map[string]interface{}) respData, ok := resp["data"].(map[string]interface{})
if !ok { if !ok {
t.Fatalf("expected object data, got %T", resp["data"]) t.Fatalf("expected object data, got %T", resp["data"])
} }
supports, ok := data["supports"].([]interface{}) supports, ok := respData["supported_assets"].([]interface{})
if !ok { if !ok {
t.Fatalf("expected supports array, got %T", data["supports"]) t.Fatalf("expected supported_assets array, got %T", respData["supported_assets"])
} }
if len(supports) < 2 { if len(supports) < 2 {
t.Fatalf("expected >= 2 network supports, got %d", len(supports)) t.Fatalf("expected >= 2 network supports, got %d", len(supports))
} }
site, ok := respData["site"].(map[string]interface{})
if !ok {
t.Fatalf("expected site object, got %T", respData["site"])
}
if site["cashier_name"] != "asd" {
t.Fatalf("site.cashier_name = %v, want asd", site["cashier_name"])
}
if site["logo_url"] != "https://cdn.example.com/logo.png" {
t.Fatalf("site.logo_url = %v", site["logo_url"])
}
if site["website_title"] != "asd title" {
t.Fatalf("site.website_title = %v, want asd title", site["website_title"])
}
if site["support_link"] != "https://example.com/support" {
t.Fatalf("site.support_link = %v", site["support_link"])
}
if site["background_color"] != "#123456" {
t.Fatalf("site.background_color = %v, want #123456", site["background_color"])
}
if site["background_image_url"] != "https://cdn.example.com/bg.png" {
t.Fatalf("site.background_image_url = %v", site["background_image_url"])
}
epay, ok := respData["epay"].(map[string]interface{})
if !ok {
t.Fatalf("expected epay object, got %T", respData["epay"])
}
if epay["default_network"] != "tron" {
t.Fatalf("epay.default_network = %v, want tron", epay["default_network"])
}
okpay, ok := respData["okpay"].(map[string]interface{})
if !ok {
t.Fatalf("expected okpay object, got %T", respData["okpay"])
}
if okpay["enabled"] != false {
t.Fatalf("okpay.enabled = %v, want false", okpay["enabled"])
}
allowTokens, ok := okpay["allow_tokens"].([]interface{})
if !ok || len(allowTokens) != 2 {
t.Fatalf("expected okpay.allow_tokens array, got %T %#v", okpay["allow_tokens"], okpay["allow_tokens"])
}
if _, exists := okpay["shop_id"]; exists {
t.Fatalf("public config should not expose okpay.shop_id: %#v", okpay)
}
if _, exists := okpay["shop_token"]; exists {
t.Fatalf("public config should not expose okpay.shop_token: %#v", okpay)
}
seen := map[string]bool{} seen := map[string]bool{}
for _, item := range supports { for _, item := range supports {
@@ -449,6 +584,322 @@ func TestGetSupportedAssetsPublic(t *testing.T) {
t.Fatalf("missing network support: %s", n) t.Fatalf("missing network support: %s", n)
} }
} }
if got := data.GetOkPayCallbackURL(); got != "http://localhost:8080/payments/okpay/v1/notify" {
t.Fatalf("default okpay callback_url = %q, want %q", got, "http://localhost:8080/payments/okpay/v1/notify")
}
}
func TestGetPublicConfig_BrandLegacyFallback(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSiteName, "legacy cashier", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.site_name: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandPageTitle, "legacy title", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.page_title: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSupportUrl, "https://legacy.example.com/help", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.support_url: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/config", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected object data, got %T", resp["data"])
}
site, ok := respData["site"].(map[string]interface{})
if !ok {
t.Fatalf("expected site object, got %T", respData["site"])
}
if site["cashier_name"] != "legacy cashier" {
t.Fatalf("site.cashier_name = %v, want legacy cashier", site["cashier_name"])
}
if site["website_title"] != "legacy title" {
t.Fatalf("site.website_title = %v, want legacy title", site["website_title"])
}
if site["support_link"] != "https://legacy.example.com/help" {
t.Fatalf("site.support_link = %v", site["support_link"])
}
}
func TestOkPayNotifyRouteExists(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodPost, "/payments/okpay/v1/notify", strings.NewReader(""))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Fatalf("expected okpay notify route to exist, got 404")
}
}
type okPayNotifyFixture struct {
Parent *mdb.Orders
Child *mdb.Orders
ProviderRow *mdb.ProviderOrder
Payload map[string]interface{}
}
func seedOkPayNotifyFixture(t *testing.T) *okPayNotifyFixture {
t.Helper()
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "okpay-shop-test", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-1", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
parent := &mdb.Orders{
TradeId: "parent-okpay-notify-001",
OrderId: "merchant-okpay-notify-001",
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "http://localhost/notify",
PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(parent).Error; err != nil {
t.Fatalf("create parent order: %v", err)
}
child := &mdb.Orders{
TradeId: "okpay-unique-test-001",
ParentTradeId: parent.TradeId,
OrderId: "merchant-okpay-notify-001-sub",
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "",
PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(child).Error; err != nil {
t.Fatalf("create child order: %v", err)
}
providerRow := &mdb.ProviderOrder{
TradeId: child.TradeId,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "okpay-order-test-001",
PayURL: "https://t.me/ExampleWalletBot?start=shop_deposit--okpay-order-test-001",
Amount: 0.15,
Coin: "USDT",
Status: mdb.ProviderOrderStatusPending,
}
if err := dao.Mdb.Create(providerRow).Error; err != nil {
t.Fatalf("create provider row: %v", err)
}
payload := map[string]interface{}{
"code": 200,
"id": "okpay-shop-test",
"status": "success",
"data": map[string]interface{}{
"order_id": providerRow.ProviderOrderID,
"unique_id": child.TradeId,
"pay_user_id": 1234567890,
"amount": "0.15000000",
"coin": "USDT",
"status": 1,
"type": "deposit",
},
}
payload["sign"] = signOkPayNotifyPayload("token-1", payload)
return &okPayNotifyFixture{
Parent: parent,
Child: child,
ProviderRow: providerRow,
Payload: payload,
}
}
func buildOkPayNotifyFormValues(payload map[string]interface{}) url.Values {
values := url.Values{}
values.Set("code", stringifyOkPayTestValue(payload["code"]))
values.Set("id", stringifyOkPayTestValue(payload["id"]))
values.Set("status", stringifyOkPayTestValue(payload["status"]))
values.Set("sign", stringifyOkPayTestValue(payload["sign"]))
dataMap, _ := payload["data"].(map[string]interface{})
values.Set("data[order_id]", stringifyOkPayTestValue(dataMap["order_id"]))
values.Set("data[unique_id]", stringifyOkPayTestValue(dataMap["unique_id"]))
values.Set("data[pay_user_id]", stringifyOkPayTestValue(dataMap["pay_user_id"]))
values.Set("data[amount]", stringifyOkPayTestValue(dataMap["amount"]))
values.Set("data[coin]", stringifyOkPayTestValue(dataMap["coin"]))
values.Set("data[status]", stringifyOkPayTestValue(dataMap["status"]))
values.Set("data[type]", stringifyOkPayTestValue(dataMap["type"]))
return values
}
func assertOkPayNotifyProcessed(t *testing.T, fixture *okPayNotifyFixture) {
t.Helper()
paidChild, err := data.GetOrderInfoByTradeId(fixture.Child.TradeId)
if err != nil {
t.Fatalf("load paid child: %v", err)
}
if paidChild.Status != mdb.StatusPaySuccess {
t.Fatalf("child status = %d, want %d", paidChild.Status, mdb.StatusPaySuccess)
}
if paidChild.BlockTransactionId != fixture.ProviderRow.ProviderOrderID {
t.Fatalf("child block_transaction_id = %q, want %q", paidChild.BlockTransactionId, fixture.ProviderRow.ProviderOrderID)
}
paidParent, err := data.GetOrderInfoByTradeId(fixture.Parent.TradeId)
if err != nil {
t.Fatalf("load paid parent: %v", err)
}
if paidParent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", paidParent.Status, mdb.StatusPaySuccess)
}
if paidParent.PayBySubId != paidChild.ID {
t.Fatalf("parent pay_by_sub_id = %d, want %d", paidParent.PayBySubId, paidChild.ID)
}
savedProviderRow, err := data.GetProviderOrderByTradeIDAndProvider(fixture.Child.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("load provider row: %v", err)
}
if savedProviderRow.Status != mdb.ProviderOrderStatusPaid {
t.Fatalf("provider row status = %q, want %q", savedProviderRow.Status, mdb.ProviderOrderStatusPaid)
}
if savedProviderRow.NotifyRaw == "" {
t.Fatal("provider row notify_raw should be saved")
}
}
func TestOkPayNotify_JSONPayloadMarksOrdersPaid(t *testing.T) {
e := setupTestEnv(t)
fixture := seedOkPayNotifyFixture(t)
rawBody, err := json.Marshal(fixture.Payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/payments/okpay/v1/notify", strings.NewReader(string(rawBody)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("okpay notify failed: %d %s", rec.Code, rec.Body.String())
}
if strings.TrimSpace(rec.Body.String()) != "success" {
t.Fatalf("okpay notify response = %q, want success", rec.Body.String())
}
assertOkPayNotifyProcessed(t, fixture)
}
func TestOkPayNotify_FormPayloadMarksOrdersPaid(t *testing.T) {
e := setupTestEnv(t)
fixture := seedOkPayNotifyFixture(t)
formValues := buildOkPayNotifyFormValues(fixture.Payload)
req := httptest.NewRequest(http.MethodPost, "/payments/okpay/v1/notify", strings.NewReader(formValues.Encode()))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("okpay form notify failed: %d %s", rec.Code, rec.Body.String())
}
if strings.TrimSpace(rec.Body.String()) != "success" {
t.Fatalf("okpay form notify response = %q, want success", rec.Body.String())
}
assertOkPayNotifyProcessed(t, fixture)
}
func TestOkPayNotify_ExpiresSiblingOkPayProviderOrder(t *testing.T) {
e := setupTestEnv(t)
fixture := seedOkPayNotifyFixture(t)
sibling := &mdb.Orders{
TradeId: "okpay-unique-test-002",
ParentTradeId: fixture.Parent.TradeId,
OrderId: "merchant-okpay-notify-001-sibling",
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "",
PaymentType: mdb.PaymentTypeEpay,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(sibling).Error; err != nil {
t.Fatalf("create sibling order: %v", err)
}
siblingProvider := &mdb.ProviderOrder{
TradeId: sibling.TradeId,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "okpay-order-test-002",
PayURL: "https://t.me/ExampleWalletBot?start=shop_deposit--okpay-order-test-002",
Amount: 0.15,
Coin: "USDT",
Status: mdb.ProviderOrderStatusPending,
}
if err := dao.Mdb.Create(siblingProvider).Error; err != nil {
t.Fatalf("create sibling provider row: %v", err)
}
rawBody, err := json.Marshal(fixture.Payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/payments/okpay/v1/notify", strings.NewReader(string(rawBody)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("okpay notify failed: %d %s", rec.Code, rec.Body.String())
}
expiredSibling, err := data.GetOrderInfoByTradeId(sibling.TradeId)
if err != nil {
t.Fatalf("reload sibling order: %v", err)
}
if expiredSibling.Status != mdb.StatusExpired {
t.Fatalf("sibling status = %d, want %d", expiredSibling.Status, mdb.StatusExpired)
}
expiredSiblingProvider, err := data.GetProviderOrderByTradeIDAndProvider(sibling.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("reload sibling provider row: %v", err)
}
if expiredSiblingProvider.Status != mdb.ProviderOrderStatusExpired {
t.Fatalf("sibling provider status = %q, want %q", expiredSiblingProvider.Status, mdb.ProviderOrderStatusExpired)
}
} }
// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix. // TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
@@ -685,3 +1136,191 @@ func TestSwitchNetwork_WithOrder(t *testing.T) {
t.Fatal("expected data in switch-network response") t.Fatal("expected data in switch-network response")
} }
} }
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func stubRestyClient(fn roundTripFunc) *resty.Client {
return resty.NewWithClient(&http.Client{Transport: fn})
}
func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-1", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-1", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay/notify", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.callback_url: %v", err)
}
origFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
return stubRestyClient(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://api.okaypay.me/shop/payLink" {
t.Fatalf("unexpected okpay URL: %s", req.URL.String())
}
if err := req.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := req.Form.Get("coin"); got != "USDT" {
t.Fatalf("coin = %q, want USDT", got)
}
if req.Form.Get("callback_url") == "" {
t.Fatal("callback_url should be set")
}
body := `{"status":"success","code":200,"data":{"order_id":"okp-order-1","pay_url":"https://pay.okaypay.test/abc"}}`
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
}, nil
})
}
t.Cleanup(func() {
http_client.ClientFactory = origFactory
})
createBody := signBody(map[string]interface{}{
"order_id": "switch-okpay-parent-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
t.Fatalf("create parent order failed: %d %s", createRec.Code, createRec.Body.String())
}
createResp := parseResp(t, createRec)
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeID == "" {
t.Fatal("missing parent trade_id")
}
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "okpay",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch-network okpay failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
dataMap, _ := resp["data"].(map[string]interface{})
if dataMap["payment_url"] != "https://pay.okaypay.test/abc" {
t.Fatalf("payment_url = %v, want okpay url", dataMap["payment_url"])
}
if dataMap["network"] != "tron" {
t.Fatalf("network = %v, want tron", dataMap["network"])
}
if dataMap["receive_address"] != "OKPAY" {
t.Fatalf("receive_address = %v, want OKPAY", dataMap["receive_address"])
}
subTradeID, _ := dataMap["trade_id"].(string)
if subTradeID == "" || subTradeID == tradeID {
t.Fatalf("expected child trade_id, got %q", subTradeID)
}
subOrder, err := data.GetOrderInfoByTradeId(subTradeID)
if err != nil {
t.Fatalf("load sub-order: %v", err)
}
if subOrder.PayProvider != mdb.PaymentProviderOkPay {
t.Fatalf("pay_provider = %q, want %q", subOrder.PayProvider, mdb.PaymentProviderOkPay)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(subTradeID, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("load provider row: %v", err)
}
if providerRow.ProviderOrderID != "okp-order-1" {
t.Fatalf("provider_order_id = %q, want okp-order-1", providerRow.ProviderOrderID)
}
if providerRow.PayURL != "https://pay.okaypay.test/abc" {
t.Fatalf("pay_url = %q", providerRow.PayURL)
}
}
func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
if shopID == "" {
shopID = strings.TrimSpace(os.Getenv("OKPAY_ID"))
}
if shopID == "" {
shopID = strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_TEST_SHOP_ID"))
}
shopToken := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_TOKEN"))
if shopToken == "" {
shopToken = strings.TrimSpace(os.Getenv("OKPAY_TOKEN"))
}
if shopToken == "" {
shopToken = strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_TEST_SHOP_TOKEN"))
}
if shopID == "" || shopToken == "" {
t.Skip("set EPUSDT_OKPAY_ID/EPUSDT_OKPAY_TOKEN (or OKPAY_ID/OKPAY_TOKEN) to run OkPay integration test")
}
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay-notify", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.callback_url: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayReturnURL, "https://example.com/okpay-return", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.return_url: %v", err)
}
createBody := signBody(map[string]interface{}{
"order_id": "switch-okpay-live-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "solana",
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
t.Fatalf("create parent order failed: %d %s", createRec.Code, createRec.Body.String())
}
createResp := parseResp(t, createRec)
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeID == "" {
t.Fatal("missing parent trade_id")
}
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "okpay",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch-network okpay failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
dataMap, _ := resp["data"].(map[string]interface{})
paymentURL, _ := dataMap["payment_url"].(string)
if paymentURL == "" {
t.Fatalf("missing payment_url in response: %v", dataMap)
}
t.Logf("OkPay payment_url=%s", paymentURL)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

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