mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
38 Commits
v0.9.0
..
2d546e3478
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d546e3478 | |||
| 1a35cab919 | |||
| ff157c33d9 | |||
| 51ad344b36 | |||
| 3623a543bb | |||
| 377176a0f2 | |||
| a4022df7b4 | |||
| 32aa1b66f5 | |||
| 53e66bad35 | |||
| ff68279b0f | |||
| 9c533a7ff8 | |||
| 785a162f88 | |||
| 06933451c5 | |||
| d17d212c24 | |||
| 51a2accd9f | |||
| 96ea7481bc | |||
| de9c45a944 | |||
| 9e885d8b2f | |||
| 74c3a3a08c | |||
| 0a19854602 | |||
| 67263da0f1 | |||
| 95879e33d4 | |||
| 0c059e8a28 | |||
| 485059f60e | |||
| 143bc84ac3 | |||
| 06856e5caa | |||
| b9bde0f95b | |||
| 1b92ec1905 | |||
| 89be1fc967 | |||
| 91936d580f | |||
| 09514a6946 | |||
| b6cd4325b8 | |||
| 27d5e5bcfe | |||
| b625bac1e3 | |||
| bc5bbf72f7 | |||
| 3a3a830d93 | |||
| 59aa71209c | |||
| cdf274599e |
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 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">
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
</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>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,7 +42,7 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
|
|||||||
| **Polygon** | USDT、USDC |
|
| **Polygon** | USDT、USDC |
|
||||||
| **更多** | 持续扩展中… |
|
| **更多** | 持续扩展中… |
|
||||||
|
|
||||||
> 💡 具体支持的链与代币以 [最新版本](https://github.com/assimon/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
|
|||||||
|
|
||||||
## 💬 社区与支持
|
## 💬 社区与支持
|
||||||
|
|
||||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/assimon/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
||||||
|
|
||||||
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
||||||
|
|
||||||
@@ -153,9 +153,16 @@ Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ 免责声明
|
## ⚠️ 免责声明及使用条款
|
||||||
|
EPusdt 由 Good Morning Technology, LLC 以免费、开源、非盈利及非托管性质开发和披露,仅供学习、研究与技术交流使用。项目本身不构成投资、金融、法律、税务、合规或任何其他专业建议,也不应被视为对任何资产、交易结果、收益、资金安全、技术可用性或特定用途作出任何保证。
|
||||||
|
|
||||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
Good Morning Technology, LLC 为依据美国法律设立的主体,并将在适用法律法规范围内履行相应合规义务。就美国监管框架而言,虚拟货币、数字资产及相关技术服务是否构成 money transmission、money services business(MSB)或其他受监管活动,通常取决于具体业务模式,包括项目方是否接收、持有、控制或传输资金或价值,是否代表用户托管资产,以及是否从事兑换、支付、清算、结算或其他中介性金融服务。
|
||||||
|
|
||||||
|
作为免费、开源、非盈利、非托管的软件项目,EPusdt 的代码披露、文档说明、技术交流及相关开发活动,本身不应被理解为 Good Morning Technology, LLC 或其贡献者对任何用户后续使用、修改、部署、集成、分发或二次开发行为的授权、背书、控制、参与、保证或承诺。
|
||||||
|
|
||||||
|
用户对本项目的实际使用方式、使用目的及后续行为由其自行决定,Good Morning Technology, LLC 及其贡献者无法控制、审查或限制该等行为。用户应自行确保其使用、修改、部署、集成、分发或二次开发行为符合所在地适用法律法规、监管要求、制裁规则及第三方权利,并独立承担由此产生的全部风险、责任与后果。
|
||||||
|
|
||||||
|
加密资产属于高风险新兴资产类别,包括稳定币在内的数字资产均可能发生剧烈波动、脱锚、流动性不足、技术故障、监管变化或价值归零等风险。本项目所有代码、文档及相关材料均按“现状”和“可用状态”提供。除适用法律另有强制规定外,Good Morning Technology, LLC 及其贡献者不因用户使用、无法使用、错误使用、违法使用、修改、部署、集成、分发、二次开发或依赖本项目而产生的任何直接或间接损失承担责任。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
epusdt:
|
epusdt:
|
||||||
image: gmwallet/epusdt:alpine
|
image: gmwallet/epusdt:latest
|
||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* The file was created by Assimon.
|
* The file was created by Assimon.
|
||||||
*
|
*
|
||||||
* @author assimon<ashang@utf8.hk>
|
* @author GMWalletApp<ashang@utf8.hk>
|
||||||
* @copyright assimon<ashang@utf8.hk>
|
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||||
* @link http://utf8.hk/
|
* @link http://utf8.hk/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* The file was created by Assimon.
|
* The file was created by Assimon.
|
||||||
*
|
*
|
||||||
* @author assimon<ashang@utf8.hk>
|
* @author GMWalletApp<ashang@utf8.hk>
|
||||||
* @copyright assimon<ashang@utf8.hk>
|
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||||
* @link http://utf8.hk/
|
* @link http://utf8.hk/
|
||||||
*/
|
*/
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+12
-15
@@ -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,17 @@ 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()
|
_, 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. Fetch one-time password via API first!║")
|
||||||
color.Yellow.Printf("║ Username: admin ║\n")
|
color.Yellow.Printf("║ Username: admin ║\n")
|
||||||
color.Yellow.Printf("║ Password: %-60s ║\n", adminPwd)
|
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,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"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 +14,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 +24,30 @@ 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.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
|
||||||
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 +61,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,10 +84,11 @@ 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 brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
||||||
// @Description system group keys: system.order_expiration_time.
|
// @Description system group keys: system.order_expiration_time.
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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{}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "=")
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,58 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
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(),
|
||||||
|
},
|
||||||
|
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 +120,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 {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
module github.com/assimon/luuu
|
module github.com/GMWalletApp/epusdt
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
|||||||
+56
-28
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 @@ 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.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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -202,6 +202,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,6 +152,93 @@ 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, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
|||||||
@@ -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 添加)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+34
-15
@@ -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,43 @@ 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"
|
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
SettingKeyBrandSiteName = "brand.site_name"
|
||||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
SettingKeyBrandSiteTitle = "brand.site_title"
|
||||||
SettingKeyRateApiUrl = "rate.api_url"
|
SettingKeyBrandPageTitle = "brand.page_title"
|
||||||
|
SettingKeyBrandSuccessCopy = "brand.success_copy"
|
||||||
|
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||||
|
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||||
|
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||||
|
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||||
|
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||||
|
SettingKeyRateApiUrl = "rate.api_url"
|
||||||
|
|
||||||
// EPAY route defaults — can be overridden via admin settings.
|
// 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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"` // 订单金额,保留4位小数 法币金额
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type okPayCreateDepositResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
PayURL string `json:"pay_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okPayDepositOrder struct {
|
||||||
|
ProviderOrderID string
|
||||||
|
PayURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type okPayNotifyPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
UniqueID string `json:"unique_id"`
|
||||||
|
PayUserID string `json:"pay_user_id"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Coin string `json:"coin"`
|
||||||
|
PayStatus string `json:"pay_status"`
|
||||||
|
NotifyType string `json:"notify_type"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
RawFormData string `json:"raw_form_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPaySign(form map[string]string, shopID string, shopToken string) map[string]string {
|
||||||
|
values := url.Values{}
|
||||||
|
for key, value := range form {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values.Set(key, value)
|
||||||
|
}
|
||||||
|
values.Set("id", shopID)
|
||||||
|
query, _ := url.QueryUnescape(values.Encode())
|
||||||
|
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||||
|
|
||||||
|
signed := make(map[string]string, len(values)+1)
|
||||||
|
for key, vals := range values {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signed[key] = vals[0]
|
||||||
|
}
|
||||||
|
signed["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOkPayDepositOrder(uniqueID string, amount float64, coin string, returnURL string) (*okPayDepositOrder, error) {
|
||||||
|
shopID := data.GetOkPayShopID()
|
||||||
|
shopToken := data.GetOkPayShopToken()
|
||||||
|
apiURL := strings.TrimSpace(data.GetOkPayAPIURL())
|
||||||
|
callbackURL := data.GetOkPayCallbackURL()
|
||||||
|
if shopID == "" || shopToken == "" || apiURL == "" || callbackURL == "" {
|
||||||
|
return nil, fmt.Errorf("okpay config incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
form := map[string]string{
|
||||||
|
"unique_id": uniqueID,
|
||||||
|
"name": uniqueID,
|
||||||
|
"amount": fmt.Sprintf("%.2f", amount),
|
||||||
|
"coin": strings.ToUpper(strings.TrimSpace(coin)),
|
||||||
|
"callback_url": callbackURL,
|
||||||
|
"return_url": returnURL,
|
||||||
|
}
|
||||||
|
form = okPaySign(form, shopID, shopToken)
|
||||||
|
|
||||||
|
base, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base.Path = path.Join(base.Path, "payLink")
|
||||||
|
|
||||||
|
client := http_client.GetHttpClient()
|
||||||
|
client.SetTimeout(time.Duration(data.GetOkPayTimeoutSeconds()) * time.Second)
|
||||||
|
|
||||||
|
var payload okPayCreateDepositResponse
|
||||||
|
resp, err := client.R().
|
||||||
|
SetFormData(form).
|
||||||
|
SetResult(&payload).
|
||||||
|
Post(base.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[okpay] create order request failed unique_id=%s api_url=%s callback_url=%s return_url=%s err=%v", uniqueID, base.String(), callbackURL, returnURL, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() >= 400 {
|
||||||
|
log.Sugar.Warnf("[okpay] create order http error unique_id=%s status=%s api_url=%s callback_url=%s return_url=%s body=%s", uniqueID, resp.Status(), base.String(), callbackURL, returnURL, resp.String())
|
||||||
|
return nil, fmt.Errorf("okpay http status: %s", resp.Status())
|
||||||
|
}
|
||||||
|
if payload.Data.OrderID == "" || payload.Data.PayURL == "" {
|
||||||
|
log.Sugar.Warnf("[okpay] create order rejected unique_id=%s api_url=%s callback_url=%s return_url=%s status=%s code=%d msg=%s", uniqueID, base.String(), callbackURL, returnURL, payload.Status, payload.Code, payload.Msg)
|
||||||
|
if payload.Msg != "" {
|
||||||
|
return nil, fmt.Errorf("okpay create order failed: %s (callback_url=%s)", payload.Msg, callbackURL)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("okpay create order failed (callback_url=%s)", callbackURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &okPayDepositOrder{
|
||||||
|
ProviderOrderID: payload.Data.OrderID,
|
||||||
|
PayURL: payload.Data.PayURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyOkPayNotify(form map[string]string) bool {
|
||||||
|
shopID := data.GetOkPayShopID()
|
||||||
|
shopToken := data.GetOkPayShopToken()
|
||||||
|
signature := strings.TrimSpace(form["sign"])
|
||||||
|
if shopID == "" || shopToken == "" || signature == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected := okPayNotifySign(form, shopToken); expected != "" && strings.EqualFold(expected, signature) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned := make(map[string]string, len(form))
|
||||||
|
for key, value := range form {
|
||||||
|
if strings.EqualFold(key, "sign") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unsigned[key] = value
|
||||||
|
}
|
||||||
|
signed := okPaySign(unsigned, shopID, shopToken)
|
||||||
|
return strings.EqualFold(strings.TrimSpace(signed["sign"]), signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPayNotifySign(form map[string]string, shopToken string) string {
|
||||||
|
orderedKeys := []string{
|
||||||
|
"code",
|
||||||
|
"data[order_id]",
|
||||||
|
"data[unique_id]",
|
||||||
|
"data[pay_user_id]",
|
||||||
|
"data[amount]",
|
||||||
|
"data[coin]",
|
||||||
|
"data[status]",
|
||||||
|
"data[type]",
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs := make([]string, 0, len(orderedKeys))
|
||||||
|
for _, key := range orderedKeys {
|
||||||
|
value := strings.TrimSpace(form[key])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
query, err := url.QueryUnescape(strings.Join(pairs, "&"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||||
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOkPayNotify(form map[string]string, rawFormData string) *okPayNotifyPayload {
|
||||||
|
return &okPayNotifyPayload{
|
||||||
|
ID: strings.TrimSpace(form["id"]),
|
||||||
|
Status: strings.TrimSpace(form["status"]),
|
||||||
|
Code: strings.TrimSpace(form["code"]),
|
||||||
|
OrderID: strings.TrimSpace(form["data[order_id]"]),
|
||||||
|
UniqueID: strings.TrimSpace(form["data[unique_id]"]),
|
||||||
|
PayUserID: strings.TrimSpace(form["data[pay_user_id]"]),
|
||||||
|
Amount: strings.TrimSpace(form["data[amount]"]),
|
||||||
|
Coin: strings.TrimSpace(form["data[coin]"]),
|
||||||
|
PayStatus: strings.TrimSpace(form["data[status]"]),
|
||||||
|
NotifyType: strings.TrimSpace(form["data[type]"]),
|
||||||
|
Sign: strings.TrimSpace(form["sign"]),
|
||||||
|
RawFormData: rawFormData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
||||||
|
if !verifyOkPayNotify(form) {
|
||||||
|
return fmt.Errorf("invalid okpay notify sign")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := parseOkPayNotify(form, rawFormData)
|
||||||
|
|
||||||
|
if payload.ID == "" || payload.ID != data.GetOkPayShopID() {
|
||||||
|
return fmt.Errorf("okpay notify shop id mismatch: %s", payload.ID)
|
||||||
|
}
|
||||||
|
if payload.UniqueID == "" || payload.OrderID == "" {
|
||||||
|
return fmt.Errorf("missing okpay notify identifiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(payload.UniqueID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.ID <= 0 {
|
||||||
|
return fmt.Errorf("okpay notify order not found: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
if order.PayProvider != "okpay" {
|
||||||
|
return fmt.Errorf("okpay notify order provider mismatch: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(payload.UniqueID, order.PayProvider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if providerRow.ID <= 0 {
|
||||||
|
return fmt.Errorf("okpay provider order not found: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = data.SaveProviderOrderNotify(payload.UniqueID, order.PayProvider, payload.RawFormData)
|
||||||
|
|
||||||
|
if !strings.EqualFold(payload.Status, "success") || !strings.EqualFold(payload.NotifyType, "deposit") || payload.PayStatus != "1" {
|
||||||
|
log.Sugar.Infof("[okpay] notify ignored trade_id=%s status=%s pay_status=%s type=%s", payload.UniqueID, payload.Status, payload.PayStatus, payload.NotifyType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerRow.ProviderOrderID != "" && providerRow.ProviderOrderID != payload.OrderID {
|
||||||
|
return fmt.Errorf("okpay provider order id mismatch: got=%s want=%s", payload.OrderID, providerRow.ProviderOrderID)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(order.Token), strings.TrimSpace(payload.Coin)) {
|
||||||
|
return fmt.Errorf("okpay coin mismatch: got=%s want=%s", payload.Coin, order.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAmount, err := strconv.ParseFloat(payload.Amount, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid okpay amount: %w", err)
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%.2f", notifyAmount) != fmt.Sprintf("%.2f", order.ActualAmount) {
|
||||||
|
return fmt.Errorf("okpay amount mismatch: got=%.2f want=%.2f", notifyAmount, order.ActualAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Currency: order.Currency,
|
||||||
|
Token: order.Token,
|
||||||
|
Network: order.Network,
|
||||||
|
Amount: order.ActualAmount,
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
BlockTransactionId: payload.OrderID,
|
||||||
|
})
|
||||||
|
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -346,6 +357,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)
|
||||||
@@ -427,6 +442,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 +474,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(), 2)
|
||||||
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := dao.Mdb.Begin()
|
||||||
|
subOrder := &mdb.Orders{
|
||||||
|
TradeId: subTradeID,
|
||||||
|
OrderId: subTradeID,
|
||||||
|
ParentTradeId: parent.TradeId,
|
||||||
|
Amount: parent.Amount,
|
||||||
|
Currency: parent.Currency,
|
||||||
|
ActualAmount: amount,
|
||||||
|
ReceiveAddress: "OKPAY",
|
||||||
|
Token: token,
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
IsSelected: true,
|
||||||
|
NotifyUrl: "",
|
||||||
|
RedirectUrl: parent.RedirectUrl,
|
||||||
|
Name: parent.Name,
|
||||||
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||||
|
PaymentType: parent.PaymentType,
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
ApiKeyID: parent.ApiKeyID,
|
||||||
|
}
|
||||||
|
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
providerRow := &mdb.ProviderOrder{
|
||||||
|
TradeId: subTradeID,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: "",
|
||||||
|
PayURL: "",
|
||||||
|
Amount: amount,
|
||||||
|
Coin: token,
|
||||||
|
Status: mdb.ProviderOrderStatusCreating,
|
||||||
|
}
|
||||||
|
if err = data.CreateProviderOrderWithTransaction(tx, providerRow); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit().Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
okpayOrder, err := createOkPayDepositOrder(subTradeID, amount, token, returnURL)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||||
|
|
||||||
|
resp := buildCheckoutResponse(subOrder)
|
||||||
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPayTokenAllowed(token string) bool {
|
||||||
|
for _, item := range data.GetOkPayAllowTokens() {
|
||||||
|
if strings.EqualFold(item, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,59 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +612,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.
|
||||||
|
|||||||
@@ -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("不存在待支付订单或已过期")
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
+178
-314
@@ -1,328 +1,159 @@
|
|||||||
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(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
log.Sugar.Error(err)
|
log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var innerWg sync.WaitGroup
|
addr := strings.TrimSpace(toAddr)
|
||||||
innerWg.Add(2)
|
if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
|
||||||
go checkTrxTransfers(address, &innerWg)
|
return
|
||||||
go checkTrc20Transfers(address, &innerWg)
|
}
|
||||||
innerWg.Wait()
|
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||||
|
if amount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: addr,
|
||||||
|
Token: "USDT",
|
||||||
|
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] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", 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(), 2)
|
||||||
|
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 {
|
||||||
@@ -340,9 +171,6 @@ func evmChainLogLabel(chainNetwork string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -350,38 +178,67 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
net := evmChainLogLabel(chainNetwork)
|
var usdt, usdc common.Address
|
||||||
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
var polygonUsdcE common.Address
|
||||||
if err != nil {
|
switch chainNetwork {
|
||||||
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
|
case mdb.NetworkEthereum:
|
||||||
|
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||||
|
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||||
|
case mdb.NetworkBsc:
|
||||||
|
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
|
||||||
|
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
|
||||||
|
case mdb.NetworkPolygon:
|
||||||
|
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
|
||||||
|
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
|
||||||
|
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
|
||||||
|
case mdb.NetworkPlasma:
|
||||||
|
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
|
||||||
|
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
|
||||||
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token == nil || token.ID == 0 {
|
|
||||||
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var tokenSym string
|
||||||
|
switch {
|
||||||
|
case contract == usdt:
|
||||||
|
tokenSym = "USDT"
|
||||||
|
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
|
||||||
|
tokenSym = "USDC"
|
||||||
|
default:
|
||||||
|
net := evmChainLogLabel(chainNetwork)
|
||||||
|
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
net := evmChainLogLabel(chainNetwork)
|
||||||
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))
|
|
||||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
chainTokens, err := data.ListChainTokens(chainNetwork)
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
if err != nil {
|
||||||
if token.MinAmount > 0 && amount < token.MinAmount {
|
log.Sugar.Warnf("[%s-%s] load chain tokens: %v", net, tokenSym, err)
|
||||||
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var tokenConfig *mdb.ChainToken
|
||||||
|
for _, t := range chainTokens {
|
||||||
|
if strings.EqualFold(t.Symbol, tokenSym) {
|
||||||
|
tokenConfig = &t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokenConfig == nil || !tokenConfig.Enabled {
|
||||||
|
log.Sugar.Warnf("[%s-%s] token not enabled or configured in chain_tokens", net, tokenSym)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pow := decimal.New(1, int32(tokenConfig.Decimals))
|
||||||
|
log.Sugar.Warnf("tokenConfig.Decimals %d pow %s", tokenConfig.Decimals, pow.String())
|
||||||
|
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
|
||||||
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
|
||||||
@@ -412,11 +269,6 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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",
|
|
||||||
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: walletAddr,
|
ReceiveAddress: walletAddr,
|
||||||
@@ -441,6 +293,18 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"🎉 <b>收款成功通知</b>\n\n"+
|
"🎉 <b>收款成功通知</b>\n\n"+
|
||||||
"💰 <b>金额信息</b>\n"+
|
"💰 <b>金额信息</b>\n"+
|
||||||
@@ -463,7 +327,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -3,7 +3,7 @@ package mq
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+8
-8
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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{}{
|
||||||
@@ -622,6 +729,98 @@ 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 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},
|
||||||
|
},
|
||||||
|
}, token)
|
||||||
|
assertOK(t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSite := func(path, wantCashier, wantLogo, wantTitle, wantSupport 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBrand("cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1")
|
||||||
|
assertSite("/payments/gmpay/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1")
|
||||||
|
assertSite("/admin/api/v1/config", "cashier-v1", "https://cdn.example.com/v1.png", "title-v1", "https://example.com/help-v1")
|
||||||
|
|
||||||
|
updateBrand("cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2")
|
||||||
|
assertSite("/payments/gmpay/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2")
|
||||||
|
assertSite("/admin/api/v1/config", "cashier-v2", "https://cdn.example.com/v2.png", "title-v2", "https://example.com/help-v2")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Notification Channels ───────────────────────────────────────────────────
|
// ─── Notification Channels ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAdminNotificationChannels_CRUD verifies notification channel routes.
|
// TestAdminNotificationChannels_CRUD verifies notification channel routes.
|
||||||
|
|||||||
+25
-14
@@ -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)
|
||||||
|
|||||||
+647
-20
@@ -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,22 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
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 +509,57 @@ 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"])
|
||||||
|
}
|
||||||
|
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 +572,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 +1124,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,546 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="dark">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>GMWallet — Crypto Payment</title>
|
|
||||||
<link rel="icon" href="https://www.gmwallet.app/favicon.png" />
|
|
||||||
<link rel="preconnect" href="https://cdn.jsdmirror.com" crossorigin />
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fontsource-variable/noto-sans-sc/index.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fontsource-variable/nunito/index.css" />
|
|
||||||
<script src="https://cdn.jsdmirror.com/npm/@tailwindcss/browser@4"></script>
|
|
||||||
<style type="text/tailwindcss">
|
|
||||||
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
|
||||||
--font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground:var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground:var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground:var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-success: var(--success);
|
|
||||||
--color-warning: var(--warning);
|
|
||||||
--animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Design tokens ── */
|
|
||||||
:root, [data-theme="light"] {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground:oklch(0.141 0.005 285.823);
|
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
|
||||||
--primary-foreground:oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
|
||||||
--secondary-foreground:oklch(0.21 0.006 285.885);
|
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.92 0.004 286.32);
|
|
||||||
--input: oklch(0.92 0.004 286.32);
|
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
|
||||||
--radius: 0.875rem;
|
|
||||||
--success: oklch(0.721 0.193 143.56);
|
|
||||||
--warning: oklch(0.741 0.188 55.68);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--background: oklch(0.141 0.005 285.823);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.21 0.006 285.885);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
|
||||||
--popover-foreground:oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.92 0.004 286.32);
|
|
||||||
--primary-foreground:oklch(0.21 0.006 285.885);
|
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
|
||||||
--secondary-foreground:oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
|
||||||
--success: oklch(0.747 0.201 143.5);
|
|
||||||
--warning: oklch(0.762 0.182 61.47);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Ambient blobs ── */
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fill-bar {
|
|
||||||
from { width: 0%; }
|
|
||||||
to { width: 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-out-l {
|
|
||||||
from { transform: translateX(0); }
|
|
||||||
to { transform: translateX(calc(-100% - 20px)); }
|
|
||||||
}
|
|
||||||
@keyframes slide-out-r {
|
|
||||||
from { transform: translateX(0); }
|
|
||||||
to { transform: translateX(calc(100% + 20px)); }
|
|
||||||
}
|
|
||||||
@keyframes slide-in-r {
|
|
||||||
from { transform: translateX(calc(100% + 20px)); }
|
|
||||||
to { transform: translateX(0); }
|
|
||||||
}
|
|
||||||
@keyframes slide-in-l {
|
|
||||||
from { transform: translateX(calc(-100% - 20px)); }
|
|
||||||
to { transform: translateX(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blob-drift {
|
|
||||||
0% { transform: translate(0,0) scale(1); }
|
|
||||||
50% { transform: translate(30px,-20px) scale(1.06); }
|
|
||||||
100% { transform: translate(-20px,30px) scale(0.96); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
/* Card */
|
|
||||||
.card {
|
|
||||||
@apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chip / pill button */
|
|
||||||
.chip {
|
|
||||||
@apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all;
|
|
||||||
}
|
|
||||||
.chip:hover { @apply bg-accent border-ring; }
|
|
||||||
.chip:active { @apply opacity-60; }
|
|
||||||
.chip.w-9 { @apply p-0; }
|
|
||||||
|
|
||||||
/* Copy rows */
|
|
||||||
.row {
|
|
||||||
@apply flex items-center px-4 py-3.5 transition-colors;
|
|
||||||
}
|
|
||||||
.row:active { @apply bg-muted; }
|
|
||||||
|
|
||||||
/* Icon button */
|
|
||||||
.icon-btn {
|
|
||||||
@apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors;
|
|
||||||
}
|
|
||||||
.icon-btn:hover { @apply bg-accent text-card-foreground border-border; }
|
|
||||||
|
|
||||||
/* Primary button */
|
|
||||||
.btn-primary {
|
|
||||||
@apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity];
|
|
||||||
}
|
|
||||||
.btn-primary:hover { @apply bg-primary/90; }
|
|
||||||
.btn-primary:active { @apply scale-[0.97] opacity-90; }
|
|
||||||
.btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; }
|
|
||||||
|
|
||||||
/* Secondary button */
|
|
||||||
.btn-secondary {
|
|
||||||
@apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover { @apply bg-accent border-ring; }
|
|
||||||
.btn-secondary:active { @apply opacity-70; }
|
|
||||||
|
|
||||||
/* State icon circle */
|
|
||||||
.state-icon {
|
|
||||||
@apply size-20 rounded-full flex items-center justify-center mx-auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown menu */
|
|
||||||
.menu {
|
|
||||||
@apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
.menu--up { transform: translateY(8px); }
|
|
||||||
.menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; }
|
|
||||||
.menu-item {
|
|
||||||
@apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors;
|
|
||||||
}
|
|
||||||
.menu-item:hover { @apply bg-accent; }
|
|
||||||
.menu-item.is-selected { @apply text-primary font-semibold; }
|
|
||||||
.menu-item + .menu-item { @apply border-t border-border; }
|
|
||||||
.select-trigger.is-open { opacity: 0.8; }
|
|
||||||
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
/* QR wrapper */
|
|
||||||
.qr-wrapper {
|
|
||||||
@apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Timer SVG rings */
|
|
||||||
.timer-ring-track { stroke: var(--border); transition: stroke .3s; }
|
|
||||||
.timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; }
|
|
||||||
.timer-icon { @apply text-muted-foreground; }
|
|
||||||
|
|
||||||
/* Flippable status card */
|
|
||||||
.status-card {
|
|
||||||
position: relative;
|
|
||||||
perspective: 1600px;
|
|
||||||
transition: height 0.38s cubic-bezier(.22,1,.36,1);
|
|
||||||
}
|
|
||||||
.status-card-inner {
|
|
||||||
position: relative; width: 100%;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
|
|
||||||
}
|
|
||||||
.status-card.is-flipped .status-card-inner { transform: rotateY(180deg); }
|
|
||||||
.status-face {
|
|
||||||
@apply absolute inset-0 w-full;
|
|
||||||
-webkit-backface-visibility: hidden;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
|
||||||
.status-face-front { z-index: 2; }
|
|
||||||
.status-face-back { transform: rotateY(180deg); }
|
|
||||||
.state-panel-shell { @apply pt-px; }
|
|
||||||
.state-screen { @apply hidden; }
|
|
||||||
.state-screen.is-active { @apply flex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Panel viewport: hide scrollbars ── */
|
|
||||||
#panel-viewport { scrollbar-width: none; }
|
|
||||||
#panel-viewport::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
/* ── Toast ── */
|
|
||||||
#toast {
|
|
||||||
@apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999;
|
|
||||||
@apply bg-foreground/95 text-background;
|
|
||||||
@apply dark:bg-card/95 dark:text-card-foreground;
|
|
||||||
transform: translateX(-50%) translateY(12px);
|
|
||||||
transition: opacity 0.22s, transform 0.22s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes state-in {
|
|
||||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
|
|
||||||
::view-transition-old(root) { z-index: 1; }
|
|
||||||
::view-transition-new(root) { z-index: 9; }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.status-card, .status-card-inner, .status-face, .timer-ring-progress {
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/qrcodejs@latest/qrcode.min.js"></script>
|
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body
|
|
||||||
class="bg-background text-foreground min-h-screen flex flex-col items-center font-sans transition-colors duration-300">
|
|
||||||
<!-- Nav bar -->
|
|
||||||
<header class="w-full max-w-sm px-4 pt-8 pb-6 flex items-center justify-between relative z-10">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-sm shadow-sm" />
|
|
||||||
<span class="text-lg font-semibold tracking-tight text-card-foreground">GM Pay</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Language pill -->
|
|
||||||
<div class="select-wrap relative select-none" id="dd-lang">
|
|
||||||
<div class="select-trigger chip flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
|
||||||
<span class="text-sm font-medium" id="lang-label">EN</span>
|
|
||||||
<i data-lucide="chevron-down"
|
|
||||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11"
|
|
||||||
height="11" stroke-width="2.5"></i>
|
|
||||||
</div>
|
|
||||||
<div class="select-menu menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-40" id="dd-lang-menu">
|
|
||||||
<div class="select-option menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English
|
|
||||||
</div>
|
|
||||||
<div class="select-option menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
|
||||||
<div class="select-option menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
|
||||||
<div class="select-option menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
|
||||||
<div class="select-option menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
|
||||||
<div class="select-option menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Theme toggle -->
|
|
||||||
<button class="chip w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)"
|
|
||||||
title="Toggle theme">
|
|
||||||
<i data-lucide="moon" id="icon-moon" class="hidden" width="16" height="16" stroke-width="1.8"></i>
|
|
||||||
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
|
|
||||||
</button>
|
|
||||||
<!-- Ambient background blobs -->
|
|
||||||
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
||||||
<div
|
|
||||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] size-[420px] -top-30 -left-25 bg-[color-mix(in_srgb,var(--muted)_60%,transparent)]">
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-6s] size-[340px] -bottom-20 -right-15 bg-[color-mix(in_srgb,var(--success)_14%,transparent)]">
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-12s] size-[260px] top-[45%] left-[55%] bg-[color-mix(in_srgb,var(--warning)_10%,transparent)]">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-1 flex flex-col items-center justify-center w-full max-w-sm px-5 relative">
|
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<div id="step-progress" class="flex items-center gap-2 w-full mb-4 select-none">
|
|
||||||
<div id="step-bar-1" class="h-1 flex-1 rounded-full overflow-hidden"
|
|
||||||
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
|
||||||
<div id="step-fill-1" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
|
||||||
</div>
|
|
||||||
<div id="step-bar-2" class="h-1 flex-1 rounded-full overflow-hidden"
|
|
||||||
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
|
||||||
<div id="step-fill-2" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Order info -->
|
|
||||||
<aside id="order-info" class="card w-full px-6 pt-6 pb-5 mb-4">
|
|
||||||
<p class="text-sm font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
|
||||||
<div class="flex items-end gap-3 justify-between">
|
|
||||||
<span class="text-4xl font-bold leading-none tracking-tighter text-card-foreground font-nunito"
|
|
||||||
id="display-amount">--</span>
|
|
||||||
<button class="icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
|
||||||
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
|
|
||||||
<div class="mt-3 pt-3 border-t border-border/50">
|
|
||||||
<table class="w-full text-sm text-muted-foreground border-separate border-spacing-y-1">
|
|
||||||
<tbody>
|
|
||||||
<tr id="display-fiat"></tr>
|
|
||||||
<tr id="display-order-id"></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Panel viewport: clips horizontal slide -->
|
|
||||||
<div class="relative w-full" id="panel-viewport">
|
|
||||||
|
|
||||||
<!-- Step 1: Select coin + network -->
|
|
||||||
<section id="step1-panel" class="w-full mb-4 pb-4">
|
|
||||||
<div class="flex gap-2 mb-4">
|
|
||||||
<!-- Network -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
|
||||||
data-i18n="network_label">Network</p>
|
|
||||||
<div class="select-wrap relative select-none" id="dd-network">
|
|
||||||
<div
|
|
||||||
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
|
||||||
onclick="toggleSelect('dd-network')">
|
|
||||||
<span id="network-label" class="text-sm font-semibold text-card-foreground leading-none"></span>
|
|
||||||
<i data-lucide="chevron-down"
|
|
||||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
|
||||||
height="13" stroke-width="2.5"></i>
|
|
||||||
</div>
|
|
||||||
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-network-menu">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Currency -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
|
||||||
data-i18n="currency_label">Currency</p>
|
|
||||||
<div class="select-wrap relative select-none" id="dd-token">
|
|
||||||
<div
|
|
||||||
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
|
||||||
onclick="toggleSelect('dd-token')">
|
|
||||||
<span id="token-label" class="text-sm font-semibold text-card-foreground"></span>
|
|
||||||
<i data-lucide="chevron-down"
|
|
||||||
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
|
||||||
height="13" stroke-width="2.5"></i>
|
|
||||||
</div>
|
|
||||||
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-token-menu">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="btn-confirm-step1" class="btn-primary w-full" onclick="confirmStep1()"
|
|
||||||
data-i18n="confirm">Confirm</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Payment panel (Step 2) -->
|
|
||||||
<section id="payment-panel" class="w-full pb-4" style="display:none">
|
|
||||||
<!-- QR + address card -->
|
|
||||||
<div class="card w-full px-5 pt-5 pb-0 mb-4 overflow-hidden">
|
|
||||||
<!-- Scan title + timer -->
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<p class="text-sm font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay
|
|
||||||
</p>
|
|
||||||
<div id="timer-row" class="relative w-10 h-10 shrink-0">
|
|
||||||
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
|
|
||||||
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
|
||||||
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
|
||||||
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="var(--success)"
|
|
||||||
stroke-width="3" stroke-linecap="round" stroke-dasharray="125.66" stroke-dashoffset="0" />
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Countdown -->
|
|
||||||
<p class="text-3xl font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
|
||||||
<!-- QR code -->
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="qr-wrapper">
|
|
||||||
<div id="qrcode" class="w-44 h-44"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Address row (edge-to-edge inside card) -->
|
|
||||||
<div class="h-px bg-border/50 -mx-5"></div>
|
|
||||||
<div id="copy-addr-box" class="row cursor-pointer select-none -mx-5">
|
|
||||||
<div class="flex-1 min-w-0 pr-3">
|
|
||||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-0.5"
|
|
||||||
data-i18n="payment_address">Payment address</p>
|
|
||||||
<p class="text-sm font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
|
||||||
</div>
|
|
||||||
<span class="icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
|
||||||
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span id="countdown-inline" class="sr-only"></span>
|
|
||||||
<!-- Connect Wallet (above I have transferred) -->
|
|
||||||
<button class="btn-secondary w-full mb-3" id="btn-connect-wallet" onclick="connectWallet()">
|
|
||||||
<i data-lucide="wallet" width="16" height="16" stroke-width="1.8" class="mr-1.5 shrink-0"></i>
|
|
||||||
<span data-i18n="connect_wallet">Connect Wallet to Pay</span>
|
|
||||||
</button>
|
|
||||||
<!-- I have transferred -->
|
|
||||||
<button class="btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
|
||||||
<span data-i18n="i_have_transferred">I have transferred</span>
|
|
||||||
</button>
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
|
|
||||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13"
|
|
||||||
stroke-width="2.2"></i>
|
|
||||||
<span class="text-xs text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking
|
|
||||||
blockchain</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- State: Success -->
|
|
||||||
<section id="screen-success"
|
|
||||||
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
|
||||||
aria-live="polite" style="display:none">
|
|
||||||
<div class="state-icon bg-success/15 mb-6">
|
|
||||||
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="var(--success)"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
|
||||||
<p class="text-sm text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
|
||||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22"
|
|
||||||
stroke-width="2"></i>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- State: Expired -->
|
|
||||||
<section id="screen-expired" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
|
||||||
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
|
||||||
<div class="state-icon bg-destructive/12 mb-6">
|
|
||||||
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="var(--destructive)"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
|
||||||
<p class="text-sm text-muted-foreground" data-i18n="expired_sub">Please initiate a new payment</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-secondary w-full" onclick="goBack()" data-i18n="back">Back</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- State: Timeout -->
|
|
||||||
<section id="screen-timeout" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
|
||||||
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
|
||||||
<div class="state-icon bg-warning/12 mb-6">
|
|
||||||
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="var(--warning)"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
|
||||||
<p class="text-sm text-muted-foreground" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button class="btn-secondary flex-1" onclick="goBack()" data-i18n="back">Back</button>
|
|
||||||
<button class="btn-primary flex-1" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- State: Not Found -->
|
|
||||||
<section id="screen-not-found"
|
|
||||||
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
|
||||||
aria-live="polite" style="display:none">
|
|
||||||
<div class="state-icon bg-muted-foreground/12 mb-6">
|
|
||||||
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="var(--muted-foreground)"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
|
||||||
<p class="text-sm text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already
|
|
||||||
expired
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div><!-- /panel viewport -->
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="flex items-center justify-center gap-2.5 pb-6 text-xs text-muted-foreground select-none relative z-10">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
Powered by
|
|
||||||
<a href="https://www.gmwallet.app" target="_blank" rel="noopener"
|
|
||||||
class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
|
|
||||||
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-xs" />
|
|
||||||
GM Wallet
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
<span class="opacity-30">|</span>
|
|
||||||
<a href="https://github.com/GMwalletApp" target="_blank" rel="noopener"
|
|
||||||
class="flex items-center gap-1 hover:opacity-70 transition-opacity">
|
|
||||||
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Toast -->
|
|
||||||
<div id="toast">Copied</div>
|
|
||||||
|
|
||||||
<!-- Order data injected by server (Go template) -->
|
|
||||||
<script>
|
|
||||||
var ORDER = {
|
|
||||||
tradeId: "{{.TradeId}}",
|
|
||||||
amount: "{{.Amount}}",
|
|
||||||
actualAmount: "{{.ActualAmount}}",
|
|
||||||
token: "{{.Token}}",
|
|
||||||
currency: "{{.Currency}}",
|
|
||||||
network: "{{.Network}}",
|
|
||||||
receiveAddress: "{{.ReceiveAddress}}",
|
|
||||||
expirationTime: "{{.ExpirationTime}}",
|
|
||||||
redirectUrl: "{{.RedirectUrl}}",
|
|
||||||
createdAt: "{{.CreatedAt}}",
|
|
||||||
is_selected: "{{.IsSelected}}"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script defer src="/static/payment.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
+7
-9
@@ -1,7 +1,7 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,22 +13,19 @@ func Start() {
|
|||||||
go StartBscWebSocketListener()
|
go StartBscWebSocketListener()
|
||||||
go StartPolygonWebSocketListener()
|
go StartPolygonWebSocketListener()
|
||||||
go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
|
go StartTronBlockScannerListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
// TRC20 polling
|
|
||||||
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
|
||||||
// Solana polling
|
// Solana polling
|
||||||
_, err = c.AddJob("@every 5s", ListenSolJob{})
|
_, err := c.AddJob("@every 5s", ListenSolJob{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
|
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
|
||||||
|
|
||||||
// RPC node health checks
|
// RPC node health checks
|
||||||
_, err = c.AddJob("@every 30s", RpcHealthJob{})
|
_, err = c.AddJob("@every 30s", RpcHealthJob{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,6 +33,7 @@ func Start() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
|
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
|
||||||
|
|
||||||
c.Start()
|
c.Start()
|
||||||
log.Sugar.Info("[task] Task scheduler started")
|
log.Sugar.Info("[task] Task scheduler started")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"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/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const bscDefaultWsURL = "wss://bsc.drpc.org"
|
|
||||||
|
|
||||||
type bscRecipientSnapshot struct {
|
type bscRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -68,7 +66,10 @@ func runBscListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkBsc, "[BSC-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ 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/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
)
|
)
|
||||||
@@ -90,12 +90,22 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
|
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
|
||||||
// given network, falling back to the provided default (usually the
|
// given network. If no enabled node is configured, the caller skips the
|
||||||
// public node URL) when no row is configured.
|
// current listener run so admin-side disabled/deleted rows are respected.
|
||||||
func resolveChainWsURL(network, fallback string) string {
|
func resolveChainWsURL(network, logPrefix string) (string, bool) {
|
||||||
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
return node.Url
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
if rpcURL != "" {
|
||||||
|
return rpcURL, true
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("%s rpc_nodes id=%d has empty url", logPrefix, node.ID)
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
return fallback
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("%s resolve rpc_nodes err=%v", logPrefix, err)
|
||||||
|
} else {
|
||||||
|
log.Sugar.Warnf("%s no enabled %s WS RPC node configured in rpc_nodes", logPrefix, network)
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChainWsURLWithRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Url: " wss://ethereum.example.com ",
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() ok=false, want true")
|
||||||
|
}
|
||||||
|
if got != "wss://ethereum.example.com" {
|
||||||
|
t.Fatalf("resolveChainWsURL() = %q, want wss://ethereum.example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Url: "wss://disabled.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, ok := resolveChainWsURL(mdb.NetworkEthereum, "[TEST]"); ok {
|
||||||
|
t.Fatalf("resolveChainWsURL() = (%q, true), want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-23
@@ -7,11 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/assimon/luuu/util/log"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -22,22 +21,6 @@ import (
|
|||||||
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
|
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
|
||||||
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||||
|
|
||||||
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
|
|
||||||
|
|
||||||
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
|
|
||||||
// legacy .env value (if set) before the shared default — keeps existing
|
|
||||||
// deployments working until the admin adds an rpc_nodes row.
|
|
||||||
func resolveEthereumWsURL() string {
|
|
||||||
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
|
|
||||||
if err == nil && node != nil && node.ID > 0 {
|
|
||||||
return node.Url
|
|
||||||
}
|
|
||||||
if u := config.GetEthereumWsUrl(); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return ethereumDefaultWsURL
|
|
||||||
}
|
|
||||||
|
|
||||||
type ethRecipientSnapshot struct {
|
type ethRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -88,7 +71,10 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveEthereumWsURL()
|
wsURL, ok := resolveChainWsURL(mdb.NetworkEthereum, "[ETH-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
@@ -145,4 +131,3 @@ func isWatchedEthRecipient(to common.Address) bool {
|
|||||||
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"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/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
|
|
||||||
|
|
||||||
type plasmaRecipientSnapshot struct {
|
type plasmaRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,10 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPlasma, "[PLASMA-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"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/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
|
|
||||||
|
|
||||||
type polygonRecipientSnapshot struct {
|
type polygonRecipientSnapshot struct {
|
||||||
addrs map[string]struct{}
|
addrs map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,10 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL)
|
wsURL, ok := resolveChainWsURL(mdb.NetworkPolygon, "[POLYGON-WS]")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package task
|
|||||||
import (
|
import (
|
||||||
"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/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenSolJob struct{}
|
type ListenSolJob struct{}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// USDT 合约地址 (TRC20 主网)
|
||||||
|
USDTContractHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" // Base58: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
||||||
|
// transfer(address,uint256) 方法签名前4字节
|
||||||
|
TransferMethodID = "a9059cbb"
|
||||||
|
|
||||||
|
PollInterval = 3 * time.Second
|
||||||
|
RequestTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func HexToTronAddress(hexAddr string) (string, error) {
|
||||||
|
hexAddr = strings.TrimPrefix(hexAddr, "0x")
|
||||||
|
hexAddr = strings.TrimPrefix(hexAddr, "0X")
|
||||||
|
|
||||||
|
raw, err := hex.DecodeString(hexAddr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保有 0x41 前缀(TRON 主网地址前缀)
|
||||||
|
if len(raw) == 20 {
|
||||||
|
raw = append([]byte{0x41}, raw...)
|
||||||
|
}
|
||||||
|
if len(raw) != 21 {
|
||||||
|
return "", fmt.Errorf("地址长度非法: %d bytes", len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tron.EncodeCheck(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockHeader struct {
|
||||||
|
RawData struct {
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
WitnessAddress string `json:"witness_address"`
|
||||||
|
ParentHash string `json:"parentHash"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
} `json:"raw_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerSmartContractValue struct {
|
||||||
|
OwnerAddress string `json:"owner_address"`
|
||||||
|
ContractAddress string `json:"contract_address"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
CallValue int64 `json:"call_value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContractParam struct {
|
||||||
|
TypeURL string `json:"type_url"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transaction struct {
|
||||||
|
TxID string `json:"txID"`
|
||||||
|
RawData struct {
|
||||||
|
Contract []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Parameter ContractParam `json:"parameter"`
|
||||||
|
} `json:"contract"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
FeeLimit int64 `json:"fee_limit"`
|
||||||
|
} `json:"raw_data"`
|
||||||
|
Ret []struct {
|
||||||
|
ContractRet string `json:"contractRet"`
|
||||||
|
} `json:"ret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
BlockID string `json:"blockID"`
|
||||||
|
BlockHeader BlockHeader `json:"block_header"`
|
||||||
|
Transactions []Transaction `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type USDTTransfer struct {
|
||||||
|
TxID string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Raw *big.Int // 原始数值(6 decimals)
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TRXTransfer struct {
|
||||||
|
TxID string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
RawSun int64 // 单位: SUN
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferContractValue struct {
|
||||||
|
OwnerAddress string `json:"owner_address"`
|
||||||
|
ToAddress string `json:"to_address"`
|
||||||
|
Amount int64 `json:"amount"` // 单位: SUN
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = &http.Client{Timeout: RequestTimeout}
|
||||||
|
|
||||||
|
func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
|
||||||
|
req.Header.Set("TRON-PRO-API-KEY", apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
|
||||||
|
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var block Block
|
||||||
|
return &block, json.Unmarshal(b, &block)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
|
||||||
|
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var block Block
|
||||||
|
return &block, json.Unmarshal(b, &block)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUSDTTransfer(tx Transaction) *USDTTransfer {
|
||||||
|
if len(tx.RawData.Contract) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := tx.RawData.Contract[0]
|
||||||
|
if c.Type != "TriggerSmartContract" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var val TriggerSmartContractValue
|
||||||
|
if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是 USDT 合约
|
||||||
|
contractHex := strings.ToLower(strings.TrimPrefix(val.ContractAddress, "0x"))
|
||||||
|
if contractHex != USDTContractHex {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 data 字段
|
||||||
|
// 格式: [4字节方法ID][32字节 to 地址][32字节 amount]
|
||||||
|
data := strings.TrimPrefix(strings.ToLower(val.Data), "0x")
|
||||||
|
if len(data) < 8+64+64 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data[:8] != TransferMethodID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// to 地址:后 40 个十六进制字符(20 字节)
|
||||||
|
toHex := data[8+24 : 8+64] // 跳过前12字节填充,取后20字节
|
||||||
|
toAddr, err := HexToTronAddress(toHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// amount:后 32 字节大整数
|
||||||
|
amountHex := data[8+64 : 8+64+64]
|
||||||
|
amountBytes, err := hex.DecodeString(amountHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amountBig := new(big.Int).SetBytes(amountBytes)
|
||||||
|
|
||||||
|
// from 地址
|
||||||
|
fromAddr, err := HexToTronAddress(val.OwnerAddress)
|
||||||
|
if err != nil {
|
||||||
|
fromAddr = val.OwnerAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易状态
|
||||||
|
status := "SUCCESS"
|
||||||
|
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||||
|
status = tx.Ret[0].ContractRet
|
||||||
|
}
|
||||||
|
|
||||||
|
return &USDTTransfer{
|
||||||
|
TxID: tx.TxID,
|
||||||
|
From: fromAddr,
|
||||||
|
To: toAddr,
|
||||||
|
Raw: amountBig,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTRXTransfer(tx Transaction) *TRXTransfer {
|
||||||
|
if len(tx.RawData.Contract) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := tx.RawData.Contract[0]
|
||||||
|
if c.Type != "TransferContract" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var val TransferContractValue
|
||||||
|
if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if val.Amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromAddr, err := HexToTronAddress(val.OwnerAddress)
|
||||||
|
if err != nil {
|
||||||
|
fromAddr = val.OwnerAddress
|
||||||
|
}
|
||||||
|
toAddr, err := HexToTronAddress(val.ToAddress)
|
||||||
|
if err != nil {
|
||||||
|
toAddr = val.ToAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "SUCCESS"
|
||||||
|
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||||
|
status = tx.Ret[0].ContractRet
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TRXTransfer{
|
||||||
|
TxID: tx.TxID,
|
||||||
|
From: fromAddr,
|
||||||
|
To: toAddr,
|
||||||
|
RawSun: val.Amount,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processBlock(block *Block) {
|
||||||
|
blockTsMs := block.BlockHeader.RawData.Timestamp
|
||||||
|
for _, tx := range block.Transactions {
|
||||||
|
if t := parseUSDTTransfer(tx); t != nil {
|
||||||
|
if t.Status != "SUCCESS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service.TryProcessTronTRC20Transfer(t.To, t.Raw, t.TxID, blockTsMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t := parseTRXTransfer(tx); t != nil {
|
||||||
|
if t.Status != "SUCCESS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service.TryProcessTronTRXTransfer(t.To, t.RawSun, t.TxID, blockTsMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scanner struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
lastBlock int64
|
||||||
|
// 统计
|
||||||
|
totalBlocks int64
|
||||||
|
totalUSDTTxs int64
|
||||||
|
totalTRXTxs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScanner() *Scanner {
|
||||||
|
return &Scanner{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Init() error {
|
||||||
|
baseURL, apiKey, err := service.ResolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve tron node: %w", err)
|
||||||
|
}
|
||||||
|
s.baseURL = baseURL
|
||||||
|
s.apiKey = apiKey
|
||||||
|
|
||||||
|
log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
|
||||||
|
block, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取初始块失败: %w", err)
|
||||||
|
}
|
||||||
|
s.lastBlock = block.BlockHeader.RawData.Number
|
||||||
|
log.Sugar.Infof("[TRON-BLOCK] start block=%d", s.lastBlock)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Run() {
|
||||||
|
log.Sugar.Info("[TRON-BLOCK] start scanning (USDT TRC20 + TRX)")
|
||||||
|
ticker := time.NewTicker(PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
statTicker := time.NewTicker(60 * time.Second)
|
||||||
|
defer statTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-statTicker.C:
|
||||||
|
log.Sugar.Infof("[TRON-BLOCK] stats blocks=%d usdt=%d trx=%d", s.totalBlocks, s.totalUSDTTxs, s.totalTRXTxs)
|
||||||
|
case <-ticker.C:
|
||||||
|
s.poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) poll() {
|
||||||
|
latest, err := GetNowBlock(s.baseURL, s.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latestNum := latest.BlockHeader.RawData.Number
|
||||||
|
if latestNum <= s.lastBlock {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for num := s.lastBlock + 1; num <= latestNum; num++ {
|
||||||
|
var block *Block
|
||||||
|
if num == latestNum {
|
||||||
|
block = latest
|
||||||
|
} else {
|
||||||
|
block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
processBlock(block)
|
||||||
|
s.lastBlock = num
|
||||||
|
s.totalBlocks++
|
||||||
|
|
||||||
|
for _, tx := range block.Transactions {
|
||||||
|
if parseUSDTTransfer(tx) != nil {
|
||||||
|
s.totalUSDTTxs++
|
||||||
|
} else if parseTRXTransfer(tx) != nil {
|
||||||
|
s.totalTRXTxs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartTronBlockScannerListener() {
|
||||||
|
scanner := NewScanner()
|
||||||
|
if err := scanner.Init(); err != nil {
|
||||||
|
log.Sugar.Errorf("[TRON-BLOCK] init: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner.Run()
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
|
||||||
"github.com/assimon/luuu/model/service"
|
|
||||||
"github.com/assimon/luuu/util/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListenTrc20Job struct{}
|
|
||||||
|
|
||||||
var gListenTrc20JobLock sync.Mutex
|
|
||||||
|
|
||||||
func (r ListenTrc20Job) Run() {
|
|
||||||
gListenTrc20JobLock.Lock()
|
|
||||||
defer gListenTrc20JobLock.Unlock()
|
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
|
||||||
if !data.IsChainEnabled(mdb.NetworkTron) {
|
|
||||||
log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(walletAddress) <= 0 {
|
|
||||||
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, address := range walletAddress {
|
|
||||||
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
go service.Trc20CallBack(address.Address, &wg)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job completed")
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"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/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rpcProbeTimeout = 5 * time.Second
|
const rpcProbeTimeout = 5 * time.Second
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --------------- ParseAddress ---------------
|
// --------------- ParseAddress ---------------
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"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/gookit/goutil/mathutil"
|
"github.com/gookit/goutil/mathutil"
|
||||||
"github.com/gookit/goutil/strutil"
|
"github.com/gookit/goutil/strutil"
|
||||||
tb "gopkg.in/telebot.v3"
|
tb "gopkg.in/telebot.v3"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
tb "gopkg.in/telebot.v3"
|
tb "gopkg.in/telebot.v3"
|
||||||
"gopkg.in/telebot.v3/middleware"
|
"gopkg.in/telebot.v3/middleware"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package telegram
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/assimon/luuu/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user