mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1512fae1 | |||
| fce84c9551 | |||
| 0b5eef242c |
@@ -1,111 +0,0 @@
|
||||
name: Bug 反馈
|
||||
description: 有一个 bug 需要解决
|
||||
title: "发现bug:"
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 提 ISSUE 须知
|
||||
|
||||
请先阅读文档:
|
||||
- [epusdt 文档](https://epusdt.com/)
|
||||
|
||||
如果仍有问题,请先在 issue 列表中搜索是否已有相同问题:
|
||||
- [Issue 列表](https://github.com/GMWalletApp/epusdt/issues)
|
||||
|
||||
如果没有找到相关问题,请按下面格式尽量完整填写,便于我们快速排查。
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: 提交前检查
|
||||
options:
|
||||
- label: 我已阅读文档并检查常见问题
|
||||
required: true
|
||||
- label: 我已搜索现有 issue,确认不是重复问题
|
||||
required: true
|
||||
- label: 我已尽量提供可复现信息(代码、配置、日志)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: epusdt_version
|
||||
attributes:
|
||||
label: 1. epusdt 版本
|
||||
description: 请提供版本号或 commit hash,避免只写“最新版”
|
||||
placeholder: |
|
||||
例如:
|
||||
- v1.2.3
|
||||
- commit: abcdef1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: runtime
|
||||
attributes:
|
||||
label: 2. 运行环境版本
|
||||
description: 请提供语言/运行时及相关依赖版本
|
||||
placeholder: |
|
||||
例如:
|
||||
- Go/Python/Node 版本:
|
||||
- 数据库及版本:
|
||||
- Docker 版本(如有):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: os_info
|
||||
attributes:
|
||||
label: 3. 操作系统信息
|
||||
description: Windows / Linux / macOS 或其他系统,请写明版本
|
||||
placeholder: |
|
||||
例如:
|
||||
- Ubuntu 22.04
|
||||
- macOS 14.6
|
||||
- Windows Server 2022
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro_code
|
||||
attributes:
|
||||
label: 4. 可复现代码/配置
|
||||
description: 请提供最小可复现代码、请求参数或配置(敏感信息请脱敏)
|
||||
render: shell
|
||||
placeholder: |
|
||||
# 最小复现步骤
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
# 关键请求/配置(脱敏后)
|
||||
curl -X POST https://xxx/api/...
|
||||
-H "Authorization: Bearer ***"
|
||||
-d '{"amount":100,"order_id":"test-001"}'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: stacktrace
|
||||
attributes:
|
||||
label: 5. 错误日志 / 堆栈信息
|
||||
description: 请粘贴完整报错信息、状态码、关键日志
|
||||
render: shell
|
||||
placeholder: |
|
||||
例如:
|
||||
- HTTP 状态码:
|
||||
- 错误码:
|
||||
- 错误堆栈:
|
||||
- 关键日志片段:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 可补充截图、发生时间、复现概率、已尝试修复动作等
|
||||
placeholder: 其他你认为有帮助的信息(可选)
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 安全漏洞报告
|
||||
url: https://github.com/ginta/epusdt/security/advisories/new
|
||||
about: 请不要公开提交安全漏洞,请通过 GitHub Security Advisory 私下报告
|
||||
@@ -1,57 +0,0 @@
|
||||
name: docker-alpine-epusdt
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "master"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
DOCKERHUB_NAMESPACE: gmwallet
|
||||
IMAGE_NAME: epusdt
|
||||
|
||||
jobs:
|
||||
push-docker-hub:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch,suffix=-alpine
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=sha-,suffix=-alpine
|
||||
|
||||
- name: Login To Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build And Push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,86 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "要发布的 tag(例如 v1.2.3,必须已存在)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
name: Build And Publish Binaries
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Ensure Tag Commit Is On Default Branch
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
git fetch origin "$DEFAULT_BRANCH" --depth=1
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then
|
||||
echo "tag ${GITHUB_REF_NAME} must reference a commit reachable from ${DEFAULT_BRANCH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set Up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: src/go.mod
|
||||
|
||||
- name: Select Previous Stable Tag For Final Releases
|
||||
run: |
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
if [[ "$current_tag" == *-* ]]; then
|
||||
echo "prerelease tag detected: $current_tag"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
previous_stable_tag="$(
|
||||
git tag --list 'v*' --sort=-version:refname |
|
||||
grep -Fxv "$current_tag" |
|
||||
grep -Ev -- '-' |
|
||||
head -n 1
|
||||
)"
|
||||
|
||||
if [[ -n "$previous_stable_tag" ]]; then
|
||||
echo "using previous stable tag: $previous_stable_tag"
|
||||
echo "GORELEASER_PREVIOUS_TAG=$previous_stable_tag" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no previous stable tag found"
|
||||
fi
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
workdir: src
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attest Release Artifacts
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-checksums: src/dist/SHA256SUMS
|
||||
@@ -1,8 +1,4 @@
|
||||
.idea/
|
||||
src/runtime/
|
||||
src/.env
|
||||
src/*.db
|
||||
src/*.db-shm
|
||||
src/*.db-wal
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# Development Standard
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the default development goals, review criteria, and testing constraints for changes in this repository.
|
||||
|
||||
Before starting any implementation, use this document as a checklist.
|
||||
|
||||
## Development Goals
|
||||
|
||||
Every change should be judged against all of the following goals:
|
||||
|
||||
1. Functional completeness
|
||||
The target feature must actually work end to end, not just compile.
|
||||
|
||||
2. No accidental teammate regression
|
||||
Do not accidentally remove or override teammate capabilities that already exist on the base branch.
|
||||
|
||||
3. Robustness
|
||||
The code should handle normal failure cases, retries, state conflicts, and restart scenarios as reasonably as possible.
|
||||
|
||||
4. Elegance
|
||||
Prefer clear responsibilities, minimal coupling, and a small number of well-named moving parts.
|
||||
|
||||
5. Maintainability
|
||||
Avoid hidden behavior, confusing configuration, or implementation details that make future debugging harder.
|
||||
|
||||
6. No dirty output
|
||||
Never commit local runtime artifacts, local databases, caches, logs, or other development byproducts.
|
||||
|
||||
7. No mojibake
|
||||
Source files, messages, and docs should not contain broken encoding text.
|
||||
|
||||
## Multi-Dimensional Self Review
|
||||
|
||||
Each meaningful change should be evaluated across these dimensions:
|
||||
|
||||
- functional completeness
|
||||
- engineering implementation quality
|
||||
- robustness
|
||||
- elegance
|
||||
- maintainability
|
||||
- regression risk
|
||||
- commit readiness
|
||||
- test coverage quality
|
||||
|
||||
Scoring should be honest. High scores require both working behavior and clean delivery quality.
|
||||
|
||||
## Code Quality Rules
|
||||
|
||||
### Redundancy
|
||||
|
||||
Avoid redundant code unless duplication is clearly cheaper and safer than abstraction.
|
||||
|
||||
The following are considered bad redundancy:
|
||||
|
||||
- duplicate business logic with only tiny naming differences
|
||||
- duplicate retry or state logic in multiple places
|
||||
- helper functions that exist only to wrap one line without improving clarity
|
||||
|
||||
The following are acceptable:
|
||||
|
||||
- small data shapes for read optimization
|
||||
- isolated helpers that remove repeated infrastructure setup
|
||||
- compatibility shims with clear removal intent
|
||||
|
||||
### Elegance
|
||||
|
||||
Code is considered elegant when:
|
||||
|
||||
- responsibilities are split cleanly
|
||||
- names explain intent
|
||||
- state transitions are explicit
|
||||
- configuration is understandable
|
||||
- behavior is observable through useful logs
|
||||
- failure handling does not hide important problems
|
||||
|
||||
### Robustness
|
||||
|
||||
Code is considered robust when:
|
||||
|
||||
- state changes are guarded by conditions where needed
|
||||
- retries are controlled, not infinite
|
||||
- restart recovery is possible where it matters
|
||||
- local concurrency does not easily corrupt behavior
|
||||
- failure in one side task does not silently break the main flow
|
||||
|
||||
## Bug Standard
|
||||
|
||||
Ask these questions before treating a change as finished:
|
||||
|
||||
- Is there any blocking functional bug?
|
||||
- Is there any likely edge-case bug?
|
||||
- Is there any operational bug under realistic local concurrency?
|
||||
- Is there any user-visible regression?
|
||||
|
||||
If a bug is known, it must be called out explicitly in review notes.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
### Required Principle
|
||||
|
||||
Tests must validate behavior, not re-implement the code under test.
|
||||
|
||||
### Forbidden Test Types
|
||||
|
||||
Do not write:
|
||||
|
||||
- invalid tests
|
||||
- mirror tests
|
||||
- tests that only repeat implementation branches line by line
|
||||
- tests that only verify mocks interacted in the same order as the code was written
|
||||
- tests that lock onto incidental internals instead of real behavior
|
||||
|
||||
### Preferred Test Types
|
||||
|
||||
Prefer:
|
||||
|
||||
- end-to-end behavior tests
|
||||
- state transition tests
|
||||
- concurrency conflict tests
|
||||
- retry and recovery tests
|
||||
- expiration and timing behavior tests
|
||||
- persistence and restart-oriented tests where relevant
|
||||
|
||||
### Test Coverage Expectations
|
||||
|
||||
Coverage quality matters more than raw coverage count.
|
||||
|
||||
For core flows, tests should cover:
|
||||
|
||||
- normal success path
|
||||
- important failure path
|
||||
- state conflict or idempotency path
|
||||
- retry or delayed processing path where relevant
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before considering a change ready, confirm:
|
||||
|
||||
- functionality is complete
|
||||
- teammate capability was not accidentally lost
|
||||
- no local artifact will be committed
|
||||
- no mojibake exists
|
||||
- no obvious redundant code exists
|
||||
- no known blocking bug remains
|
||||
- robustness is acceptable for the target deployment model
|
||||
- tests are behavior-based and not mirror tests
|
||||
- review notes clearly explain intentional semantic changes
|
||||
|
||||
## Current Project Direction
|
||||
|
||||
For this repository specifically, current review should assume:
|
||||
|
||||
- single-instance deployment is the primary target unless stated otherwise
|
||||
- runtime semantics may intentionally differ from older Redis and asynq behavior when the new SQLite design is deliberate
|
||||
- any such semantic difference must be explained in merge or review notes
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN apk add --no-cache --update git build-base
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app/src
|
||||
RUN go mod download
|
||||
RUN go build -o /app/epusdt .
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
ARG API_RATE_URL=""
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/src/static /app/static
|
||||
COPY --from=builder /app/src/static /static
|
||||
COPY --from=builder /app/src/.env.example /app/.env
|
||||
RUN if [ -n "$API_RATE_URL" ]; then \
|
||||
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
|
||||
fi
|
||||
COPY --from=builder /app/epusdt .
|
||||
|
||||
VOLUME /app/conf
|
||||
ENTRYPOINT ["./epusdt", "http", "start"]
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,170 +1,73 @@
|
||||
# Epusdt — Easy Payment USDT
|
||||
|
||||
## Epusdt (Easy Payment Usdt)
|
||||
<p align="center">
|
||||
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||
<img src="wiki/img/usdtlogo.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPLV3-blue" alt="license GPLV3"></a>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Golang-1.16-red" alt="Go version 1.16"></a>
|
||||
<a href="https://echo.labstack.com"><img src="https://img.shields.io/badge/Echo Framework-v4-blue" alt="Echo Framework v4"></a>
|
||||
<a href="https://github.com/tucnak/telebot"><img src="https://img.shields.io/badge/Telebot Framework-v3-lightgrey" alt="Telebot Framework-v3"></a>
|
||||
<a href="https://github.com/assimon/epusdt/releases/tag/v0.0.1"><img src="https://img.shields.io/badge/version-v0.0.1-green" alt="version v0.0.1"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
||||
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-交流群-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
|
||||
</p>
|
||||
## 项目简介
|
||||
`Epusdt`(全称:Easy Payment Usdt)是一个由`Go语言`编写的私有化部署`Usdt`支付中间件(`Trc20网络`)
|
||||
站长或开发者可通过`Epusdt`提供的`http api`集成至您的任何系统,无需过多的配置,仅仅依赖`mysql`和`redis`
|
||||
可实现USDT的在线支付和消息回调,这一切在优雅和顷刻间完成!🎉
|
||||
私有化搭建使得无需额外的手续费和签约费用,Usdt代币直接进入您的钱包💰
|
||||
`Epusdt` 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
|
||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
|
||||
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌍 What is Epusdt?
|
||||
|
||||
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
|
||||
|
||||
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
|
||||
|
||||
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
|
||||
|
||||
### 🔗 已支持网络与代币
|
||||
|
||||
| 网络 | 代币 |
|
||||
|------|------|
|
||||
| **TRC20** (Tron) | USDT、TRX|
|
||||
| **ERC20** (Ethereum) | USDT、USDC、ETH |
|
||||
| **Solana** | USDT、USDC |
|
||||
| **BEP20** (BSC) | USDT、USDC、BNB |
|
||||
| **Polygon** | USDT、USDC |
|
||||
| **更多** | 持续扩展中… |
|
||||
|
||||
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 🔌 广泛兼容,即插即用
|
||||
|
||||
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
||||
|
||||
| 领域 | 已支持系统 |
|
||||
|------|-----------|
|
||||
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
||||
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
||||
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
|
||||
| **更多** | 简易HTTP API 10分钟内接入 |
|
||||
|
||||
👉 查看更多集成列表与插件:[plugins/](plugins/)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
|
||||
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
|
||||
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
|
||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
|
||||
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
|
||||
- **异步队列** — 高性能消息回调,优雅处理高并发场景
|
||||
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
|
||||
- **Telegram Bot** — 实时支付通知,快捷管理与监控
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档与教程
|
||||
|
||||
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
|
||||
|
||||
快速入门:
|
||||
|
||||
| 教程 | 说明 |
|
||||
|------|------|
|
||||
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
|
||||
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
|
||||
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
|
||||
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 项目结构
|
||||
## 项目特点
|
||||
- 支持私有化部署,无需担心钱包被篡改和吞单😁
|
||||
- `Go语言`跨平台实现,支持x86和arm芯片架构的win/linux设备
|
||||
- 多钱包地址轮询,提高订单并发率
|
||||
- 异步队列响应,优雅及高性能
|
||||
- 无需额外环境配置,仅运行一个编译后二进制文件即可使用
|
||||
- 支持`http api`,其他系统亦可接入
|
||||
- `Telegram`机器人接入,便捷使用和支付消息快速通知
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
Epusdt
|
||||
├── plugins/ → 已集成的系统插件(独角数卡等)
|
||||
├── src/ → 项目核心代码
|
||||
├── sdk/ → 接入 SDK
|
||||
├── sql/ → 数据库安装 / 升级脚本
|
||||
└── wiki/ → 文档与知识库
|
||||
├── plugins ---> (已集成的插件库,例如dujiaoka)
|
||||
├── src ---> (项目核心目录)
|
||||
├── sdk ---> (接入SDK)
|
||||
├── sql ---> (安装sql文件或更新sql文件)
|
||||
└── wiki ---> (知识库)
|
||||
```
|
||||
|
||||
---
|
||||
## 教程:
|
||||
- 宝塔运行`epusdt`教程👉🏻[宝塔运行epusdt](wiki/BT_RUN.md)
|
||||
- 开发者接入`epusdt`文档👉🏻[开发者接入epusdt](wiki/API.md)
|
||||
|
||||
## 🔧 实现原理
|
||||
## 已适配系统插件
|
||||
- 独角数卡[插件地址](plugins/dujiaoka)
|
||||
|
||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||
|
||||
## 设计实现
|
||||
`Epusdt`的实现方式与其他项目原理类似,都是通过监听`trc20`网络的api或节点,
|
||||
监听钱包地址`usdt`代币入账事件,通过`金额差异`和`时效性`来判定交易归属信息,
|
||||
可参考下方`流程图`
|
||||
```
|
||||
工作流程:
|
||||
1. 客户发起支付,需支付 20.05 USDT
|
||||
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
||||
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
||||
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||
简单的原理:
|
||||
1.客户需要支付20.05usdt
|
||||
2.服务器有一个hash表存储钱包地址对应的待支付金额 例如:address_1 : 20.05
|
||||
3.发起支付的时候,我们可以判定钱包address_1的20.05金额是否被占用,如果没有被占用那么可以直接返回这个钱包地址和金额给客户,告知客户需按规定金额20.05准确支付,少一分都不行。且将钱包地址和金额 address_1:20.05锁起来,有效期10分钟。
|
||||
4.如果订单并发下,又有一个20.05元需要支付,但是在第3步的时候上一个客户已经锁定了该金额,还在等待支付中...,那么我们将待支付金额加上0.0001,再次尝试判断address_1:20.0501金额是否被占用?如果没有则重复第三步,如果还是被占用就继续累加尝试,直到加了100次后都失败
|
||||
5.新开一个线程去监听所有钱包的USDT入账事件,网上有公开的api或rpc节点。如果发现有入账金额与待支付的金额相等。则判断该笔订单支付成功!
|
||||
```
|
||||
### 流程图:
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 💬 社区与支持
|
||||
|
||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
||||
|
||||
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
||||
|
||||
| 渠道 | 链接 |
|
||||
|------|------|
|
||||
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?type=date&repos=gmwalletapp%2Fepusdt">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 📜 开源协议
|
||||
|
||||
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
|
||||
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
|
||||
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
|
||||
</sub>
|
||||
</p>
|
||||
## 声明
|
||||
`Epusdt`为开源的产品,仅用于学习交流使用!
|
||||
不可用于任何违反中华人民共和国(含台湾省)或使用者所在地区法律法规的用途。
|
||||
因为作者即本人仅完成代码的开发和开源活动(开源即任何人都可以下载使用或修改分发),从未参与用户的任何运营和盈利活动。
|
||||
且不知晓用户后续将程序源代码用于何种用途,故用户使用过程中所带来的任何法律责任即由用户自己承担。
|
||||
```
|
||||
!!!Warning!!!
|
||||
项目中所涉及区块链代币均为学习用途,作者并不赞成区块链所繁衍出代币的金融属性
|
||||
亦不鼓励和支持任何"挖矿","炒币","虚拟币ICO"等非法行为
|
||||
虚拟币市场行为不受监管要求和控制,投资交易需谨慎,仅供学习区块链知识
|
||||
```
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Releasing
|
||||
|
||||
This repository publishes versioned binaries to GitHub Releases when a tag is pushed.
|
||||
|
||||
## What gets published
|
||||
|
||||
For each tag like `v1.2.3`, GitHub Actions builds release archives for:
|
||||
|
||||
- `linux/amd64`
|
||||
- `linux/arm64`
|
||||
- `darwin/amd64`
|
||||
- `darwin/arm64`
|
||||
- `windows/amd64`
|
||||
- `windows/arm64`
|
||||
|
||||
Each archive includes:
|
||||
|
||||
- the `epusdt` binary
|
||||
- `src/.env.example`
|
||||
- `src/static/`
|
||||
|
||||
Windows artifacts are published as `.zip`. Other platforms are published as `.tar.gz`.
|
||||
|
||||
Each release also publishes:
|
||||
|
||||
- a `SHA256SUMS` checksum file
|
||||
- a GitHub build provenance attestation for the checksum file
|
||||
|
||||
## How to cut a release
|
||||
|
||||
1. Make sure the release commit has already landed on the default branch.
|
||||
2. Create an annotated tag:
|
||||
|
||||
```bash
|
||||
git tag -a v1.0.0 -m "release v1.0.0"
|
||||
```
|
||||
|
||||
3. Push the tag:
|
||||
|
||||
```bash
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
4. Wait for the `release` workflow to finish on GitHub.
|
||||
5. Download the generated binaries from the GitHub Release page.
|
||||
|
||||
## Verify a release
|
||||
|
||||
Verify the GitHub build provenance attestation:
|
||||
|
||||
```bash
|
||||
gh attestation verify SHA256SUMS -R GMwalletApp/epusdt
|
||||
```
|
||||
|
||||
## Local validation
|
||||
|
||||
Run a snapshot build locally before pushing a tag:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean
|
||||
```
|
||||
|
||||
The resulting artifacts will be generated under `src/dist/`.
|
||||
|
||||
## Release candidates
|
||||
|
||||
If you use tags like `v1.2.3-rc1`, GoReleaser will mark the GitHub release as a prerelease.
|
||||
|
||||
For a final tag like `v1.2.3`, the release workflow forces GoReleaser to compare against the previous stable tag instead of the latest `rc` tag. This keeps the final changelog focused on stable-to-stable changes.
|
||||
@@ -1,208 +0,0 @@
|
||||
# Review
|
||||
|
||||
## Scope
|
||||
|
||||
This review checks `dev-runtime` against `dev` with the following standards:
|
||||
|
||||
- Do not accidentally remove or override teammate payment capabilities already merged into `dev`.
|
||||
- Do not submit local test artifacts or dirty files.
|
||||
- Confirm Redis -> SQLite replacement is functionally complete for the intended single-instance target.
|
||||
- Judge code quality, elegance, robustness, and maintainability.
|
||||
- Check for real bugs, mojibake, and redundant code.
|
||||
- Compare current quality with the previous `dev` implementation.
|
||||
|
||||
These standards are also the default development constraints for future work:
|
||||
|
||||
- no invalid tests
|
||||
- no mirror tests
|
||||
- prefer behavior-based tests over implementation-shaped tests
|
||||
- explain intentional semantic changes in review notes
|
||||
- verify dirty local artifacts are excluded before commit
|
||||
- think about teammate-regression risk before coding, not only after coding
|
||||
|
||||
## Files That Must Not Be Committed
|
||||
|
||||
These local SQLite artifacts must never be committed:
|
||||
|
||||
- `src/epusdt.db`
|
||||
- `src/epusdt.db-shm`
|
||||
- `src/epusdt.db-wal`
|
||||
|
||||
They are now excluded by `.gitignore`.
|
||||
|
||||
## Teammate Capabilities Still Preserved
|
||||
|
||||
The following teammate payment capabilities are still present:
|
||||
|
||||
- multi-currency order model: `src/model/mdb/orders_mdb.go`
|
||||
- wallet address model: `src/model/mdb/wallet_address_mdb.go`
|
||||
- native `TRX` transfer detection: `src/model/service/task_service.go`
|
||||
- `TRC20 USDT` detection: `src/model/service/task_service.go`
|
||||
- dynamic exchange-rate path: `src/model/service/order_service.go`, `src/config/config.go`
|
||||
- `TRON_GRID_API_KEY` support: `src/config/config.go`
|
||||
|
||||
Conclusion: teammate core payment capabilities were not accidentally removed.
|
||||
|
||||
## Intentional Architecture Changes
|
||||
|
||||
These are intentional refactors, not accidental overrides:
|
||||
|
||||
- Redis runtime removed: `src/model/dao/rdb.go`
|
||||
- asynq handlers removed: `src/mq/handle/callback_queue.go`, `src/mq/handle/order_expiration_queue.go`
|
||||
- SQLite runtime lock introduced: `src/model/mdb/transaction_lock_mdb.go`
|
||||
- SQLite scheduler introduced: `src/mq/queue.go`, `src/mq/worker.go`
|
||||
- reservation moved from Redis key to SQLite unique constraint: `src/model/data/order_data.go`
|
||||
|
||||
Conclusion: this branch keeps payment capability but changes the runtime implementation.
|
||||
|
||||
## Previously Regressed Parts That Are Now Restored
|
||||
|
||||
The following regressions have been restored in the current working tree:
|
||||
|
||||
- rich Telegram payment notification template: `src/model/service/task_service.go`
|
||||
- key chain-scan observability logs: `src/model/service/task_service.go`
|
||||
- Chinese checkout error message: `src/model/service/pay_service.go`
|
||||
- more reliable Telegram add-wallet flow: `src/telegram/handle.go`
|
||||
|
||||
## Functional Completion
|
||||
|
||||
For the single-instance target, the Redis -> SQLite replacement is complete across:
|
||||
|
||||
- order creation
|
||||
- amount reservation
|
||||
- chain scan matching
|
||||
- payment success processing
|
||||
- order expiration
|
||||
- callback scheduling
|
||||
- Telegram notification
|
||||
|
||||
Conclusion: feature work is complete for the intended single-instance model.
|
||||
|
||||
## Mojibake Check
|
||||
|
||||
No real mojibake was found in the source files checked during review.
|
||||
|
||||
Files spot-checked:
|
||||
|
||||
- `src/telegram/handle.go`
|
||||
- `src/model/service/task_service.go`
|
||||
- `src/model/service/pay_service.go`
|
||||
|
||||
Note: some terminals may render UTF-8 Chinese poorly, but the source content itself is fine.
|
||||
|
||||
## Redundant Code Check
|
||||
|
||||
No obvious redundant business code was found.
|
||||
|
||||
Recent helper additions are justified:
|
||||
|
||||
- `PendingCallbackOrder`
|
||||
- `expirableOrder`
|
||||
- `configureSQLite`
|
||||
|
||||
Conclusion: no clear "fix by duplication" smell was found.
|
||||
|
||||
## Bug Assessment
|
||||
|
||||
No confirmed blocking business bug is currently visible in the reviewed code path.
|
||||
|
||||
Important risk that still needs attention:
|
||||
|
||||
- SQLite main DB can still hit `SQLITE_BUSY` under concurrent polling and writes.
|
||||
|
||||
This risk is now being mitigated by:
|
||||
|
||||
- `src/model/dao/sqlite_config.go`
|
||||
- `src/model/dao/mdb_sqlite.go`
|
||||
- `src/model/dao/runtime_sqlite.go`
|
||||
- lightweight busy retry in `src/mq/worker.go`
|
||||
|
||||
Conclusion: no confirmed blocking functional bug, but SQLite concurrency remains a real operational boundary that should still be watched in runtime.
|
||||
|
||||
## Robustness Assessment
|
||||
|
||||
Strengths:
|
||||
|
||||
- conditional state transitions prevent reviving expired orders
|
||||
- reservation is now a persistent unique constraint instead of a temporary Redis key
|
||||
- callback state is durable across process restarts
|
||||
- behavior tests exist for key paths:
|
||||
- `src/model/service/order_service_test.go`
|
||||
- `src/mq/worker_test.go`
|
||||
- callback polling now has a small targeted retry for transient SQLite busy errors
|
||||
|
||||
Remaining limits:
|
||||
|
||||
- robust for single-instance deployment
|
||||
- not a final design for multi-instance deployment
|
||||
- SQLite contention should still be watched in real runtime
|
||||
|
||||
Conclusion: robust enough for single-instance use, not a direct multi-instance design.
|
||||
|
||||
## Elegance Assessment
|
||||
|
||||
Strengths:
|
||||
|
||||
- runtime responsibility is cleaner with SQLite reservation + scheduler
|
||||
- logging responsibility is split more clearly:
|
||||
- `log_level`
|
||||
- `http_access_log`
|
||||
- `sql_debug`
|
||||
- Telegram input flow is more practical for real clients
|
||||
- SQLite tuning is extracted into one helper
|
||||
|
||||
Tradeoffs:
|
||||
|
||||
- callback semantics are no longer the same as the old immediate-asynq enqueue path
|
||||
- current branch still has local uncommitted fixes that must be organized into a clean commit set
|
||||
|
||||
Conclusion: elegant for the chosen single-instance direction, but must be explained clearly when merging.
|
||||
|
||||
## Quality Compared With Previous Code
|
||||
|
||||
Overall judgment: quality did not go down; it improved in the single-instance direction.
|
||||
|
||||
Improvements:
|
||||
|
||||
- stronger state-machine constraints
|
||||
- better log control
|
||||
- more durable runtime reservation
|
||||
- better Telegram interaction resilience
|
||||
- added behavior tests
|
||||
- added restart-oriented callback recovery coverage
|
||||
- reduced scheduler fragility under transient SQLite busy errors
|
||||
|
||||
Changed semantics rather than degradation:
|
||||
|
||||
- merchant callback is now polled by SQLite scheduler instead of immediate asynq enqueue
|
||||
|
||||
Conclusion: quality is not lower, but runtime semantics changed and must be stated explicitly.
|
||||
|
||||
## Self Score
|
||||
|
||||
- functional completeness: 9.1/10
|
||||
- engineering implementation: 8.9/10
|
||||
- robustness: 8.7/10
|
||||
- elegance: 9.0/10
|
||||
- commit readiness: 8.8/10
|
||||
|
||||
Overall self score: 8.9/10
|
||||
|
||||
Main deductions:
|
||||
|
||||
- current uncommitted fixes still need to be organized into a clean commit
|
||||
- SQLite single-file concurrency boundary still exists
|
||||
- callback semantics differ from `dev-payment` and require clear communication
|
||||
|
||||
## Final Conclusion
|
||||
|
||||
This code can be submitted, but only if all of the following are done:
|
||||
|
||||
1. Do not commit local SQLite artifacts.
|
||||
2. Include the current uncommitted fixes in a clean, intentional commit set.
|
||||
3. Explain clearly in merge notes:
|
||||
- what teammate capabilities were preserved
|
||||
- what runtime behavior was intentionally refactored
|
||||
- what semantics changed and why
|
||||
|
||||
If those three conditions are met, this is not a hidden override of teammate code. It is a reviewed and documented refactor.
|
||||
@@ -1,11 +0,0 @@
|
||||
services:
|
||||
epusdt:
|
||||
image: gmwallet/epusdt:latest
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./env:/app/.env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author GMWalletApp<ashang@utf8.hk>
|
||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* The file was created by Assimon.
|
||||
*
|
||||
* @author GMWalletApp<ashang@utf8.hk>
|
||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
||||
* @author assimon<ashang@utf8.hk>
|
||||
* @copyright assimon<ashang@utf8.hk>
|
||||
* @link http://utf8.hk/
|
||||
*/
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
+39
-38
@@ -1,59 +1,60 @@
|
||||
app_name=epusdt
|
||||
app_uri=https://epusdt.com
|
||||
log_level=info
|
||||
http_access_log=false
|
||||
sql_debug=false
|
||||
#下面配置你的域名,收银台会需要
|
||||
app_uri=https://dujiaoka.com
|
||||
#是否开启debug,默认false
|
||||
app_debug=false
|
||||
#http服务监听端口
|
||||
http_listen=:8000
|
||||
|
||||
#静态资源文件目录
|
||||
static_path=/static
|
||||
runtime_root_path=./runtime
|
||||
#缓存路径
|
||||
runtime_root_path=/runtime
|
||||
|
||||
log_save_path=./logs
|
||||
#日志配置
|
||||
log_save_path=/logs
|
||||
log_max_size=32
|
||||
log_max_age=7
|
||||
max_backups=3
|
||||
|
||||
# supported values: postgres,mysql,sqlite
|
||||
db_type=sqlite
|
||||
|
||||
# sqlite primary database config
|
||||
sqlite_database_filename=
|
||||
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配置
|
||||
mysql_host=127.0.0.1
|
||||
mysql_port=3306
|
||||
mysql_user=mysql_user
|
||||
mysql_passwd=mysql_password
|
||||
mysql_database=database_name
|
||||
mysql_user=mysql账号
|
||||
mysql_passwd=mysql密码
|
||||
mysql_database=数据库
|
||||
mysql_table_prefix=
|
||||
mysql_max_idle_conns=10
|
||||
mysql_max_open_conns=100
|
||||
mysql_max_life_time=6
|
||||
|
||||
# sqlite runtime store config
|
||||
runtime_sqlite_filename=epusdt-runtime.db
|
||||
# redis配置
|
||||
redis_host=127.0.0.1
|
||||
redis_port=6379
|
||||
redis_passwd=
|
||||
redis_db=5
|
||||
redis_pool_size=5
|
||||
redis_max_retries=3
|
||||
redis_idle_timeout=1000
|
||||
|
||||
# background scheduler config
|
||||
# 消息队列配置
|
||||
queue_concurrency=10
|
||||
queue_poll_interval_ms=1000
|
||||
callback_retry_base_seconds=5
|
||||
queue_level_critical=6
|
||||
queue_level_default=3
|
||||
queue_level_low=1
|
||||
|
||||
#机器人Apitoken
|
||||
tg_bot_token=
|
||||
#telegram代理url(大陆地区服务器可使用一台国外服务器做反代tg的url),如果运行的本来就是境外服务器,则无需填写
|
||||
tg_proxy=
|
||||
#管理员userid
|
||||
tg_manage=
|
||||
|
||||
#api接口认证token
|
||||
api_auth_token=
|
||||
|
||||
#订单过期时间(单位分钟)
|
||||
order_expiration_time=10
|
||||
order_notice_max_retry=0
|
||||
|
||||
api_rate_url=
|
||||
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=true
|
||||
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
|
||||
forced_usdt_rate=
|
||||
@@ -1,3 +1,2 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
*.log
|
||||
+24
-43
@@ -1,62 +1,43 @@
|
||||
version: 2
|
||||
|
||||
project_name: epusdt
|
||||
|
||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
- go test ./...
|
||||
|
||||
# You may remove this if you don't use go modules.
|
||||
#- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
#- go generate ./...
|
||||
builds:
|
||||
- id: epusdt
|
||||
main: .
|
||||
binary: epusdt
|
||||
env:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
#- arm
|
||||
#- 386
|
||||
archives:
|
||||
- id: epusdt
|
||||
ids:
|
||||
- epusdt
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
id: epusdt
|
||||
name_template: "{{ .ProjectName }}_v0.0.1_{{ .Os }}_{{ .Arch }}"
|
||||
format: tar.gz
|
||||
files:
|
||||
- .env.example
|
||||
- static/**
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
|
||||
- static/*
|
||||
- runtime/*
|
||||
checksum:
|
||||
name_template: "SHA256SUMS"
|
||||
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: GMwalletApp
|
||||
name: epusdt
|
||||
prerelease: auto
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
- "^ci:"
|
||||
- "^fix\\(ci\\):"
|
||||
- "^build\\(release\\):"
|
||||
- "^Merge pull request"
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
|
||||
+22
-54
@@ -1,65 +1,33 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/mq"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/assimon/luuu/command"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/mq"
|
||||
"github.com/assimon/luuu/task"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
)
|
||||
|
||||
var initOnce sync.Once
|
||||
|
||||
func InitApp() {
|
||||
initOnce.Do(func() {
|
||||
//Start 服务启动
|
||||
func Start() {
|
||||
// 配置加载
|
||||
config.Init()
|
||||
// 日志加载
|
||||
log.Init()
|
||||
dao.Init()
|
||||
// Wire settings-table lookups into the config package so
|
||||
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||
config.SettingsGetString = func(key string) string {
|
||||
return data.GetSettingString(key, "")
|
||||
}
|
||||
// Seed rate.api_url from .env into the settings table on first run
|
||||
// so the admin UI can display and change it without a code restart.
|
||||
// Only written if the key is not already present in the DB.
|
||||
if data.GetSettingString("rate.api_url", "") == "" {
|
||||
if envURL := config.GetRateApiUrlFromEnv(); envURL != "" {
|
||||
if err := data.SetSetting("rate", "rate.api_url", envURL, "string"); err != nil {
|
||||
color.Red.Printf("[bootstrap] seed rate.api_url err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed admin account and JWT secret so the management console is
|
||||
// immediately usable on a fresh install. Both are idempotent.
|
||||
_, isNew, err := data.EnsureDefaultAdmin()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||
}
|
||||
if isNew {
|
||||
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
|
||||
color.Yellow.Printf("║ Username: admin ║\n")
|
||||
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||
}
|
||||
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure jwt secret err=%s\n", err)
|
||||
}
|
||||
// Seed one universal default API key on fresh installs. The seeded
|
||||
// key (PID=1000) works for all three gateway flows.
|
||||
_, err = data.EnsureDefaultApiKey()
|
||||
if err != nil {
|
||||
color.Red.Printf("[bootstrap] ensure default api key err=%s\n", err)
|
||||
}
|
||||
// Mysql启动
|
||||
dao.MysqlInit()
|
||||
// redis启动
|
||||
dao.RedisInit()
|
||||
// 队列启动
|
||||
mq.Start()
|
||||
// telegram机器人启动
|
||||
go telegram.BotStart()
|
||||
// 定时任务
|
||||
go task.Start()
|
||||
})
|
||||
err := command.Execute()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/gookit/color"
|
||||
)
|
||||
|
||||
func printBanner() {
|
||||
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
|
||||
color.Infof(
|
||||
"Epusdt version(%s) commit(%s) built(%s) Powered by %s %s \n",
|
||||
config.GetAppVersion(),
|
||||
config.GetBuildCommit(),
|
||||
config.GetBuildDate(),
|
||||
"GMwalletApp",
|
||||
"https://github.com/GMwalletApp/epusdt",
|
||||
)
|
||||
}
|
||||
+27
-75
@@ -2,31 +2,26 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/bootstrap"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/install"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/route"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/middleware"
|
||||
"github.com/assimon/luuu/route"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
luluHttp "github.com/assimon/luuu/util/http"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
)
|
||||
|
||||
var httpCmd = &cobra.Command{
|
||||
Use: "http",
|
||||
Short: "http service",
|
||||
Long: "http service commands",
|
||||
Short: "http服务",
|
||||
Long: "http服务相关命令",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
},
|
||||
}
|
||||
@@ -37,19 +32,9 @@ func init() {
|
||||
|
||||
var startCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "start",
|
||||
Long: "start http service",
|
||||
Short: "启动",
|
||||
Long: "启动http服务",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// If no config file exists, or if install=true is set in the config,
|
||||
// run the first-run install API on the same port as the main server.
|
||||
// The wizard writes the .env (with install=false) and shuts itself
|
||||
// down so bootstrap.InitApp() can read it normally on the same port.
|
||||
if config.NeedsInstall() {
|
||||
envPath, _ := config.ResolveConfigPath()
|
||||
install.RunInstallServer(install.DefaultInstallAddr, envPath)
|
||||
}
|
||||
bootstrap.InitApp()
|
||||
printBanner()
|
||||
HttpServerStart()
|
||||
},
|
||||
}
|
||||
@@ -59,45 +44,23 @@ func HttpServerStart() {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
// 中间件注册
|
||||
MiddlewareRegister(e)
|
||||
// 路由注册
|
||||
route.RegisterRoute(e)
|
||||
e.Static(config.StaticPath, config.StaticFilePath)
|
||||
|
||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||
// of the working directory. main.go extracts www/ next to the binary.
|
||||
wwwRoot := "./www"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||
}
|
||||
}
|
||||
e.Use(echoMiddleware.StaticWithConfig(echoMiddleware.StaticConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
path := c.Request().URL.Path
|
||||
if path == "/install" || strings.HasPrefix(path, "/install/") {
|
||||
// The install wizard is only served by install.RunInstallServer
|
||||
// before bootstrap. Once main server starts, block /install.
|
||||
return true
|
||||
}
|
||||
return luluHttp.ShouldSkipSPAFallback(path)
|
||||
},
|
||||
HTML5: true,
|
||||
Index: "index.html",
|
||||
Root: wwwRoot,
|
||||
}))
|
||||
|
||||
// 静态目录注册
|
||||
e.Static(config.StaticPath, "static")
|
||||
httpListen := viper.GetString("http_listen")
|
||||
go func() {
|
||||
if err = e.Start(httpListen); err != http.ErrServerClosed {
|
||||
log.Sugar.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
|
||||
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, os.Kill)
|
||||
<-quit
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err = e.Shutdown(ctx); err != nil {
|
||||
@@ -105,13 +68,16 @@ func HttpServerStart() {
|
||||
}
|
||||
}
|
||||
|
||||
// MiddlewareRegister 中间件注册
|
||||
func MiddlewareRegister(e *echo.Echo) {
|
||||
if config.HTTPAccessLog {
|
||||
if config.AppDebug {
|
||||
e.Debug = true
|
||||
e.Use(echoMiddleware.Logger())
|
||||
}
|
||||
e.Use(middleware.RequestUUID())
|
||||
}
|
||||
|
||||
// customHTTPErrorHandler 默认消息提示
|
||||
func customHTTPErrorHandler(err error, e echo.Context) {
|
||||
code := http.StatusInternalServerError
|
||||
msg := "server error"
|
||||
@@ -120,28 +86,14 @@ func customHTTPErrorHandler(err error, e echo.Context) {
|
||||
Message: msg,
|
||||
RequestID: e.Request().Header.Get(echo.HeaderXRequestID),
|
||||
}
|
||||
// echo.HTTPError carries a real HTTP status (401 for auth failures,
|
||||
// 404 for missing routes, etc.). Propagate it instead of flattening
|
||||
// everything to 200 — clients rely on the status code.
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
resp.StatusCode = he.Code
|
||||
if s, ok := he.Message.(string); ok {
|
||||
resp.Message = s
|
||||
} else if he.Message != nil {
|
||||
resp.Message = http.StatusText(he.Code)
|
||||
}
|
||||
_ = e.JSON(he.Code, resp)
|
||||
e.String(http.StatusOK, he.Message.(string))
|
||||
return
|
||||
}
|
||||
// Internal RspError: propagate Code as both the JSON status_code and
|
||||
// the real HTTP status when it maps to one (400/401/...); business
|
||||
// codes (>=1000) map to HTTP 400 so clients get a proper 4xx while
|
||||
// still reading the granular code from the body.
|
||||
if he, ok := err.(*constant.RspError); ok {
|
||||
resp.StatusCode = he.Code
|
||||
resp.Message = he.Msg
|
||||
_ = e.JSON(he.HttpStatus(), resp)
|
||||
}
|
||||
_ = e.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
_ = e.JSON(http.StatusInternalServerError, resp)
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,11 +1,6 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
var rootCmd = &cobra.Command{}
|
||||
|
||||
@@ -14,9 +9,5 @@ func Execute() error {
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to .env or directory containing .env")
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
config.SetConfigPath(configPath)
|
||||
}
|
||||
rootCmd.AddCommand(httpCmd)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print build version information",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"version: %s\ncommit: %s\nbuilt: %s\n",
|
||||
config.GetAppVersion(),
|
||||
config.GetBuildCommit(),
|
||||
config.GetBuildDate(),
|
||||
)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
+32
-334
@@ -1,221 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/tidwall/gjson"
|
||||
"os"
|
||||
)
|
||||
|
||||
// parseFloat is the minimal helper used by the settings bridge; kept
|
||||
// here so settings_bridge.go stays import-free.
|
||||
func parseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
var (
|
||||
HTTPAccessLog bool
|
||||
SQLDebug bool
|
||||
LogLevel string
|
||||
AppDebug bool
|
||||
MysqlDns string
|
||||
RuntimePath string
|
||||
LogSavePath string
|
||||
StaticPath string
|
||||
StaticFilePath string
|
||||
TgBotToken string
|
||||
TgProxy string
|
||||
TgManage int64
|
||||
BuildVersion = "0.0.0-dev"
|
||||
BuildCommit = "none"
|
||||
BuildDate = "unknown"
|
||||
configRootPath string
|
||||
explicitConfigPath string
|
||||
UsdtRate float64
|
||||
)
|
||||
|
||||
func SetConfigPath(path string) {
|
||||
explicitConfigPath = strings.TrimSpace(path)
|
||||
}
|
||||
|
||||
func Init() {
|
||||
configPath, err := resolveConfigFilePath()
|
||||
viper.AddConfigPath("./")
|
||||
viper.SetConfigFile(".env")
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
configRootPath = filepath.Dir(configPath)
|
||||
|
||||
viper.SetConfigFile(configPath)
|
||||
err = viper.ReadInConfig()
|
||||
gwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
HTTPAccessLog = viper.GetBool("http_access_log")
|
||||
SQLDebug = viper.GetBool("sql_debug")
|
||||
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
|
||||
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
|
||||
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
|
||||
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
|
||||
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
|
||||
mustMkdir(RuntimePath)
|
||||
mustMkdir(LogSavePath)
|
||||
AppDebug = viper.GetBool("app_debug")
|
||||
StaticPath = viper.GetString("static_path")
|
||||
RuntimePath = fmt.Sprintf(
|
||||
"%s%s",
|
||||
gwd,
|
||||
viper.GetString("runtime_root_path"))
|
||||
LogSavePath = fmt.Sprintf(
|
||||
"%s%s",
|
||||
RuntimePath,
|
||||
viper.GetString("log_save_path"))
|
||||
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
url.QueryEscape(viper.GetString("mysql_user")),
|
||||
url.QueryEscape(viper.GetString("mysql_passwd")),
|
||||
fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
|
||||
viper.GetString("mysql_user"),
|
||||
viper.GetString("mysql_passwd"),
|
||||
fmt.Sprintf(
|
||||
"%s:%s",
|
||||
viper.GetString("mysql_host"),
|
||||
viper.GetString("mysql_port")),
|
||||
viper.GetString("mysql_database"))
|
||||
TgBotToken = viper.GetString("tg_bot_token")
|
||||
TgProxy = viper.GetString("tg_proxy")
|
||||
TgManage = viper.GetInt64("tg_manage")
|
||||
}
|
||||
|
||||
func mustMkdir(path string) {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLogLevel(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug", "info", "warn", "error":
|
||||
return strings.ToLower(strings.TrimSpace(level))
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStaticURLPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "/" {
|
||||
return "/static"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func resolvePathFromBase(basePath string, path string, fallback string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fallback
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
path = strings.TrimPrefix(path, "\\")
|
||||
return filepath.Join(basePath, filepath.FromSlash(path))
|
||||
}
|
||||
|
||||
func resolveConfigFilePath() (string, error) {
|
||||
if explicitConfigPath != "" {
|
||||
return normalizeConfiguredPath(explicitConfigPath)
|
||||
}
|
||||
if envPath := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); envPath != "" {
|
||||
return normalizeConfiguredPath(envPath)
|
||||
}
|
||||
return normalizeConfiguredPath(".env")
|
||||
}
|
||||
|
||||
// NeedsInstall returns true when the first-run install wizard should run.
|
||||
// It returns true if the config file does not exist yet, or if the file
|
||||
// exists and contains install=true (an explicit reset flag).
|
||||
// Existing installs that have no install key are NOT affected.
|
||||
func NeedsInstall() bool {
|
||||
envPath, exists := ResolveConfigPath()
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
// Read just the install key without triggering full Init().
|
||||
v := viper.New()
|
||||
v.SetConfigFile(envPath)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
// Unreadable config → treat as needing install.
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
|
||||
}
|
||||
|
||||
// ResolveConfigPath returns the absolute path to the .env file and whether
|
||||
// it currently exists on disk. Unlike resolveConfigFilePath it does not
|
||||
// return an error when the file is absent — callers (e.g. the install
|
||||
// wizard) need the target path even before the file is written.
|
||||
func ResolveConfigPath() (path string, exists bool) {
|
||||
candidate := explicitConfigPath
|
||||
if candidate == "" {
|
||||
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
|
||||
candidate = e
|
||||
} else {
|
||||
candidate = ".env"
|
||||
}
|
||||
}
|
||||
// Resolve relative paths against cwd.
|
||||
p := strings.TrimSpace(candidate)
|
||||
if !filepath.IsAbs(p) {
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
p = filepath.Join(cwd, p)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err == nil && info.IsDir() {
|
||||
p = filepath.Join(p, ".env")
|
||||
info, err = os.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
return p, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func normalizeConfiguredPath(input string) (string, error) {
|
||||
path := strings.TrimSpace(input)
|
||||
if path == "" {
|
||||
path = ".env"
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
path = filepath.Join(path, ".env")
|
||||
info, err = os.Stat(path)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("config file not found: %s", path)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("config path must point to a file, got directory: %s", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func GetAppVersion() string {
|
||||
return BuildVersion
|
||||
}
|
||||
|
||||
func GetBuildCommit() string {
|
||||
return BuildCommit
|
||||
}
|
||||
|
||||
func GetBuildDate() string {
|
||||
return BuildDate
|
||||
return "0.0.1"
|
||||
}
|
||||
|
||||
func GetAppName() string {
|
||||
@@ -230,98 +68,19 @@ func GetAppUri() string {
|
||||
return viper.GetString("app_uri")
|
||||
}
|
||||
|
||||
func GetRateApiUrl() string {
|
||||
// settings table wins (admin-configurable); .env and env var remain
|
||||
// as fallbacks for smooth migration from the old layout.
|
||||
if db := settingsRateApiUrl(); db != "" {
|
||||
return db
|
||||
}
|
||||
return GetRateApiUrlFromEnv()
|
||||
}
|
||||
|
||||
// GetRateApiUrlFromEnv returns the rate API URL read only from the
|
||||
// .env / environment variables, bypassing the settings table. Used
|
||||
// by bootstrap to seed the settings table from the initial .env value.
|
||||
func GetRateApiUrlFromEnv() string {
|
||||
rateURL := viper.GetString("api_rate_url")
|
||||
if rateURL == "" {
|
||||
rateURL = os.Getenv("API_RATE_URL")
|
||||
}
|
||||
if rateURL == "" {
|
||||
log.Println("api_rate_url is empty")
|
||||
}
|
||||
return rateURL
|
||||
}
|
||||
|
||||
func GetRateForCoin(coin string, base string) float64 {
|
||||
coin = strings.ToLower(strings.TrimSpace(coin))
|
||||
base = strings.ToLower(strings.TrimSpace(base))
|
||||
if coin == "" || base == "" {
|
||||
return 0
|
||||
}
|
||||
if coin == base {
|
||||
return 1
|
||||
}
|
||||
if coin == "usdt" {
|
||||
switch base {
|
||||
case "usd":
|
||||
return 1
|
||||
case "cny":
|
||||
usdtRate := GetUsdtRate()
|
||||
if usdtRate > 0 {
|
||||
return 1 / usdtRate
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return getRateForCoinFromAPI(coin, base)
|
||||
}
|
||||
|
||||
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||
baseURL := GetRateApiUrl()
|
||||
if baseURL == "" {
|
||||
log.Printf("rate api url is empty")
|
||||
return 0.0
|
||||
}
|
||||
if baseURL[len(baseURL)-1] != '/' {
|
||||
baseURL += "/"
|
||||
}
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base))
|
||||
if err != nil {
|
||||
log.Printf("call rate api error: %s", err.Error())
|
||||
return 0.0
|
||||
}
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
log.Printf("call rate api unexpected status: %s", resp.Status())
|
||||
return 0.0
|
||||
}
|
||||
|
||||
targetRate := 0.0
|
||||
gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool {
|
||||
if key.String() == coin {
|
||||
targetRate = value.Float()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return targetRate
|
||||
func GetApiAuthToken() string {
|
||||
return viper.GetString("api_auth_token")
|
||||
}
|
||||
|
||||
func GetUsdtRate() float64 {
|
||||
// Only the admin setting can force the USDT/CNY rate. When the
|
||||
// setting is unset, zero, or negative, fall back to the rate API.
|
||||
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||
return forced
|
||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
||||
if forcedUsdtRate > 0 {
|
||||
return forcedUsdtRate
|
||||
}
|
||||
|
||||
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||
if apiRate > 0 {
|
||||
return 1 / apiRate
|
||||
if UsdtRate <= 0 {
|
||||
return 6.4
|
||||
}
|
||||
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data")
|
||||
return 0
|
||||
return UsdtRate
|
||||
}
|
||||
|
||||
func GetOrderExpirationTime() int {
|
||||
@@ -331,64 +90,3 @@ func GetOrderExpirationTime() int {
|
||||
}
|
||||
return timer
|
||||
}
|
||||
|
||||
func GetOrderExpirationTimeDuration() time.Duration {
|
||||
timer := GetOrderExpirationTime()
|
||||
return time.Minute * time.Duration(timer)
|
||||
}
|
||||
|
||||
func GetRuntimeSqlitePath() string {
|
||||
filename := viper.GetString("runtime_sqlite_filename")
|
||||
if filename == "" {
|
||||
filename = "epusdt-runtime.db"
|
||||
}
|
||||
if filepath.IsAbs(filename) {
|
||||
return filename
|
||||
}
|
||||
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
|
||||
return filepath.Join(RuntimePath, filepath.FromSlash(filename))
|
||||
}
|
||||
|
||||
func GetPrimarySqlitePath() string {
|
||||
filename := strings.TrimSpace(viper.GetString("sqlite_database_filename"))
|
||||
if filename == "" {
|
||||
return filepath.Join(configRootPath, "epusdt.db")
|
||||
}
|
||||
if filepath.IsAbs(filename) {
|
||||
return filename
|
||||
}
|
||||
filename = strings.TrimPrefix(strings.TrimPrefix(filename, "/"), "\\")
|
||||
return filepath.Join(configRootPath, filepath.FromSlash(filename))
|
||||
}
|
||||
|
||||
func GetQueueConcurrency() int {
|
||||
concurrency := viper.GetInt("queue_concurrency")
|
||||
if concurrency <= 0 {
|
||||
return 10
|
||||
}
|
||||
return concurrency
|
||||
}
|
||||
|
||||
func GetQueuePollInterval() time.Duration {
|
||||
interval := viper.GetInt("queue_poll_interval_ms")
|
||||
if interval <= 0 {
|
||||
interval = 1000
|
||||
}
|
||||
return time.Duration(interval) * time.Millisecond
|
||||
}
|
||||
|
||||
func GetOrderNoticeMaxRetry() int {
|
||||
retry := viper.GetInt("order_notice_max_retry")
|
||||
if retry < 0 {
|
||||
return 0
|
||||
}
|
||||
return retry
|
||||
}
|
||||
|
||||
func GetCallbackRetryBaseDuration() time.Duration {
|
||||
seconds := viper.GetInt("callback_retry_base_seconds")
|
||||
if seconds <= 0 {
|
||||
seconds = 5
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func installSettingsGetter(t *testing.T, values map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
oldGetter := SettingsGetString
|
||||
SettingsGetString = func(key string) string {
|
||||
return values[key]
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
SettingsGetString = oldGetter
|
||||
})
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
t.Helper()
|
||||
|
||||
oldFactory := http_client.ClientFactory
|
||||
http_client.ClientFactory = func() *resty.Client {
|
||||
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||
client.SetTimeout(10 * time.Second)
|
||||
return client
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http_client.ClientFactory = oldFactory
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "custom.env")
|
||||
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
got, err := normalizeConfiguredPath(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize explicit file: %v", err)
|
||||
}
|
||||
if got != configPath {
|
||||
t.Fatalf("config path = %s, want %s", got, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfiguredPathUsesExplicitDirectory(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, ".env")
|
||||
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
got, err := normalizeConfiguredPath(root)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize explicit directory: %v", err)
|
||||
}
|
||||
if got != configPath {
|
||||
t.Fatalf("config path = %s, want %s", got, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFilePathUsesCurrentDirectoryByDefault(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get cwd: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(oldCwd) }()
|
||||
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, ".env")
|
||||
if err := os.WriteFile(configPath, []byte("app_name=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EPUSDT_CONFIG", "")
|
||||
SetConfigPath("")
|
||||
|
||||
got, err := resolveConfigFilePath()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve config path: %v", err)
|
||||
}
|
||||
|
||||
gotAbs, err := filepath.Abs(got)
|
||||
if err != nil {
|
||||
t.Fatalf("abs got: %v", err)
|
||||
}
|
||||
wantAbs, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("abs want: %v", err)
|
||||
}
|
||||
|
||||
gotReal, err := filepath.EvalSymlinks(gotAbs)
|
||||
if err != nil {
|
||||
t.Fatalf("eval symlinks got: %v", err)
|
||||
}
|
||||
wantReal, err := filepath.EvalSymlinks(wantAbs)
|
||||
if err != nil {
|
||||
t.Fatalf("eval symlinks want: %v", err)
|
||||
}
|
||||
|
||||
if gotReal != wantReal {
|
||||
t.Fatalf("config path = %s, want %s", gotReal, wantReal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get cwd: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(oldCwd) }()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
envDir := filepath.Join(root, "from-env")
|
||||
if err := os.MkdirAll(envDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir env dir: %v", err)
|
||||
}
|
||||
envPath := filepath.Join(envDir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("app_name=env\n"), 0o644); err != nil {
|
||||
t.Fatalf("write env config: %v", err)
|
||||
}
|
||||
|
||||
flagDir := filepath.Join(root, "from-flag")
|
||||
if err := os.MkdirAll(flagDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir flag dir: %v", err)
|
||||
}
|
||||
flagPath := filepath.Join(flagDir, ".env")
|
||||
if err := os.WriteFile(flagPath, []byte("app_name=flag\n"), 0o644); err != nil {
|
||||
t.Fatalf("write flag config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EPUSDT_CONFIG", envDir)
|
||||
SetConfigPath(flagDir)
|
||||
defer SetConfigPath("")
|
||||
|
||||
got, err := resolveConfigFilePath()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve config path: %v", err)
|
||||
}
|
||||
if got != flagPath {
|
||||
t.Fatalf("config path = %s, want %s", got, flagPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
apiCalled := false
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
apiCalled = true
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Status: http.StatusText(http.StatusInternalServerError),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "7.25",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
if got != 7.25 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||
}
|
||||
if apiCalled {
|
||||
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "-1",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
got := GetUsdtRate()
|
||||
want := 1 / 0.14635
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
rate := GetRateForCoin("usdt", "cny")
|
||||
if math.Abs(rate-0.14635) > 1e-9 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Status: "502 Bad Gateway",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
if got := GetUsdtRate(); got != 0 {
|
||||
t.Fatalf("GetUsdtRate() = %v, want 0", got)
|
||||
}
|
||||
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("API_RATE_URL", "")
|
||||
|
||||
callCount := 0
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
callCount++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Status: "502 Bad Gateway",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
installSettingsGetter(t, map[string]string{
|
||||
"rate.forced_usdt_rate": "0",
|
||||
"rate.api_url": "https://rate.example.test",
|
||||
})
|
||||
|
||||
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("rate api call count = %d, want 1", callCount)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package config
|
||||
|
||||
// Settings lookups are injected at runtime by bootstrap (after DB init)
|
||||
// to keep the config package free of a `model/data` import, which would
|
||||
// create a cycle via `model/dao -> config`.
|
||||
//
|
||||
// If unset (e.g. during early startup or tests) the getters return the
|
||||
// zero string / 0 and callers apply their own fallback behavior.
|
||||
|
||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||
// reads from the settings table. Runtime-only.
|
||||
var SettingsGetString func(key string) string
|
||||
|
||||
func settingsRateApiUrl() string {
|
||||
if SettingsGetString == nil {
|
||||
return ""
|
||||
}
|
||||
return SettingsGetString("rate.api_url")
|
||||
}
|
||||
|
||||
func settingsForcedUsdtRate() float64 {
|
||||
if SettingsGetString == nil {
|
||||
return 0
|
||||
}
|
||||
raw := SettingsGetString("rate.forced_usdt_rate")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := parseFloat(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateApiKeyRequest is the payload for creating an API key.
|
||||
// A single key is valid for both gateway flows (epay/gmpay); there is
|
||||
// no gateway_type. PID is auto-generated (incrementing from 1000);
|
||||
// no manual override.
|
||||
type CreateApiKeyRequest struct {
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
|
||||
IpWhitelist string `json:"ip_whitelist" example:""`
|
||||
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// CreateApiKeyResponse is the response for a newly created API key.
|
||||
type CreateApiKeyResponse struct {
|
||||
ID uint64 `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"My API Key"`
|
||||
Pid string `json:"pid" example:"1003"`
|
||||
// SecretKey is returned ONCE on creation. After that, fetch via
|
||||
// GET /api-keys/:id/secret or rotate to generate a new one.
|
||||
SecretKey string `json:"secret_key" example:"secret123abc456"`
|
||||
}
|
||||
|
||||
// UpdateApiKeyRequest is the payload for updating an API key.
|
||||
type UpdateApiKeyRequest struct {
|
||||
Name *string `json:"name" example:"Updated Key Name"`
|
||||
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
|
||||
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
|
||||
type ChangeApiKeyStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
|
||||
// mdb.ApiKey json tag (`json:"-"`).
|
||||
// @Summary List API keys
|
||||
// @Description Returns all API keys
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [get]
|
||||
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
||||
rows, err := data.ListApiKeys()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateApiKey mints a new universal key. PID is auto-incremented from
|
||||
// the highest existing numeric PID (starting at 1000). Secret is
|
||||
// randomly generated and returned once.
|
||||
// @Summary Create API key
|
||||
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateApiKeyRequest true "API key payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [post]
|
||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||
req := new(CreateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Retry on unique-index violation: two concurrent creates could
|
||||
// both see the same max PID from NextPid() and race on INSERT.
|
||||
// The loser re-reads and retries with the now-higher max.
|
||||
const maxAttempts = 5
|
||||
secret := randomHex(32)
|
||||
var row *mdb.ApiKey
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
pid, err := data.NextPid()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row = &mdb.ApiKey{
|
||||
Name: req.Name,
|
||||
Pid: strconv.Itoa(pid),
|
||||
SecretKey: secret,
|
||||
IpWhitelist: req.IpWhitelist,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
err = data.CreateApiKey(row)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
}
|
||||
return c.SucJson(ctx, CreateApiKeyResponse{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Pid: row.Pid,
|
||||
SecretKey: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// isUniqueViolation matches the driver-specific strings for a unique-
|
||||
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
|
||||
// the PID retry loop; a cleaner approach would be a typed error but
|
||||
// GORM doesn't surface one portably.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unique") ||
|
||||
strings.Contains(msg, "duplicate") ||
|
||||
strings.Contains(msg, "constraint failed")
|
||||
}
|
||||
|
||||
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
|
||||
// and enable/disable have their own endpoints.
|
||||
// @Summary Update API key
|
||||
// @Description Patch API key name / ip_whitelist / notify_url
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.IpWhitelist != nil {
|
||||
fields["ip_whitelist"] = *req.IpWhitelist
|
||||
}
|
||||
if req.NotifyUrl != nil {
|
||||
fields["notify_url"] = *req.NotifyUrl
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatus toggles enable/disable.
|
||||
// @Summary Change API key status
|
||||
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(ChangeApiKeyStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
|
||||
// @Summary Rotate API key secret
|
||||
// @Description Generate a new secret_key. Returned only once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
|
||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
secret := randomHex(32)
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": secret})
|
||||
}
|
||||
|
||||
// DeleteApiKey soft-deletes a row. Orders created with this key will
|
||||
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
|
||||
// disabling via /status over deletion if there are live orders.
|
||||
// @Summary Delete API key
|
||||
// @Description Soft-delete an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// GetApiKeyStats returns usage stats for one key. Currently thin — just
|
||||
// the stored call_count and last_used_at — since we don't persist per-
|
||||
// call telemetry. Expanded later if/when we add a request log table.
|
||||
// @Summary Get API key stats
|
||||
// @Description Returns usage stats (call_count, last_used_at) for an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/stats [get]
|
||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"call_count": row.CallCount,
|
||||
"last_used_at": row.LastUsedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetApiKeySecret returns the secret_key for one API key. Requires
|
||||
// admin JWT authentication.
|
||||
// @Summary View API key secret
|
||||
// @Description Returns the secret_key for an API key (admin only)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=map[string]string}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/secret [get]
|
||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// LoginRequest is the payload for admin login.
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" validate:"required" example:"admin"`
|
||||
Password string `json:"password" validate:"required" example:"password123"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response for a successful login.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
|
||||
Username string `json:"username" example:"admin"`
|
||||
UserID uint64 `json:"user_id" example:"1"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest is the payload for changing admin password.
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" validate:"required" example:"old_pass"`
|
||||
NewPassword string `json:"new_password" validate:"required|minLen:6" example:"new_pass123"`
|
||||
}
|
||||
|
||||
// MeResponse wraps AdminUser with a default-password flag.
|
||||
type MeResponse struct {
|
||||
mdb.AdminUser
|
||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
||||
}
|
||||
|
||||
// InitialPasswordResponse is returned by the one-time initial password API.
|
||||
type InitialPasswordResponse struct {
|
||||
Username string `json:"username" example:"admin"`
|
||||
Password string `json:"password" example:"a1b2c3d4e5f6"`
|
||||
}
|
||||
|
||||
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
||||
// @Summary Admin login
|
||||
// @Description Verify credentials and return a signed JWT token
|
||||
// @Tags Admin Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.LoginResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/login [post]
|
||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||
req := new(LoginRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
return c.FailJson(ctx, errors.New("invalid username or password"))
|
||||
}
|
||||
if user.Status != mdb.AdminUserStatusEnable {
|
||||
return c.FailJson(ctx, errors.New("user disabled"))
|
||||
}
|
||||
token, err := appjwt.Sign(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
err = data.TouchAdminUserLastLogin(user.ID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, LoginResponse{
|
||||
Token: token,
|
||||
Username: user.Username,
|
||||
UserID: user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPassword returns the one-time initial admin password.
|
||||
// @Summary Get initial admin password (one-time)
|
||||
// @Description Returns the initial random admin password once, then invalidates it.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password [get]
|
||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||
password, err := data.ConsumeInitialAdminPassword()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, InitialPasswordResponse{
|
||||
Username: "admin",
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
// GetInitialPasswordHash returns the initial-password fingerprint so the
|
||||
// frontend can warn until the password is changed.
|
||||
// @Summary Get initial admin password hash
|
||||
// @Description Returns the initial password hash and password-changed flag.
|
||||
// @Tags Admin Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=data.InitialAdminPasswordHashInfo}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/init-password-hash [get]
|
||||
func (c *BaseAdminController) GetInitialPasswordHash(ctx echo.Context) error {
|
||||
info, err := data.GetInitialAdminPasswordHashInfo()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, info)
|
||||
}
|
||||
|
||||
// Logout is a no-op stub — tokens are stateless and the frontend
|
||||
// discards them. Kept for API symmetry with the documented spec.
|
||||
// @Summary Admin logout
|
||||
// @Description Logout (no-op, frontend discards token)
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/logout [post]
|
||||
func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Me returns the currently authenticated admin user profile.
|
||||
// @Summary Get current admin profile
|
||||
// @Description Returns the currently authenticated admin user
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.MeResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/me [get]
|
||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("user not found"))
|
||||
}
|
||||
// Warn the frontend when the operator hasn't changed the seeded
|
||||
// initial password.
|
||||
isDefault := data.IsUsingInitialAdminPassword()
|
||||
return c.SucJson(ctx, MeResponse{
|
||||
AdminUser: *user,
|
||||
PasswordIsDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword requires the old password, updates to the new one.
|
||||
// @Summary Change admin password
|
||||
// @Description Change the current admin user's password
|
||||
// @Tags Admin Auth
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.ChangePasswordRequest true "Password payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/auth/password [post]
|
||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||
uid := currentAdminUserID(ctx)
|
||||
if uid == 0 {
|
||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
||||
}
|
||||
req := new(ChangePasswordRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
user, err := data.GetAdminUserByID(uid)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||
return c.FailJson(ctx, errors.New("old password incorrect"))
|
||||
}
|
||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/controller"
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Ctrl is the singleton admin controller. Keeps parity with comm.Ctrl.
|
||||
var Ctrl = &BaseAdminController{}
|
||||
|
||||
// BaseAdminController aggregates admin-domain handlers. Split across
|
||||
// *_controller.go files in this package; all receive methods hang off
|
||||
// this type so a single router registration is enough.
|
||||
type BaseAdminController struct {
|
||||
controller.BaseController
|
||||
}
|
||||
|
||||
// currentAdminUserID returns the admin user id injected by CheckAdminJWT.
|
||||
// Returns 0 if the middleware is missing (treat as auth bug upstream).
|
||||
func currentAdminUserID(ctx echo.Context) uint64 {
|
||||
if v, ok := ctx.Get(middleware.AdminUserIDKey).(uint64); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// UpdateChainRequest is the payload for updating chain settings.
|
||||
type UpdateChainRequest struct {
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinConfirmations *int `json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec *int `json:"scan_interval_sec" example:"5"`
|
||||
DisplayName *string `json:"display_name" example:"Tron"`
|
||||
}
|
||||
|
||||
// ListChains returns the chains table (tron/ethereum/solana seeded; new
|
||||
// networks like bsc/polygon inserted manually once supported).
|
||||
// @Summary List chains
|
||||
// @Description Returns all supported blockchain networks
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Chain}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains [get]
|
||||
func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
||||
rows, err := data.ListChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpdateChain toggles enabled / tweaks scan params. Scanners re-read the
|
||||
// chains table on every poll tick, so changes take effect within one
|
||||
// cycle without a restart.
|
||||
// @Summary Update chain
|
||||
// @Description Toggle enabled / tweak scan params for a chain
|
||||
// @Tags Admin Chains
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
|
||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chains/{network} [patch]
|
||||
func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
||||
network := ctx.Param("network")
|
||||
req := new(UpdateChainRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinConfirmations != nil {
|
||||
fields["min_confirmations"] = *req.MinConfirmations
|
||||
}
|
||||
if req.ScanIntervalSec != nil {
|
||||
fields["scan_interval_sec"] = *req.ScanIntervalSec
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
fields["display_name"] = *req.DisplayName
|
||||
}
|
||||
if err := data.UpdateChainFields(network, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateChainTokenRequest is the payload for creating a chain token.
|
||||
type CreateChainTokenRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
|
||||
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// UpdateChainTokenRequest is the payload for updating a chain token.
|
||||
type UpdateChainTokenRequest struct {
|
||||
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals *int `json:"decimals" example:"6"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
MinAmount *float64 `json:"min_amount" example:"1.0"`
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatusRequest is the payload for toggling chain token status.
|
||||
type ChangeChainTokenStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListChainTokens returns rows, optionally filtered by network.
|
||||
// @Summary List chain tokens
|
||||
// @Description Returns chain tokens, optionally filtered by network
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [get]
|
||||
func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
|
||||
// @Summary Create chain token
|
||||
// @Description Create a new chain token entry
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateChainTokenRequest true "Chain token payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens [post]
|
||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||
req := new(CreateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
decimals := req.Decimals
|
||||
if decimals == 0 {
|
||||
decimals = 6
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.ChainToken{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Symbol: strings.ToUpper(strings.TrimSpace(req.Symbol)),
|
||||
ContractAddress: strings.TrimSpace(req.ContractAddress),
|
||||
Decimals: decimals,
|
||||
Enabled: enabled,
|
||||
MinAmount: req.MinAmount,
|
||||
}
|
||||
if err := data.CreateChainToken(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateChainToken patches mutable columns.
|
||||
// @Summary Update chain token
|
||||
// @Description Patch chain token fields
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.UpdateChainTokenRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateChainTokenRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.ContractAddress != nil {
|
||||
fields["contract_address"] = *req.ContractAddress
|
||||
}
|
||||
if req.Decimals != nil {
|
||||
fields["decimals"] = *req.Decimals
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
fields["min_amount"] = *req.MinAmount
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeChainTokenStatus toggles enable/disable.
|
||||
// @Summary Change chain token status
|
||||
// @Description Toggle enable/disable for a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Param request body admin.ChangeChainTokenStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(ChangeChainTokenStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteChainToken soft-deletes the row.
|
||||
// @Summary Delete chain token
|
||||
// @Description Soft-delete a chain token
|
||||
// @Tags Admin Chain Tokens
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Chain token ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/chain-tokens/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OverviewResponse is the dashboard top-card response.
|
||||
type OverviewResponse struct {
|
||||
TotalAsset float64 `json:"total_asset" example:"12345.6789"`
|
||||
Volume float64 `json:"volume" example:"100.5"`
|
||||
OrderCount int64 `json:"order_count" example:"42"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
SevenDayVolume float64 `json:"seven_day_volume" example:"700.25"`
|
||||
ActiveAddresses int64 `json:"active_addresses" example:"5"`
|
||||
OnlineChains int64 `json:"online_chains" example:"3"`
|
||||
}
|
||||
|
||||
// OrderStatsResponse is the order statistics response.
|
||||
type OrderStatsResponse struct {
|
||||
OrderCount int64 `json:"order_count" example:"100"`
|
||||
SuccessCount int64 `json:"success_count" example:"85"`
|
||||
SuccessRate float64 `json:"success_rate" example:"0.85"`
|
||||
AvgPaymentSeconds float64 `json:"avg_payment_seconds" example:"120.5"`
|
||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||
}
|
||||
|
||||
// RpcHealthCheckResponse is the response for a health check probe.
|
||||
type RpcHealthCheckResponse struct {
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `json:"status" example:"ok" enums:"unknown,ok,down"`
|
||||
LastLatencyMs int `json:"last_latency_ms" example:"120"`
|
||||
}
|
||||
|
||||
// ApiKeyStatsResponse is the response for API key usage stats.
|
||||
type ApiKeyStatsResponse struct {
|
||||
CallCount int64 `json:"call_count" example:"342"`
|
||||
LastUsedAt string `json:"last_used_at" example:"2026-04-14T08:30:00+00:00"`
|
||||
}
|
||||
|
||||
// RotateSecretResponse is returned when rotating an API key secret.
|
||||
type RotateSecretResponse struct {
|
||||
SecretKey string `json:"secret_key" example:"newSecret123abc456"`
|
||||
}
|
||||
|
||||
// BatchImportResult is one row in the batch import response.
|
||||
type BatchImportResult struct {
|
||||
Address string `json:"address" example:"TTestTronAddress001"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
}
|
||||
|
||||
// SettingsUpsertResult is one row in the settings upsert response.
|
||||
type SettingsUpsertResult struct {
|
||||
Key string `json:"key" example:"rate.api_url"`
|
||||
OK bool `json:"ok" example:"true"`
|
||||
Error string `json:"error,omitempty" example:""`
|
||||
}
|
||||
|
||||
// Overview returns the top-card numbers for the dashboard landing page.
|
||||
// All values are computed with on-demand SQL — no cache — per the design.
|
||||
// The selected time range (range / start_at / end_at) is applied to
|
||||
// volume, order_count, success_rate and active_addresses.
|
||||
// seven_day_volume always covers the rolling past 7 days.
|
||||
// total_asset and online_chains are time-independent.
|
||||
// @Summary Dashboard overview
|
||||
// @Description Returns top-card numbers: total asset, volume, success rate, active addresses, etc. Supports ?range= for filtering.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OverviewResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/overview [get]
|
||||
func (c *BaseAdminController) Overview(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
|
||||
now := time.Now()
|
||||
sevenDaysAgo := startOfDay(now).AddDate(0, 0, -6)
|
||||
|
||||
totalActual, _ := data.SumPaidActualAmount()
|
||||
|
||||
orderCount, successCount, volume, _ := data.PaidStatsInRange(start, end)
|
||||
var successRate float64
|
||||
if orderCount > 0 {
|
||||
successRate = float64(successCount) / float64(orderCount)
|
||||
}
|
||||
|
||||
_, _, sevenDayActual, _ := data.PaidStatsInRange(sevenDaysAgo, now)
|
||||
|
||||
activeAddr, _ := data.ActiveAddressCountInRange(start, end)
|
||||
onlineChains, _ := data.CountEnabledChains()
|
||||
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"total_asset": totalActual,
|
||||
"volume": volume,
|
||||
"order_count": orderCount,
|
||||
"success_rate": successRate,
|
||||
"seven_day_volume": sevenDayActual,
|
||||
"active_addresses": activeAddr,
|
||||
"online_chains": onlineChains,
|
||||
})
|
||||
}
|
||||
|
||||
// isHourlyRange returns true when the time window spans ≤ 1 day,
|
||||
// so callers can choose hourly vs daily granularity.
|
||||
func isHourlyRange(start, end time.Time) bool {
|
||||
return end.Sub(start) <= 24*time.Hour
|
||||
}
|
||||
|
||||
// AssetTrend returns per-day actual_amount for paid orders. group_by=
|
||||
// "total" yields one series; "address" yields rows keyed by
|
||||
// receive_address for the stacked chart.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Asset trend
|
||||
// @Description Returns per-period actual_amount for paid orders. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Param group_by query string false "Group by: total or address" default(total)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/asset-trend [get]
|
||||
func (c *BaseAdminController) AssetTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
groupBy := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group_by")))
|
||||
hourly := isHourlyRange(start, end)
|
||||
if groupBy == "address" {
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyAssetByAddress(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyAssetByAddress(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
var rows interface{}
|
||||
var err error
|
||||
if hourly {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// RevenueTrend mirrors the daily stats view used for revenue charts.
|
||||
// Same data shape as AssetTrend(group_by=total) but kept as its own
|
||||
// endpoint so the frontend can evolve each chart independently.
|
||||
// Granularity: hourly when range ≤ 1 day, daily otherwise.
|
||||
// Missing buckets are always zero-filled.
|
||||
// @Summary Revenue trend
|
||||
// @Description Returns per-period revenue stats for charts. Hourly for ≤1-day ranges, daily otherwise. Missing buckets are zero-filled.
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]data.DailyStat}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/revenue-trend [get]
|
||||
func (c *BaseAdminController) RevenueTrend(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
var rows interface{}
|
||||
var err error
|
||||
if isHourlyRange(start, end) {
|
||||
rows, err = data.HourlyOrderStats(start, end)
|
||||
} else {
|
||||
rows, err = data.DailyOrderStats(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// OrderStats returns aggregate order figures for the selected window:
|
||||
// total count, success rate, average payment duration (seconds), and
|
||||
// expired-unpaid count.
|
||||
// @Summary Order statistics
|
||||
// @Description Returns aggregate order figures: count, success rate, avg payment time, expired count
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param range query string false "Time range: today, 7d, 30d, custom" default(today)
|
||||
// @Param start_at query int false "Start time (Unix seconds, for custom range)"
|
||||
// @Param end_at query int false "End time (Unix seconds, for custom range)"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/order-stats [get]
|
||||
func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
||||
start, end := parseRange(ctx)
|
||||
total, success, _, err := data.PaidStatsInRange(start, end)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
avg, _ := data.AveragePaymentDurationSeconds(start, end)
|
||||
expired, _ := data.CountExpiredInRange(start, end)
|
||||
|
||||
var rate float64
|
||||
if total > 0 {
|
||||
rate = float64(success) / float64(total)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"order_count": total,
|
||||
"success_count": success,
|
||||
"success_rate": rate,
|
||||
"avg_payment_seconds": avg,
|
||||
"expired_unpaid_count": expired,
|
||||
})
|
||||
}
|
||||
|
||||
// RecentOrders returns the last N orders for the dashboard list card.
|
||||
// @Summary Recent orders
|
||||
// @Description Returns the last N orders for the dashboard
|
||||
// @Tags Admin Dashboard
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of orders to return" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/dashboard/recent-orders [get]
|
||||
func (c *BaseAdminController) RecentOrders(ctx echo.Context) error {
|
||||
limit := 20
|
||||
if s := ctx.QueryParam("limit"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := data.RecentOrders(limit)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// parseRange maps the ?range=... shorthand to a concrete [start, end].
|
||||
// Accepted: today / 7d / 30d / custom (with start_at/end_at Unix secs).
|
||||
// Defaults to today.
|
||||
func parseRange(ctx echo.Context) (time.Time, time.Time) {
|
||||
rng := strings.ToLower(strings.TrimSpace(ctx.QueryParam("range")))
|
||||
now := time.Now()
|
||||
todayStart := startOfDay(now)
|
||||
switch rng {
|
||||
case "7d":
|
||||
return todayStart.AddDate(0, 0, -6), now
|
||||
case "30d":
|
||||
return todayStart.AddDate(0, 0, -29), now
|
||||
case "custom":
|
||||
start := todayStart
|
||||
end := now
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
start = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
end = time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
default:
|
||||
return todayStart, now
|
||||
}
|
||||
}
|
||||
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateNotificationChannelRequest is the payload for creating a notification channel.
|
||||
type CreateNotificationChannelRequest struct {
|
||||
Type string `json:"type" validate:"required|in:telegram,webhook,email" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"主通知群"`
|
||||
Config map[string]interface{} `json:"config" validate:"required"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelRequest is the payload for updating a notification channel.
|
||||
type UpdateNotificationChannelRequest struct {
|
||||
Name *string `json:"name" example:"更新后的名称"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Events interface{} `json:"events"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ChangeChannelStatusRequest is the payload for toggling notification channel status.
|
||||
type ChangeChannelStatusRequest struct {
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListNotificationChannels lists rows, optionally filtered by type.
|
||||
// @Summary List notification channels
|
||||
// @Description Returns notification channels, optionally filtered by type
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param type query string false "Channel type: telegram, webhook, email"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [get]
|
||||
func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
|
||||
channelType := strings.ToLower(strings.TrimSpace(ctx.QueryParam("type")))
|
||||
rows, err := data.ListNotificationChannels(channelType)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateNotificationChannel validates Config shape per type (only
|
||||
// telegram is strongly validated today) and inserts a row.
|
||||
// @Summary Create notification channel
|
||||
// @Description Create a new notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateNotificationChannelRequest true "Channel payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.NotificationChannel}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels [post]
|
||||
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||
req := new(CreateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := validateChannelConfig(req.Type, req.Config); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.NotificationChannel{
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := data.CreateNotificationChannel(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(row.Type)) == mdb.NotificationTypeTelegram {
|
||||
telegram.ReloadBotAsync("admin create telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel patches name/config/events/enabled.
|
||||
// @Summary Update notification channel
|
||||
// @Description Patch notification channel fields
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.UpdateNotificationChannelRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(UpdateNotificationChannelRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.Config != nil {
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
fields["config"] = string(configJSON)
|
||||
}
|
||||
if req.Events != nil {
|
||||
eventsMap, err := normalizeChannelEvents(req.Events)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(eventsMap)
|
||||
fields["events"] = string(eventsJSON)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin update telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeNotificationChannelStatus toggles enabled.
|
||||
// @Summary Change notification channel status
|
||||
// @Description Toggle enable/disable for a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Param request body admin.ChangeChannelStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
req := new(ChangeChannelStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin change telegram channel status")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel soft-deletes the row.
|
||||
// @Summary Delete notification channel
|
||||
// @Description Soft-delete a notification channel
|
||||
// @Tags Admin Notifications
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Channel ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/notification-channels/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
existing, err := data.GetNotificationChannelByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
isTelegram := existing != nil && existing.ID > 0 && strings.ToLower(strings.TrimSpace(existing.Type)) == mdb.NotificationTypeTelegram
|
||||
|
||||
if err := data.DeleteNotificationChannelByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if isTelegram {
|
||||
telegram.ReloadBotAsync("admin delete telegram channel")
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// validateChannelConfig enforces minimum shape. Telegram needs bot_token
|
||||
// + chat_id; webhook/email kept loose until those senders exist.
|
||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config required")
|
||||
}
|
||||
if channelType == mdb.NotificationTypeTelegram {
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
||||
if raw == nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
toFlag := func(v interface{}) (bool, error) {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, nil
|
||||
case float64:
|
||||
return t != 0, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
switch s {
|
||||
case "", "0", "false", "off", "no":
|
||||
return false, nil
|
||||
case "1", "true", "on", "yes":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("invalid boolean string")
|
||||
}
|
||||
default:
|
||||
return false, errors.New("invalid event value type")
|
||||
}
|
||||
}
|
||||
|
||||
trimKey := func(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
out := make(map[string]bool)
|
||||
switch t := raw.(type) {
|
||||
case map[string]bool:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
case map[string]interface{}:
|
||||
for k, v := range t {
|
||||
k = trimKey(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
flag, err := toFlag(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = flag
|
||||
}
|
||||
return out, nil
|
||||
case []interface{}:
|
||||
for _, v := range t {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("events array must contain strings")
|
||||
}
|
||||
s = trimKey(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
out[s] = true
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, errors.New("events must be object or string array")
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// MarkPaidRequest is the payload for manually marking an order as paid.
|
||||
type MarkPaidRequest struct {
|
||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||
}
|
||||
|
||||
// OrderListResponse wraps the paginated order list.
|
||||
type OrderListResponse struct {
|
||||
List []mdb.Orders `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// OrderWithSub embeds a parent order together with its child orders.
|
||||
type OrderWithSub struct {
|
||||
mdb.Orders
|
||||
SubOrders []mdb.Orders `json:"sub_orders"`
|
||||
}
|
||||
|
||||
// OrderWithSubListResponse wraps the paginated list-with-sub result.
|
||||
type OrderWithSubListResponse struct {
|
||||
List []OrderWithSub `json:"list"`
|
||||
Total int64 `json:"total" example:"100"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"page_size" example:"20"`
|
||||
}
|
||||
|
||||
// ListOrders handles the admin order list with filters + pagination.
|
||||
// @Summary List orders
|
||||
// @Description Returns paginated orders with optional filters
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders [get]
|
||||
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"list": rows,
|
||||
"total": total,
|
||||
"page": f.Page,
|
||||
"page_size": f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrder returns full order detail by trade_id.
|
||||
// @Summary Get order
|
||||
// @Description Returns full order detail by trade_id
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id} [get]
|
||||
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
return c.SucJson(ctx, order)
|
||||
}
|
||||
|
||||
// CloseOrder flips a waiting order to expired and releases its lock.
|
||||
// @Summary Close order
|
||||
// @Description Manually close a waiting order (mark as expired)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/close [post]
|
||||
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
}
|
||||
ok, err := data.CloseOrderManually(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
||||
}
|
||||
// Release the transaction lock so the amount slot becomes reusable.
|
||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
|
||||
// same OrderProcessing path as on-chain confirmation so downstream
|
||||
// callback + notification fire normally.
|
||||
// @Summary Mark order paid
|
||||
// @Description Manually mark a waiting order as paid (operator补单)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
req := new(MarkPaidRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
if order.Status != mdb.StatusWaitPay {
|
||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
||||
}
|
||||
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Currency: order.Currency,
|
||||
Token: order.Token,
|
||||
Network: order.Network,
|
||||
Amount: order.ActualAmount,
|
||||
TradeId: order.TradeId,
|
||||
BlockTransactionId: req.BlockTransactionId,
|
||||
})
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ResendCallback requeues the order callback by flipping
|
||||
// callback_confirm back to NO. The mq worker will grab it on the
|
||||
// next poll tick.
|
||||
// @Summary Resend callback
|
||||
// @Description Re-queue the order callback notification
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
ok, err := data.ReopenOrderCallback(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if !ok {
|
||||
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ExportOrders streams matching orders as CSV. No pagination — the
|
||||
// caller drives what rows to include via filter params. Capped at
|
||||
// 10k rows to keep the response bounded.
|
||||
// @Summary Export orders as CSV
|
||||
// @Description Stream matching orders as a CSV file (max 10k rows)
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce text/csv
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Success 200 {string} string "CSV file"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/export [get]
|
||||
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
f.Page = 1
|
||||
f.PageSize = 10000
|
||||
rows, _, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp := ctx.Response()
|
||||
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
|
||||
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
|
||||
w := csv.NewWriter(resp.Writer)
|
||||
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.TradeId,
|
||||
r.OrderId,
|
||||
fmt.Sprintf("%.4f", r.Amount),
|
||||
fmt.Sprintf("%.4f", r.ActualAmount),
|
||||
r.Currency,
|
||||
r.Token,
|
||||
r.Network,
|
||||
r.ReceiveAddress,
|
||||
strconv.Itoa(r.Status),
|
||||
r.BlockTransactionId,
|
||||
r.CreatedAt.ToDateTimeString(),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOrderFilter extracts filter + pagination values from the query
|
||||
// string. Time params are Unix seconds.
|
||||
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
|
||||
f := data.OrderListFilter{
|
||||
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
|
||||
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
|
||||
Address: strings.TrimSpace(ctx.QueryParam("address")),
|
||||
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
|
||||
}
|
||||
if s := ctx.QueryParam("status"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Status = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("start_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.StartAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("end_at"); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
f.EndAt = &t
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.Page = n
|
||||
}
|
||||
}
|
||||
if s := ctx.QueryParam("page_size"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
f.PageSize = n
|
||||
}
|
||||
}
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.PageSize < 1 {
|
||||
f.PageSize = 20
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ListOrdersWithSub returns the same paginated order list as ListOrders but
|
||||
// each row has a sub_orders field containing all child orders under it.
|
||||
// @Summary List orders with sub-orders
|
||||
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param token query string false "Token filter"
|
||||
// @Param address query string false "Receive address filter"
|
||||
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
||||
// @Param status query int false "Order status filter"
|
||||
// @Param start_at query int false "Start time (Unix seconds)"
|
||||
// @Param end_at query int false "End time (Unix seconds)"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Page size" default(20)
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/list-with-sub [get]
|
||||
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
|
||||
f := parseOrderFilter(ctx)
|
||||
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
|
||||
f.ParentOnly = true
|
||||
rows, total, err := data.ListOrders(f)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Batch-fetch all sub-orders for the current page in one query.
|
||||
tradeIds := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tradeIds = append(tradeIds, r.TradeId)
|
||||
}
|
||||
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Group sub-orders by parent_trade_id.
|
||||
subMap := make(map[string][]mdb.Orders, len(subs))
|
||||
for _, s := range subs {
|
||||
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
|
||||
}
|
||||
|
||||
list := make([]OrderWithSub, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
children := subMap[r.TradeId]
|
||||
if children == nil {
|
||||
children = []mdb.Orders{}
|
||||
}
|
||||
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, OrderWithSubListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: f.Page,
|
||||
PageSize: f.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderWithSub returns a single parent order together with all its child
|
||||
// orders (any status), regardless of whether the caller passes a parent or
|
||||
// child trade_id — if it is a child, the parent is resolved first.
|
||||
// @Summary Get order with sub-orders
|
||||
// @Description Returns an order and its child orders embedded in sub_orders
|
||||
// @Tags Admin Orders
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
|
||||
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
||||
tradeID := ctx.Param("trade_id")
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if order.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("order not found"))
|
||||
}
|
||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if subOrders == nil {
|
||||
subOrders = []mdb.Orders{}
|
||||
}
|
||||
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/task"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||
type CreateRpcNodeRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
||||
Weight int `json:"weight" example:"1"`
|
||||
ApiKey string `json:"api_key" example:""`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||
type UpdateRpcNodeRequest struct {
|
||||
Url *string `json:"url" example:"https://api.trongrid.io"`
|
||||
Weight *int `json:"weight" example:"1"`
|
||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||
Enabled *bool `json:"enabled" example:"true"`
|
||||
}
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
// @Summary List RPC nodes
|
||||
// @Description Returns RPC nodes, optionally filtered by network
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [get]
|
||||
func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
rows, err := data.ListRpcNodes(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row. Status starts as "unknown" until the
|
||||
// health-check task runs.
|
||||
// @Summary Create RPC node
|
||||
// @Description Create a new RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateRpcNodeRequest true "RPC node payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.RpcNode}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes [post]
|
||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||
req := new(CreateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
weight := req.Weight
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &mdb.RpcNode{
|
||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||
Url: strings.TrimSpace(req.Url),
|
||||
Type: strings.ToLower(strings.TrimSpace(req.Type)),
|
||||
Weight: weight,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: enabled,
|
||||
Status: mdb.RpcNodeStatusUnknown,
|
||||
LastLatencyMs: -1,
|
||||
}
|
||||
if err := data.CreateRpcNode(row); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// UpdateRpcNode patches url/weight/api_key/enabled.
|
||||
// @Summary Update RPC node
|
||||
// @Description Patch RPC node fields
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Param request body admin.UpdateRpcNodeRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateRpcNodeRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Url != nil {
|
||||
fields["url"] = *req.Url
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
}
|
||||
if req.ApiKey != nil {
|
||||
fields["api_key"] = *req.ApiKey
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
fields["enabled"] = *req.Enabled
|
||||
}
|
||||
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// DeleteRpcNode soft-deletes the row.
|
||||
// @Summary Delete RPC node
|
||||
// @Description Soft-delete an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||
// For HTTP endpoints this is a GET to the URL with a short timeout; for
|
||||
// WS we just attempt a TCP-level check via the same HTTP client (the
|
||||
// handshake URL resolves the host identically).
|
||||
// @Summary Health check RPC node
|
||||
// @Description Perform an on-demand health probe on an RPC node
|
||||
// @Tags Admin RPC Nodes
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "RPC node ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RpcHealthCheckResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/rpc-nodes/{id}/health-check [post]
|
||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetRpcNodeByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("node not found"))
|
||||
}
|
||||
status, latency := task.ProbeNode(row.Url)
|
||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latency,
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/telegram"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// SettingUpsertItem is a single setting entry for batch upsert.
|
||||
// Supported groups and keys:
|
||||
//
|
||||
// - group=rate:
|
||||
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url
|
||||
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0
|
||||
// rate.adjust_percent (float) — rate adjustment percentage
|
||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||
//
|
||||
// - group=epay:
|
||||
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
|
||||
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
|
||||
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
|
||||
//
|
||||
// - group=brand:
|
||||
// brand.site_name (string) — site display name
|
||||
// brand.logo_url (string) — logo image URL
|
||||
// brand.page_title (string) — payment page title
|
||||
// brand.pay_success_text (string) — text shown on payment success
|
||||
// brand.support_url (string) — support / help URL
|
||||
//
|
||||
// - group=system:
|
||||
// system.order_expiration_time (int) — order expiry in minutes
|
||||
type SettingUpsertItem struct {
|
||||
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value string `json:"value" example:"tron"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
}
|
||||
|
||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||
type SettingsUpsertRequest struct {
|
||||
Items []SettingUpsertItem `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
// ListSettings returns all rows, optionally filtered by group.
|
||||
// @Summary List settings
|
||||
// @Description Returns all settings, optionally filtered by group.
|
||||
// @Description Available groups: brand, rate, system, epay.
|
||||
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param group query string false "Group filter (brand|rate|system|epay)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [get]
|
||||
func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
group := strings.ToLower(strings.TrimSpace(ctx.QueryParam("group")))
|
||||
rows, err := data.ListSettingsByGroup(group)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// UpsertSettings batch-inserts / updates rows. Each item is treated
|
||||
// independently so a malformed row in the middle doesn't drop earlier
|
||||
// ones. Errors are returned per-key so the UI can surface them.
|
||||
// @Summary Upsert settings
|
||||
// @Description Batch insert/update settings. Returns per-key status.
|
||||
// @Description Supported groups: brand, rate, system, epay.
|
||||
// @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 (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
||||
// @Description system group keys: system.order_expiration_time.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.SettingsUpsertRequest true "Settings payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.SettingsUpsertResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [put]
|
||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||
req := new(SettingsUpsertRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Key string `json:"key"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
|
||||
continue
|
||||
}
|
||||
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
|
||||
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
out = append(out, result{Key: key, OK: true})
|
||||
}
|
||||
|
||||
// When telegram credentials are updated via settings, reload the
|
||||
// command bot so operators don't need to restart the process, and
|
||||
// sync the notification_channels row so the notify dispatcher picks
|
||||
// up the new values immediately.
|
||||
telegramKeys := map[string]bool{
|
||||
"system.telegram_bot_token": true,
|
||||
"system.telegram_chat_id": true,
|
||||
"system.telegram_payment_notice_enabled": true,
|
||||
"system.telegram_abnormal_notice_enabled": true,
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
if telegramKeys[strings.TrimSpace(item.Key)] {
|
||||
telegram.ReloadBotAsync("settings upsert")
|
||||
go dao.SyncTelegramChannelFromSettings()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
// DeleteSetting removes one row. The next read of that key will fall
|
||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||
// @Summary Delete setting
|
||||
// @Description Remove a setting by key (falls back to default)
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param key path string true "Setting key"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings/{key} [delete]
|
||||
func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
||||
key := strings.TrimSpace(ctx.Param("key"))
|
||||
if key == "" {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := data.DeleteSetting(key); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// Public helper for the rate/usdt overrides — used by config package to
|
||||
// read settings-backed values without importing the controller package.
|
||||
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
|
||||
@@ -1,279 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// AdminAddWalletRequest is the payload for adding a wallet via admin.
|
||||
type AdminAddWalletRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
Address string `json:"address" validate:"required" example:"TTestTronAddress001"`
|
||||
Remark string `json:"remark" example:"主钱包"`
|
||||
}
|
||||
|
||||
// AdminUpdateWalletRequest is the payload for updating a wallet remark.
|
||||
type AdminUpdateWalletRequest struct {
|
||||
Remark *string `json:"remark" example:"已更新的备注"`
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatusRequest is the payload for toggling wallet status.
|
||||
type AdminChangeWalletStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// AdminBatchImportRequest is the payload for batch importing wallets.
|
||||
type AdminBatchImportRequest struct {
|
||||
Network string `json:"network" validate:"required" example:"tron"`
|
||||
// Addresses listed as observation-only wallets. Private-key import
|
||||
// and API-managed modes are out of scope for v1.
|
||||
Addresses []string `json:"addresses" validate:"required" example:"TAddr001,TAddr002,TAddr003"`
|
||||
}
|
||||
|
||||
// WalletListItem extends WalletAddress with order count.
|
||||
type WalletListItem struct {
|
||||
mdb.WalletAddress
|
||||
OrderCount int64 `json:"order_count" example:"5"`
|
||||
}
|
||||
|
||||
// AdminListWallets extends the read model with per-wallet order counts
|
||||
// so the UI can render the "订单数" column without an N+1 follow-up.
|
||||
// @Summary List wallets (admin)
|
||||
// @Description Returns wallets with per-wallet order counts
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter"
|
||||
// @Param status query int false "Status filter (1=enabled, 2=disabled)"
|
||||
// @Param keyword query string false "Search by address or remark"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [get]
|
||||
func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
||||
network := strings.ToLower(strings.TrimSpace(ctx.QueryParam("network")))
|
||||
status := strings.TrimSpace(ctx.QueryParam("status"))
|
||||
keyword := strings.TrimSpace(ctx.QueryParam("keyword"))
|
||||
|
||||
tx := dao.Mdb.Model(&mdb.WalletAddress{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", network)
|
||||
}
|
||||
if status != "" {
|
||||
if s, err := strconv.Atoi(status); err == nil {
|
||||
tx = tx.Where("status = ?", s)
|
||||
}
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := "%" + keyword + "%"
|
||||
tx = tx.Where("address LIKE ? OR remark LIKE ?", kw, kw)
|
||||
}
|
||||
var rows []mdb.WalletAddress
|
||||
if err := tx.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
counts, err := data.CountOrdersByAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
out := make([]WalletListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, WalletListItem{WalletAddress: r, OrderCount: counts[r.Address]})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
|
||||
// AdminAddWallet wraps the existing create with admin-exposed Remark +
|
||||
// Source=manual so audit fields are populated.
|
||||
// @Summary Add wallet (admin)
|
||||
// @Description Create a wallet with admin-specific fields (remark, source=manual)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminAddWalletRequest true "Wallet payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.WalletAddress}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets [post]
|
||||
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||
req := new(AdminAddWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
// Patch remark/source separately to avoid changing the existing
|
||||
// package-level AddWalletAddressWithNetwork signature (used by
|
||||
// Telegram bot + legacy endpoint).
|
||||
fields := map[string]interface{}{"source": mdb.WalletSourceManual}
|
||||
if req.Remark != "" {
|
||||
fields["remark"] = req.Remark
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).Updates(fields).Error
|
||||
row.Remark = req.Remark
|
||||
row.Source = mdb.WalletSourceManual
|
||||
return c.SucJson(ctx, row)
|
||||
}
|
||||
|
||||
// AdminGetWallet returns detail including order count for one wallet.
|
||||
// @Summary Get wallet (admin)
|
||||
// @Description Returns wallet detail with order count
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.WalletListItem}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [get]
|
||||
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
wallet, err := data.GetWalletAddressById(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if wallet.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("wallet not found"))
|
||||
}
|
||||
counts, _ := data.CountOrdersByAddress()
|
||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||
}
|
||||
|
||||
// AdminUpdateWallet only patches remark (status has its own endpoint).
|
||||
// @Summary Update wallet (admin)
|
||||
// @Description Patch wallet remark
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminUpdateWalletRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [patch]
|
||||
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(AdminUpdateWalletRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Remark != nil {
|
||||
fields["remark"] = *req.Remark
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
if err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Updates(fields).Error; err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminChangeWalletStatus reuses existing helper.
|
||||
// @Summary Change wallet status (admin)
|
||||
// @Description Toggle enable/disable for a wallet (1=enabled, 2=disabled)
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Param request body admin.AdminChangeWalletStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id}/status [post]
|
||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(AdminChangeWalletStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminDeleteWallet soft-deletes the wallet.
|
||||
// @Summary Delete wallet (admin)
|
||||
// @Description Soft-delete a wallet
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "Wallet ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/{id} [delete]
|
||||
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// AdminBatchImportWallets accepts a list of addresses and creates
|
||||
// them all as observation wallets. Per-row failures are collected and
|
||||
// returned so the UI can surface which addresses already existed.
|
||||
// @Summary Batch import wallets
|
||||
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
||||
// @Tags Admin Wallets
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.AdminBatchImportRequest true "Batch import payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]admin.BatchImportResult}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/wallets/batch-import [post]
|
||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||
req := new(AdminBatchImportRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
type result struct {
|
||||
Address string `json:"address"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
out := make([]result, 0, len(req.Addresses))
|
||||
for _, addr := range req.Addresses {
|
||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||
if err != nil {
|
||||
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||
Update("source", mdb.WalletSourceImport).Error
|
||||
out = append(out, result{Address: addr, OK: true})
|
||||
}
|
||||
return c.SucJson(ctx, out)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/assimon/luuu/util/http"
|
||||
"github.com/gookit/validate"
|
||||
"github.com/gookit/validate/locales/zhcn"
|
||||
"github.com/gookit/validate/locales/zhtw"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package comm
|
||||
|
||||
import "github.com/GMWalletApp/epusdt/controller"
|
||||
import "github.com/assimon/luuu/controller"
|
||||
|
||||
var Ctrl = &BaseCommController{}
|
||||
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// apiKeyFromContext returns the api_keys row stamped by CheckApiSign.
|
||||
// Returns nil when the middleware didn't run (should not happen on authed routes).
|
||||
func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
||||
if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTransaction 创建交易
|
||||
// @Summary Create transaction
|
||||
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||
// @Param order_id formData string false "Merchant order ID"
|
||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||
// @Param token formData string false "Crypto token (e.g. usdt)"
|
||||
// @Param network formData string false "Network (e.g. tron)"
|
||||
// @Param amount formData number false "Amount"
|
||||
// @Param notify_url formData string false "Callback URL"
|
||||
// @Param signature formData string false "MD5 signature"
|
||||
// @Param redirect_url formData string false "Redirect URL"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param payment_type formData string false "Payment type"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
@@ -51,95 +16,9 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||
resp, err := service.CreateTransaction(req)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// SwitchNetwork 切换支付网络,创建或返回子订单
|
||||
// @Summary Switch payment network
|
||||
// @Description Switch to a different payment network, creating or returning a sub-order
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /pay/switch-network [post]
|
||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
req := new(request.SwitchNetworkRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.SwitchNetwork(req)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
||||
|
||||
return c.SucJson(ctx, resp)
|
||||
}
|
||||
|
||||
// CreateTransactionAndRedirect creates a transaction and redirects to
|
||||
// the checkout counter. The route accepts BOTH GET (query string) and
|
||||
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
||||
// the canonical form — the GET variant is identical save the transport.
|
||||
// @Summary Create transaction and redirect (EPAY compat)
|
||||
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
|
||||
// @Tags Payment
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param pid query integer false "API key PID (GET query)"
|
||||
// @Param money query number false "Amount (fiat, GET query)"
|
||||
// @Param out_trade_no query string false "Merchant order ID (GET query)"
|
||||
// @Param notify_url query string false "Callback URL (GET query)"
|
||||
// @Param return_url query string false "Redirect URL after payment (GET query)"
|
||||
// @Param name query string false "Order name (GET query)"
|
||||
// @Param type query string false "Payment type (e.g. alipay, GET query)"
|
||||
// @Param sign query string false "MD5 signature (GET query)"
|
||||
// @Param sign_type query string false "Signature type (MD5, GET query)"
|
||||
// @Param pid formData integer true "API key PID"
|
||||
// @Param money formData number true "Amount (fiat)"
|
||||
// @Param out_trade_no formData string true "Merchant order ID"
|
||||
// @Param notify_url formData string true "Callback URL"
|
||||
// @Param return_url formData string false "Redirect URL after payment"
|
||||
// @Param name formData string false "Order name"
|
||||
// @Param type formData string false "Payment type (e.g. alipay)"
|
||||
// @Param sign formData string true "MD5 signature"
|
||||
// @Param sign_type formData string false "Signature type (MD5)"
|
||||
// @Success 302 "Redirect to checkout counter"
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||
req := new(request.CreateTransactionRequest)
|
||||
if err = ctx.Bind(req); err != nil {
|
||||
log.Sugar.Errorf("bind request error: %v", err)
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||
log.Sugar.Errorf("validate request error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx))
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("create transaction error: %v", err)
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("create transaction response: %+v", resp)
|
||||
|
||||
tradeID := resp.TradeId
|
||||
return ctx.Redirect(http.StatusFound, "/pay/checkout-counter/"+tradeID)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,30 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckoutCounter 收银台
|
||||
// @Summary Checkout counter page
|
||||
// @Description Render the payment checkout counter HTML page for a given trade
|
||||
// @Tags Payment
|
||||
// @Produce html
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {string} string "HTML page"
|
||||
// @Router /pay/checkout-counter/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||
if err != nil {
|
||||
if err == service.ErrOrder {
|
||||
// Unknown trade id: render the page with empty payload
|
||||
// (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())
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
}
|
||||
ctx.Response().Status = http.StatusNotFound
|
||||
emptyResp := response.CheckoutCounterResponse{}
|
||||
return tmpl.Execute(ctx.Response(), emptyResp)
|
||||
}
|
||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
||||
tmpl, err := template.ParseFiles(fmt.Sprintf(".%s/%s", config.StaticPath, "index.html"))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
||||
return ctx.String(http.StatusOK, err.Error())
|
||||
}
|
||||
|
||||
return tmpl.Execute(ctx.Response(), resp)
|
||||
}
|
||||
|
||||
// CheckStatus 支付状态检测
|
||||
// @Summary Check payment status
|
||||
// @Description Check the payment status of an order by trade ID
|
||||
// @Tags Payment
|
||||
// @Produce json
|
||||
// @Param trade_id path string true "Trade ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /pay/check-status/{trade_id} [get]
|
||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||
tradeId := ctx.Param("trade_id")
|
||||
order, err := service.GetOrderInfoByTradeId(tradeId)
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台
|
||||
// 的 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()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
wallets, err := data.GetAvailableWalletAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, 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{})
|
||||
for _, w := range wallets {
|
||||
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
||||
}
|
||||
|
||||
supports := make([]response.NetworkTokenSupport, 0, len(chains))
|
||||
for _, ch := range chains {
|
||||
network := strings.ToLower(ch.Network)
|
||||
if _, ok := networkHasWallet[network]; !ok {
|
||||
continue
|
||||
}
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
symbols := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
sym := strings.ToUpper(strings.TrimSpace(t.Symbol))
|
||||
if sym == "" {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, sym)
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(symbols)
|
||||
supports = append(supports, response.NetworkTokenSupport{
|
||||
Network: network,
|
||||
Tokens: symbols,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
|
||||
// @Summary List supported asset records
|
||||
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network.
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter (e.g. tron, ethereum)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets/records [get]
|
||||
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||
network := ctx.QueryParam("network")
|
||||
list, err := data.ListEnabledChainTokens(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, list)
|
||||
}
|
||||
|
||||
// GetSupportedAsset 查询单条支持项(无需鉴权)。
|
||||
// @Summary Get a supported asset
|
||||
// @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 {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
token, err := data.GetChainTokenByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if token.ID <= 0 || !token.Enabled {
|
||||
return c.FailJson(ctx, constant.SupportedAssetNotFound)
|
||||
}
|
||||
return c.SucJson(ctx, token)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/shengdoushi/base58"
|
||||
)
|
||||
|
||||
const AddressLength = 20
|
||||
const PrefixMainnet = 0x41
|
||||
|
||||
// Encode returns the Base58 encoding of input using the Bitcoin alphabet.
|
||||
func Encode(input []byte) string {
|
||||
return base58.Encode(input, base58.BitcoinAlphabet)
|
||||
}
|
||||
|
||||
// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum).
|
||||
func EncodeCheck(input []byte) string {
|
||||
h256h0 := sha256.New()
|
||||
h256h0.Write(input)
|
||||
h0 := h256h0.Sum(nil)
|
||||
|
||||
h256h1 := sha256.New()
|
||||
h256h1.Write(h0)
|
||||
h1 := h256h1.Sum(nil)
|
||||
|
||||
inputCheck := append(append([]byte(nil), input...), h1[:4]...)
|
||||
|
||||
return Encode(inputCheck)
|
||||
}
|
||||
+14
-104
@@ -1,116 +1,26 @@
|
||||
module github.com/GMWalletApp/epusdt
|
||||
module github.com/assimon/luuu
|
||||
|
||||
go 1.25.0
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcutil v1.0.2
|
||||
github.com/dromara/carbon/v2 v2.6.15
|
||||
github.com/ethereum/go-ethereum v1.15.11
|
||||
github.com/gagliardetto/solana-go v1.16.0
|
||||
github.com/go-resty/resty/v2 v2.11.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-resty/resty/v2 v2.7.0
|
||||
github.com/golang-module/carbon/v2 v2.0.1
|
||||
github.com/gookit/color v1.5.0
|
||||
github.com/gookit/goutil v0.4.6
|
||||
github.com/gookit/validate v1.3.1
|
||||
github.com/gookit/goutil v0.4.4
|
||||
github.com/gookit/validate v1.2.11
|
||||
github.com/hibiken/asynq v0.22.1
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/labstack/echo/v4 v4.6.0
|
||||
github.com/libtnb/sqlite v1.0.1
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/shengdoushi/base58 v1.0.0
|
||||
github.com/shopspring/decimal v1.3.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/viper v1.9.0
|
||||
go.uber.org/zap v1.17.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/telebot.v3 v3.0.0
|
||||
gorm.io/driver/mysql v1.5.1
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.20.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/consensys/bavard v0.1.27 // indirect
|
||||
github.com/consensys/gnark-crypto v0.16.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gookit/filter v1.1.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.3.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/labstack/gommon v0.3.0 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mmcloughlin/addchain v0.4.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/supranational/blst v0.3.14 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.3 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.47.0 // indirect
|
||||
rsc.io/tmplfunc v0.0.3 // indirect
|
||||
gorm.io/driver/mysql v1.1.2
|
||||
gorm.io/gorm v1.21.15
|
||||
)
|
||||
|
||||
+690
-384
File diff suppressed because it is too large
Load Diff
@@ -1,364 +0,0 @@
|
||||
// Package install provides a first-run setup REST API.
|
||||
//
|
||||
// When the .env config file is absent (or has install=true) the HTTP start
|
||||
// command calls RunInstallServer, which listens on the same address the main
|
||||
// server will eventually use (default :8000) and mounts two JSON endpoints
|
||||
// under /install consumed by the frontend install UI:
|
||||
//
|
||||
// GET /install/defaults — default field values for the form
|
||||
// POST /install — validate + write .env, then shut down
|
||||
//
|
||||
// The HTTP listen address is submitted as two separate fields (http_bind_addr
|
||||
// and http_bind_port) and combined internally as "ADDR:PORT" before writing
|
||||
// the http_listen key in .env. This makes the form easier for users who only
|
||||
// want to change the port without touching the bind address.
|
||||
//
|
||||
// Once the .env is written the install server stops and normal bootstrap
|
||||
// proceeds on the same port without a restart.
|
||||
package install
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||
"github.com/gookit/color"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// DefaultInstallAddr is the listen address used by the install API.
|
||||
// Matches the default http_listen so no extra port is needed.
|
||||
const DefaultInstallAddr = ":8000"
|
||||
|
||||
// InstallRequest is the payload submitted by the install form.
|
||||
// All fields are optional except AppURI; omitted fields fall back to InstallDefaults().
|
||||
type InstallRequest struct {
|
||||
// Application display name (default: epusdt)
|
||||
AppName string `json:"app_name" form:"app_name" example:"epusdt"`
|
||||
// Public base URL of the service, e.g. https://pay.example.com (required)
|
||||
AppURI string `json:"app_uri" form:"app_uri" example:"https://pay.example.com"`
|
||||
// Bind address for the HTTP server (default: 127.0.0.1)
|
||||
HttpBindAddr string `json:"http_bind_addr" form:"http_bind_addr" example:"127.0.0.1"`
|
||||
// Bind port for the HTTP server (default: 8000)
|
||||
HttpBindPort int `json:"http_bind_port" form:"http_bind_port" example:"8000"`
|
||||
// Runtime directory for SQLite DB and temp files (default: ./runtime)
|
||||
RuntimeRootPath string `json:"runtime_root_path" form:"runtime_root_path" example:"./runtime"`
|
||||
// Directory for application log files (default: ./logs)
|
||||
LogSavePath string `json:"log_save_path" form:"log_save_path" example:"./logs"`
|
||||
// Minutes before an unpaid order expires (default: 10)
|
||||
OrderExpirationTime int `json:"order_expiration_time" form:"order_expiration_time" example:"10"`
|
||||
// Maximum webhook retry attempts (default: 1)
|
||||
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
|
||||
}
|
||||
|
||||
// InstallDefaults returns sensible default values for the install form.
|
||||
func InstallDefaults() InstallRequest {
|
||||
return InstallRequest{
|
||||
AppName: "epusdt",
|
||||
AppURI: "",
|
||||
HttpBindAddr: "127.0.0.1",
|
||||
HttpBindPort: 8000,
|
||||
RuntimeRootPath: "./runtime",
|
||||
LogSavePath: "./logs",
|
||||
OrderExpirationTime: 10,
|
||||
OrderNoticeMaxRetry: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// installHandler holds the per-invocation state shared between handlers.
|
||||
type installHandler struct {
|
||||
envFilePath string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
|
||||
return c.Redirect(http.StatusFound, "/install")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveInstallWWWRoot() string {
|
||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||
// of the working directory. main.go extracts www/ next to the binary.
|
||||
wwwRoot := "./www"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||
}
|
||||
}
|
||||
return wwwRoot
|
||||
}
|
||||
|
||||
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
|
||||
h := &installHandler{
|
||||
envFilePath: envFilePath,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
// api routes for the install frontend
|
||||
api := e.Group("/api")
|
||||
|
||||
api.GET("/install/defaults", h.GetDefaults)
|
||||
api.POST("/install", h.Submit)
|
||||
|
||||
// Redirect browser visits on root to /install so first-run users land
|
||||
// on the wizard directly. This must run before the static middleware,
|
||||
// otherwise "/" is intercepted by the SPA index.html fallback.
|
||||
e.Use(installRootRedirectMiddleware)
|
||||
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
|
||||
},
|
||||
HTML5: true,
|
||||
Index: "index.html",
|
||||
Root: wwwRoot,
|
||||
}))
|
||||
|
||||
return e, h
|
||||
}
|
||||
|
||||
// GetDefaults returns default values for the install form.
|
||||
//
|
||||
// @Summary Install — get default values
|
||||
// @Description Returns sensible default field values for the first-run install form.
|
||||
//
|
||||
// Available only before the .env config file has been written.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Produce json
|
||||
// @Success 200 {object} InstallRequest "Default install values"
|
||||
// @Router /api/install/defaults [get]
|
||||
func (h *installHandler) GetDefaults(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, InstallDefaults())
|
||||
}
|
||||
|
||||
// Submit validates the install payload, writes the .env file, and signals
|
||||
// the install server to shut down so the main bootstrap can proceed.
|
||||
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
|
||||
// the http_listen config key (e.g. 0.0.0.0:8000).
|
||||
//
|
||||
// @Summary Install — submit configuration
|
||||
// @Description Validates the submitted configuration and writes the .env file.
|
||||
//
|
||||
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
|
||||
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
|
||||
// http_bind_port must be in the range 1–65535 if provided.
|
||||
// app_uri is required. All other fields are optional and fall back to
|
||||
// the defaults returned by GET /api/install/defaults.
|
||||
// Sets install=false in the written .env, then shuts down the install
|
||||
// server so that normal application bootstrap starts on the same port.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body InstallRequest true "Install configuration"
|
||||
// @Success 200 {object} map[string]string "message"
|
||||
// @Failure 400 {object} map[string]string "error"
|
||||
// @Failure 500 {object} map[string]string "error"
|
||||
// @Router /api/install [post]
|
||||
func (h *installHandler) Submit(c echo.Context) error {
|
||||
req := new(InstallRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.AppURI = strings.TrimSpace(req.AppURI)
|
||||
if req.AppURI == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "app_uri is required"})
|
||||
}
|
||||
if req.HttpBindPort != 0 && (req.HttpBindPort < 1 || req.HttpBindPort > 65535) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "http_bind_port must be between 1 and 65535"})
|
||||
}
|
||||
d := InstallDefaults()
|
||||
if strings.TrimSpace(req.AppName) == "" {
|
||||
req.AppName = d.AppName
|
||||
}
|
||||
if strings.TrimSpace(req.HttpBindAddr) == "" {
|
||||
req.HttpBindAddr = d.HttpBindAddr
|
||||
}
|
||||
if req.HttpBindPort <= 0 {
|
||||
req.HttpBindPort = d.HttpBindPort
|
||||
}
|
||||
if strings.TrimSpace(req.RuntimeRootPath) == "" {
|
||||
req.RuntimeRootPath = d.RuntimeRootPath
|
||||
}
|
||||
if strings.TrimSpace(req.LogSavePath) == "" {
|
||||
req.LogSavePath = d.LogSavePath
|
||||
}
|
||||
if req.OrderExpirationTime <= 0 {
|
||||
req.OrderExpirationTime = d.OrderExpirationTime
|
||||
}
|
||||
if req.OrderNoticeMaxRetry < 0 {
|
||||
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
|
||||
}
|
||||
|
||||
if err := writeEnvFile(h.envFilePath, req); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
go func() { close(h.done) }()
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"})
|
||||
}
|
||||
|
||||
// RunInstallServer starts the install REST API on listenAddr (default :8000)
|
||||
// under the /install path and blocks until the .env file has been written.
|
||||
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
|
||||
func RunInstallServer(listenAddr, envFilePath string) {
|
||||
if listenAddr == "" {
|
||||
listenAddr = DefaultInstallAddr
|
||||
}
|
||||
|
||||
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
|
||||
|
||||
// Build a human-readable URL for the console hint.
|
||||
installHost := listenAddr
|
||||
if strings.HasPrefix(installHost, ":") {
|
||||
installHost = "localhost" + installHost
|
||||
}
|
||||
color.Green.Printf("[install] no config found — install API available at http://%s/install\n", installHost)
|
||||
|
||||
go func() {
|
||||
<-h.done
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = e.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
if err := e.Start(listenAddr); err != nil && err != http.ErrServerClosed {
|
||||
color.Red.Printf("[install] server error: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
color.Green.Printf("[install] configuration saved to %s, starting…\n", envFilePath)
|
||||
}
|
||||
|
||||
// formControlledKeys are keys whose values always come from the install form
|
||||
// (or are set unconditionally by the template). Existing config values for
|
||||
// these keys must NOT be preserved — the form submission takes precedence.
|
||||
var formControlledKeys = map[string]bool{
|
||||
"app_name": true,
|
||||
"app_uri": true,
|
||||
"http_listen": true,
|
||||
"runtime_root_path": true,
|
||||
"log_save_path": true,
|
||||
"order_expiration_time": true,
|
||||
"order_notice_max_retry": true,
|
||||
"install": true,
|
||||
}
|
||||
|
||||
// writeEnvFile renders and writes a minimal .env file.
|
||||
// If the file already exists, values for keys that are NOT controlled by the
|
||||
// install form are preserved from the existing file so that operator-specific
|
||||
// settings (tg_bot_token, db_type, etc.) survive a re-install.
|
||||
// Keys that the form controls (app_uri, http_listen, …) always use the
|
||||
// submitted values.
|
||||
func writeEnvFile(path string, r *InstallRequest) error {
|
||||
// Collect existing non-empty key→value pairs for non-form keys.
|
||||
existingValues := map[string]string{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(line, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(line[:idx])
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
if v != "" && !formControlledKeys[k] {
|
||||
existingValues[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Render the template into a buffer first.
|
||||
var buf bytes.Buffer
|
||||
if err := envTemplate.Execute(&buf, r); err != nil {
|
||||
return fmt.Errorf("render env template: %w", err)
|
||||
}
|
||||
|
||||
// For non-form keys that already had a value, substitute the existing value
|
||||
// so the template default does not clobber operator configuration.
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(trimmed, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(trimmed[:idx])
|
||||
if existing, ok := existingValues[k]; ok {
|
||||
lines[i] = k + "=" + existing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open config file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
|
||||
app_uri={{.AppURI}}
|
||||
log_level=info
|
||||
http_access_log=false
|
||||
sql_debug=false
|
||||
http_listen={{.HttpBindAddr}}:{{.HttpBindPort}}
|
||||
|
||||
static_path=/static
|
||||
runtime_root_path={{.RuntimeRootPath}}
|
||||
|
||||
log_save_path={{.LogSavePath}}
|
||||
log_max_size=32
|
||||
log_max_age=7
|
||||
max_backups=3
|
||||
|
||||
# supported values: postgres,mysql,sqlite
|
||||
db_type=sqlite
|
||||
|
||||
# sqlite primary database config
|
||||
sqlite_database_filename=
|
||||
sqlite_table_prefix=
|
||||
|
||||
# sqlite runtime store config
|
||||
runtime_sqlite_filename=epusdt-runtime.db
|
||||
|
||||
# background scheduler config
|
||||
queue_concurrency=10
|
||||
queue_poll_interval_ms=1000
|
||||
callback_retry_base_seconds=5
|
||||
|
||||
order_expiration_time={{.OrderExpirationTime}}
|
||||
order_notice_max_retry={{.OrderNoticeMaxRetry}}
|
||||
|
||||
api_rate_url=
|
||||
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=false
|
||||
`))
|
||||
@@ -1,224 +0,0 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestInstallDefaults(t *testing.T) {
|
||||
d := InstallDefaults()
|
||||
if d.AppName != "epusdt" {
|
||||
t.Errorf("AppName = %q, want epusdt", d.AppName)
|
||||
}
|
||||
if d.HttpBindAddr != "127.0.0.1" {
|
||||
t.Errorf("HttpBindAddr = %q, want 127.0.0.1", d.HttpBindAddr)
|
||||
}
|
||||
if d.HttpBindPort != 8000 {
|
||||
t.Errorf("HttpBindPort = %d, want 8000", d.HttpBindPort)
|
||||
}
|
||||
if d.OrderExpirationTime != 10 {
|
||||
t.Errorf("OrderExpirationTime = %d, want 10", d.OrderExpirationTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEnvFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
|
||||
req := &InstallRequest{
|
||||
AppName: "myapp",
|
||||
AppURI: "http://1.2.3.4:8000",
|
||||
HttpBindAddr: "0.0.0.0",
|
||||
HttpBindPort: 9000,
|
||||
RuntimeRootPath: "./runtime",
|
||||
LogSavePath: "./logs",
|
||||
OrderExpirationTime: 15,
|
||||
OrderNoticeMaxRetry: 3,
|
||||
}
|
||||
if err := writeEnvFile(path, req); err != nil {
|
||||
t.Fatalf("writeEnvFile: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read env: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"app_name=myapp",
|
||||
"app_uri=http://1.2.3.4:8000",
|
||||
"http_listen=0.0.0.0:9000",
|
||||
"order_expiration_time=15",
|
||||
"order_notice_max_retry=3",
|
||||
"db_type=sqlite",
|
||||
"install=false",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("env file missing %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPIDefaults(t *testing.T) {
|
||||
h := &installHandler{done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.GET("/install/defaults", h.GetDefaults)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/install/defaults", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["app_name"] != "epusdt" {
|
||||
t.Errorf("app_name = %v, want epusdt", body["app_name"])
|
||||
}
|
||||
if body["http_bind_addr"] != "127.0.0.1" {
|
||||
t.Errorf("http_bind_addr = %v, want 127.0.0.1", body["http_bind_addr"])
|
||||
}
|
||||
if body["http_bind_port"] != float64(8000) {
|
||||
t.Errorf("http_bind_port = %v, want 8000", body["http_bind_port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wwwRoot := filepath.Join(dir, "www")
|
||||
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||
t.Fatalf("mkdir www root: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
|
||||
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/install" {
|
||||
t.Fatalf("Location = %q, want /install", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wwwRoot := filepath.Join(dir, "www")
|
||||
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||
t.Fatalf("mkdir www root: %v", err)
|
||||
}
|
||||
const wantBody = "install-ui"
|
||||
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
|
||||
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/install", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// done channel should be closed after successful submit
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("handler did not close done channel within timeout")
|
||||
}
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("env file not written: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "app_uri=http://10.0.0.1:8000") {
|
||||
t.Errorf("env file missing app_uri; content:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
|
||||
t.Errorf("env file missing http_listen; content:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmitMissingURI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
t.Error("env file should not have been written for invalid request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAPISubmitInvalidPort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
|
||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
||||
e := echo.New()
|
||||
e.POST("/install", h.Submit)
|
||||
|
||||
payload := `{"app_uri":"http://example.com","http_bind_port":99999}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
t.Error("env file should not have been written for invalid port")
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func SetupTestDatabases(t testing.TB) func() {
|
||||
t.Helper()
|
||||
|
||||
viper.Reset()
|
||||
viper.Set("app_uri", "https://example.com")
|
||||
viper.Set("order_expiration_time", 10)
|
||||
viper.Set("order_notice_max_retry", 2)
|
||||
viper.Set("callback_retry_base_seconds", 1)
|
||||
viper.Set("queue_concurrency", 4)
|
||||
viper.Set("queue_poll_interval_ms", 50)
|
||||
|
||||
config.HTTPAccessLog = false
|
||||
config.SQLDebug = false
|
||||
config.LogLevel = "error"
|
||||
appLog.Sugar = zap.NewNop().Sugar()
|
||||
|
||||
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
||||
runtimeDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "runtime.db"))
|
||||
|
||||
mustMigrate(t, mainDB,
|
||||
&mdb.Orders{},
|
||||
&mdb.WalletAddress{},
|
||||
&mdb.ApiKey{},
|
||||
&mdb.Setting{},
|
||||
&mdb.NotificationChannel{},
|
||||
&mdb.Chain{},
|
||||
&mdb.ChainToken{},
|
||||
&mdb.RpcNode{},
|
||||
&mdb.AdminUser{},
|
||||
)
|
||||
mustMigrate(t, runtimeDB, &mdb.TransactionLock{})
|
||||
|
||||
dao.Mdb = mainDB
|
||||
dao.RuntimeDB = runtimeDB
|
||||
config.SettingsGetString = func(key string) string {
|
||||
if dao.Mdb == nil {
|
||||
return ""
|
||||
}
|
||||
var row mdb.Setting
|
||||
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return row.Value
|
||||
}
|
||||
|
||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||
for _, network := range []string{
|
||||
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
||||
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
|
||||
} {
|
||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||
}
|
||||
|
||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||
// `pid=1001` still match.
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-default",
|
||||
Pid: "test-token", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
mainDB.Create(&mdb.ApiKey{
|
||||
Name: "test-pid-1001",
|
||||
Pid: "1001", SecretKey: "test-token",
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
})
|
||||
if err := dao.Mdb.Create(&mdb.Setting{
|
||||
Group: "rate",
|
||||
Key: "rate.forced_usdt_rate",
|
||||
Value: "1.0",
|
||||
Type: "string",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
closeDB(t, runtimeDB)
|
||||
closeDB(t, mainDB)
|
||||
dao.Mdb = nil
|
||||
dao.RuntimeDB = nil
|
||||
config.SettingsGetString = nil
|
||||
viper.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func mustOpenSQLite(t testing.TB, path string) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{SingularTable: true},
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite %s: %v", path, err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle for %s: %v", path, err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func mustMigrate(t testing.TB, db *gorm.DB, models ...interface{}) {
|
||||
t.Helper()
|
||||
if err := db.AutoMigrate(models...); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeDB(t testing.TB, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle close: %v", err)
|
||||
}
|
||||
if err = sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close db: %v", err)
|
||||
}
|
||||
}
|
||||
+5
-103
@@ -1,116 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/command"
|
||||
"github.com/assimon/luuu/bootstrap"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/gookit/color"
|
||||
)
|
||||
|
||||
//go:embed all:static
|
||||
var staticDir embed.FS
|
||||
|
||||
//go:embed all:www
|
||||
var wwwDir embed.FS
|
||||
|
||||
func executableDir() (string, error) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(exePath), nil
|
||||
}
|
||||
|
||||
func extractEmbeddedDir(src embed.FS, embeddedRoot, dstRoot string) error {
|
||||
sub, err := fs.Sub(src, embeddedRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dstRoot, path)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in, err := sub.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func releaseStatic(fs embed.FS, target string) (string, error) {
|
||||
baseDir, err := executableDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(baseDir, target)
|
||||
|
||||
if err := os.RemoveAll(targetDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := extractEmbeddedDir(fs, target, targetDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return targetDir, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
staticPath, err := releaseStatic(staticDir, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("static released to:", staticPath)
|
||||
|
||||
wwwwPath, err := releaseStatic(wwwDir, "www")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("www released to:", wwwwPath)
|
||||
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
color.Error.Println("[Start Server Err!!!] ", err)
|
||||
}
|
||||
}()
|
||||
if err := command.Execute(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
color.Green.Printf("%s\n", " _____ _ _ \n | ____|_ __ _ _ ___ __| | |_ \n | _| | '_ \\| | | / __|/ _` | __|\n | |___| |_) | |_| \\__ \\ (_| | |_ \n |_____| .__/ \\__,_|___/\\__,_|\\__|\n |_| ")
|
||||
color.Infof("Epusdt version(%s) Powered by %s %s \n", config.GetAppVersion(), "assimon", "https://github.com/assimon/epusdt")
|
||||
bootstrap.Start()
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// AdminUserIDKey is the echo.Context key holding the authenticated
|
||||
// admin user's ID after successful JWT validation.
|
||||
AdminUserIDKey = "admin_user_id"
|
||||
// AdminUsernameKey mirrors the username for convenience.
|
||||
AdminUsernameKey = "admin_username"
|
||||
)
|
||||
|
||||
// CheckAdminJWT validates an Authorization: Bearer <jwt> header against
|
||||
// the HS256 secret stored in system.jwt_secret. On success it injects
|
||||
// the admin user ID and username into the echo context.
|
||||
//
|
||||
// Accepts both "Authorization: Bearer <jwt>" and a bare "<jwt>" for
|
||||
// developer convenience (curl / postman). The token itself is always
|
||||
// cryptographically validated — the relaxed header parsing doesn't
|
||||
// widen the trust surface.
|
||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
|
||||
if raw == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
|
||||
}
|
||||
token := strings.TrimSpace(raw[len("Bearer "):])
|
||||
if token == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||
}
|
||||
claims, err := appjwt.Parse(token)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||
}
|
||||
ctx.Set(AdminUserIDKey, claims.AdminUserID)
|
||||
ctx.Set(AdminUsernameKey, claims.Username)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-116
@@ -2,142 +2,36 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/json"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/json"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
// Context keys populated by CheckApiSign after successful verification.
|
||||
// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id.
|
||||
const (
|
||||
ApiKeyIDKey = "api_key_id"
|
||||
ApiKeyRowKey = "api_key_row"
|
||||
)
|
||||
|
||||
// CheckApiSign validates the body signature against the secret_key of
|
||||
// the api_keys row matching the submitted "pid" field. A single row is
|
||||
// valid for all gateway flows — identification is always by pid.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Extract the pid from the request body.
|
||||
// 2. Look up the enabled row by pid; if missing, return signature error.
|
||||
// 3. Verify signature == MD5(sorted_params + secret_key).
|
||||
// 4. Enforce IP whitelist (empty = allow any).
|
||||
// 5. Bump call_count / last_used_at (best-effort).
|
||||
// 6. Stash api_key_id + row in context and rewind the body.
|
||||
func CheckApiSign() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
params, err := io.ReadAll(ctx.Request().Body)
|
||||
params, err := ioutil.ReadAll(ctx.Request().Body)
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
// Rewind the body for downstream bindings regardless of outcome.
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(params))
|
||||
|
||||
m := make(map[string]interface{})
|
||||
contentType := ctx.Request().Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
values, parseErr := url.ParseQuery(string(params))
|
||||
if parseErr != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
for k, vs := range values {
|
||||
if len(vs) > 0 {
|
||||
m[k] = vs[0]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err = json.Cjson.Unmarshal(params, &m); err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
}
|
||||
err = json.Cjson.Unmarshal(params, &m)
|
||||
signature, ok := m["signature"]
|
||||
if !ok {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
identifier := extractPid(m)
|
||||
if identifier == "" {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
row, err := data.GetEnabledApiKey(identifier)
|
||||
if err != nil || row.ID == 0 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
checkSignature, err := sign.Get(m, row.SecretKey)
|
||||
checkSignature, err := sign.Get(m, config.GetApiAuthToken())
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
signatureStr, _ := signature.(string)
|
||||
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 {
|
||||
if checkSignature != signature {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
if !IsIPWhitelisted(row.IpWhitelist, ctx.RealIP()) {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
_ = data.TouchApiKeyUsage(row.ID)
|
||||
|
||||
ctx.Set(ApiKeyIDKey, row.ID)
|
||||
ctx.Set(ApiKeyRowKey, row)
|
||||
ctx.Request().Body = ioutil.NopCloser(bytes.NewBuffer(params))
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractPid reads the "pid" field from the request body. JSON numbers
|
||||
// unmarshal to float64 (EPAY merchants typically send numeric pid); we
|
||||
// format with -1 precision to drop trailing zeros so "1000" matches
|
||||
// regardless of whether the client sent a string or a number.
|
||||
func extractPid(m map[string]interface{}) string {
|
||||
v, ok := m["pid"]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsIPWhitelisted checks the CSV IP list. Empty list = open.
|
||||
// Entries may be single IPs or CIDR blocks.
|
||||
func IsIPWhitelisted(csv, remote string) bool {
|
||||
csv = strings.TrimSpace(csv)
|
||||
if csv == "" {
|
||||
return true
|
||||
}
|
||||
remoteIP := net.ParseIP(remote)
|
||||
for _, raw := range strings.Split(csv, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
_, cidr, err := net.ParseCIDR(entry)
|
||||
if err == nil && remoteIP != nil && cidr.Contains(remoteIP) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if entry == remote {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
if err := DBInit(); err != nil {
|
||||
log.Fatalf("DBInit err: %v", err)
|
||||
}
|
||||
|
||||
if err := RuntimeInit(); err != nil {
|
||||
log.Fatalf("RuntimeInit err: %v", err)
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -1,30 +1,47 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Mdb *gorm.DB
|
||||
|
||||
func DBInit() error {
|
||||
dbType := viper.GetString("db_type")
|
||||
if strings.EqualFold(dbType, "postgres") {
|
||||
if err := PostgreSQLInit(); err != nil {
|
||||
return err
|
||||
// MysqlInit 数据库初始化
|
||||
func MysqlInit() {
|
||||
var err error
|
||||
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: viper.GetString("mysql_table_prefix"),
|
||||
SingularTable: true,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else if strings.EqualFold(dbType, "sqlite") {
|
||||
if err := SqliteInit(); err != nil {
|
||||
return err
|
||||
if config.AppDebug {
|
||||
Mdb = Mdb.Debug()
|
||||
}
|
||||
} else {
|
||||
if err := MysqlInit(); err != nil {
|
||||
return err
|
||||
sqlDB, err := Mdb.DB()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err)
|
||||
panic(err)
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
|
||||
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
|
||||
MdbTableInit()
|
||||
return nil
|
||||
log.Sugar.Debug("[store_db] mysql connDB success")
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// MysqlInit 数据库初始化
|
||||
func MysqlInit() error {
|
||||
var err error
|
||||
Mdb, err = gorm.Open(mysql.Open(config.MysqlDns), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: viper.GetString("mysql_table_prefix"),
|
||||
SingularTable: true,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] mysql open DB,err=%s\n", err)
|
||||
// panic(err)
|
||||
return err
|
||||
}
|
||||
if config.SQLDebug {
|
||||
Mdb = Mdb.Debug()
|
||||
}
|
||||
sqlDB, err := Mdb.DB()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] mysql get DB,err=%s\n", err)
|
||||
// panic(err)
|
||||
time.Sleep(10 * time.Second)
|
||||
MysqlInit()
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(viper.GetInt("mysql_max_idle_conns"))
|
||||
sqlDB.SetMaxOpenConns(viper.GetInt("mysql_max_open_conns"))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("mysql_max_life_time")))
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] mysql connDB err:%s", err.Error())
|
||||
// panic(err)
|
||||
time.Sleep(10 * time.Second)
|
||||
MysqlInit()
|
||||
return err
|
||||
}
|
||||
log.Sugar.Debug("[store_db] mysql connDB success")
|
||||
return nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// PostgreSQLInit 数据库初始化
|
||||
func PostgreSQLInit() error {
|
||||
var err error
|
||||
user := viper.GetString("postgres_user")
|
||||
pass := viper.GetString("postgres_passwd")
|
||||
host := viper.GetString("postgres_host")
|
||||
port := viper.GetString("postgres_port")
|
||||
dbname := viper.GetString("postgres_database")
|
||||
dsn := fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
|
||||
user, pass, host, port, dbname,
|
||||
)
|
||||
Mdb, err = gorm.Open(postgres.Open(dsn),
|
||||
&gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: viper.GetString("postgres_table_prefix"),
|
||||
SingularTable: true,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] postgres open DB,err=%s\n", err)
|
||||
// panic(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if config.SQLDebug {
|
||||
Mdb = Mdb.Debug()
|
||||
}
|
||||
sqlDB, err := Mdb.DB()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] postgres get DB,err=%s\n", err)
|
||||
// panic(err)
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(viper.GetInt("postgres_max_idle_conns"))
|
||||
sqlDB.SetMaxOpenConns(viper.GetInt("postgres_max_open_conns"))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour * time.Duration(viper.GetInt("postgres_max_life_time")))
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] postgres connDB err:%s", err.Error())
|
||||
// panic(err)
|
||||
return err
|
||||
}
|
||||
log.Sugar.Debug("[store_db] postgres connDB success")
|
||||
return nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// SqliteInit 数据库初始化
|
||||
func SqliteInit() error {
|
||||
var err error
|
||||
dbFilename := config.GetPrimarySqlitePath()
|
||||
if err = os.MkdirAll(filepath.Dir(dbFilename), 0o755); err != nil {
|
||||
color.Red.Printf("[store_db] sqlite mkdir err=%s\n", err)
|
||||
return err
|
||||
}
|
||||
color.Green.Printf("[store_db] sqlite filename: %s\n", dbFilename)
|
||||
Mdb, err = openDB(dbFilename, &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: viper.GetString("sqlite_table_prefix"),
|
||||
SingularTable: true,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] sqlite open DB,err=%s\n", err)
|
||||
// panic(err)
|
||||
return err
|
||||
}
|
||||
if config.SQLDebug {
|
||||
Mdb = Mdb.Debug()
|
||||
}
|
||||
if _, err = configureSQLite(Mdb, 1); err != nil {
|
||||
color.Red.Printf("[store_db] sqlite connDB err:%s", err.Error())
|
||||
return err
|
||||
}
|
||||
log.Sugar.Debug("[store_db] sqlite connDB success")
|
||||
return nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build sqlite_cgo
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !sqlite_cgo
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/libtnb/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/gookit/color"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
|
||||
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
|
||||
// the minimum static rows: chains, chain tokens, default settings, and
|
||||
// a Telegram notification channel migrated from legacy settings keys.
|
||||
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
|
||||
// established. The install wizard (see install/installer.go) writes the
|
||||
// .env file before bootstrap runs, so this function always finds the DB
|
||||
// available.
|
||||
func MdbTableInit() {
|
||||
once.Do(func() {
|
||||
migrations := []struct {
|
||||
name string
|
||||
model interface{}
|
||||
}{
|
||||
{"Orders", &mdb.Orders{}},
|
||||
{"WalletAddress", &mdb.WalletAddress{}},
|
||||
{"AdminUser", &mdb.AdminUser{}},
|
||||
{"ApiKey", &mdb.ApiKey{}},
|
||||
{"Setting", &mdb.Setting{}},
|
||||
{"NotificationChannel", &mdb.NotificationChannel{}},
|
||||
{"Chain", &mdb.Chain{}},
|
||||
{"ChainToken", &mdb.ChainToken{}},
|
||||
{"RpcNode", &mdb.RpcNode{}},
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if err := Mdb.AutoMigrate(m.model); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
seedChains()
|
||||
seedRpcNodes()
|
||||
seedChainTokens()
|
||||
seedDefaultSettings()
|
||||
seedTelegramChannelFromSettings()
|
||||
})
|
||||
}
|
||||
|
||||
// seedChains inserts the built-in networks as enabled rows. Uses
|
||||
// ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist.
|
||||
func seedChains() {
|
||||
defaults := []mdb.Chain{
|
||||
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedChainTokens inserts default token configurations for each chain.
|
||||
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
|
||||
// re-runs are no-ops and admin edits to contract_address / decimals /
|
||||
// min_amount persist across restarts.
|
||||
func seedChainTokens() {
|
||||
// Contract-address rows are matched by scanners via exact contract
|
||||
// lookup. Native-token rows (empty contract_address) are matched by
|
||||
// symbol — these act as admin toggles for the native payment path.
|
||||
defaults := []mdb.ChainToken{
|
||||
// TRON
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
// Ethereum
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
|
||||
// Solana
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
|
||||
// Polygon
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||
// Plasma
|
||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||
func seedRpcNodes() {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ? AND url = ?", d.Network, d.Url).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := Mdb.Create(&d).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultSettings inserts built-in default settings.
|
||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||
func seedDefaultSettings() {
|
||||
defaults := []mdb.Setting{
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
|
||||
// Telegram config into a notification_channels row so the notify dispatcher
|
||||
// can use it. Idempotent: no-op when any telegram channel already exists.
|
||||
//
|
||||
// Legacy keys read from settings:
|
||||
//
|
||||
// system.telegram_bot_token → TelegramConfig.BotToken
|
||||
// system.telegram_chat_id → TelegramConfig.ChatID
|
||||
// system.telegram_payment_notice_enabled → events.pay_success
|
||||
// system.telegram_abnormal_notice_enabled → events.order_expired
|
||||
func seedTelegramChannelFromSettings() {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
SyncTelegramChannelFromSettings()
|
||||
}
|
||||
|
||||
// SyncTelegramChannelFromSettings reads the four Telegram keys from the
|
||||
// settings table and upserts the matching notification_channels row:
|
||||
// - creates a new row when none exists yet
|
||||
// - updates config+events on the first existing row when settings change
|
||||
//
|
||||
// This is called at startup (via seedTelegramChannelFromSettings) and by
|
||||
// the settings controller whenever any Telegram key is changed at runtime.
|
||||
func SyncTelegramChannelFromSettings() {
|
||||
keys := []string{
|
||||
"system.telegram_bot_token",
|
||||
"system.telegram_chat_id",
|
||||
"system.telegram_payment_notice_enabled",
|
||||
"system.telegram_abnormal_notice_enabled",
|
||||
}
|
||||
var rows []mdb.Setting
|
||||
if err := Mdb.Where("key IN ?", keys).Find(&rows).Error; err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel: read settings err=%s\n", err)
|
||||
return
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.Key] = r.Value
|
||||
}
|
||||
|
||||
botToken := strings.TrimSpace(m["system.telegram_bot_token"])
|
||||
chatIDStr := strings.TrimSpace(m["system.telegram_chat_id"])
|
||||
if botToken == "" || chatIDStr == "" {
|
||||
return // No telegram config to sync.
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel: invalid chat_id=%q\n", chatIDStr)
|
||||
return
|
||||
}
|
||||
|
||||
paymentEnabled, _ := strconv.ParseBool(m["system.telegram_payment_notice_enabled"])
|
||||
abnormalEnabled, _ := strconv.ParseBool(m["system.telegram_abnormal_notice_enabled"])
|
||||
|
||||
configJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"bot_token": botToken,
|
||||
"chat_id": chatID,
|
||||
"proxy": "",
|
||||
})
|
||||
eventsJSON, _ := json.Marshal(map[string]bool{
|
||||
mdb.NotifyEventPaySuccess: paymentEnabled,
|
||||
mdb.NotifyEventOrderExpired: abnormalEnabled,
|
||||
mdb.NotifyEventDailyReport: false,
|
||||
})
|
||||
|
||||
// Try to update an existing row first.
|
||||
// Use Find (not First) to avoid GORM printing a spurious
|
||||
// "record not found" log when the table is empty on first boot.
|
||||
var existing mdb.NotificationChannel
|
||||
Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Order("id ASC").Limit(1).Find(&existing)
|
||||
if existing.ID != 0 {
|
||||
// Row exists — patch config and events.
|
||||
if err2 := Mdb.Model(&existing).Updates(map[string]interface{}{
|
||||
"config": string(configJSON),
|
||||
"events": string(eventsJSON),
|
||||
}).Error; err2 != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel update err=%s\n", err2)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No row yet — create one.
|
||||
ch := mdb.NotificationChannel{
|
||||
Type: mdb.NotificationTypeTelegram,
|
||||
Name: "Telegram",
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := Mdb.Create(&ch).Error; err != nil {
|
||||
color.Red.Printf("[store_db] sync telegram channel create err=%s\n", err)
|
||||
} else {
|
||||
color.Green.Printf("[store_db] created telegram notification_channel from settings (id=%d)\n", ch.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/gookit/color"
|
||||
"github.com/spf13/viper"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Rdb *redis.Client
|
||||
|
||||
func RedisInit() {
|
||||
options := redis.Options{
|
||||
Addr: fmt.Sprintf(
|
||||
"%s:%s",
|
||||
viper.GetString("redis_host"),
|
||||
viper.GetString("redis_port")), // Redis地址
|
||||
DB: viper.GetInt("redis_db"), // Redis库
|
||||
PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小
|
||||
MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数
|
||||
IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间
|
||||
}
|
||||
if viper.GetString("redis_passwd") != "" {
|
||||
options.Password = viper.GetString("redis_passwd")
|
||||
}
|
||||
Rdb = redis.NewClient(&options)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pong, err := Rdb.Ping(ctx).Result()
|
||||
if err == redis.Nil {
|
||||
log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.")
|
||||
} else if err != nil {
|
||||
color.Red.Printf("[store_redis] redis connRdb err,err=%s", err)
|
||||
panic(err)
|
||||
} else {
|
||||
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/gookit/color"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
var RuntimeDB *gorm.DB
|
||||
|
||||
func RuntimeInit() error {
|
||||
var err error
|
||||
runtimePath := config.GetRuntimeSqlitePath()
|
||||
color.Green.Printf("[runtime_db] sqlite filename: %s\n", runtimePath)
|
||||
RuntimeDB, err = openDB(runtimePath, &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
SingularTable: true,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logger.Error),
|
||||
})
|
||||
if err != nil {
|
||||
color.Red.Printf("[runtime_db] sqlite open DB,err=%s\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
concurrency := config.GetQueueConcurrency()
|
||||
if concurrency < 2 {
|
||||
concurrency = 2
|
||||
}
|
||||
if concurrency > 16 {
|
||||
concurrency = 16
|
||||
}
|
||||
if _, err = configureSQLite(RuntimeDB, concurrency); err != nil {
|
||||
color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error())
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Sugar.Debug("[runtime_db] sqlite connDB success")
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func configureSQLite(db *gorm.DB, maxOpenConns int) (*sql.DB, error) {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if maxOpenConns <= 0 {
|
||||
maxOpenConns = 1
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sqlDB, nil
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
const InitialAdminPasswordHashAlgorithm = "sha256"
|
||||
|
||||
var (
|
||||
ErrInitAdminPasswordUnavailable = errors.New("initial password unavailable")
|
||||
ErrInitAdminPasswordAlreadyFetched = errors.New("initial password already fetched")
|
||||
)
|
||||
|
||||
// InitialAdminPasswordHashInfo is returned by the hash query endpoint
|
||||
// so the frontend can detect whether the operator is still using the
|
||||
// initial password.
|
||||
type InitialAdminPasswordHashInfo struct {
|
||||
Algorithm string `json:"algorithm" example:"sha256"`
|
||||
PasswordHash string `json:"password_hash" example:"3f79bb7b435b05321651daefd374cdc9f5f72c467ea3f9f3c5f6e6d7e8f9a0b1"`
|
||||
PasswordChanged bool `json:"password_changed" example:"false"`
|
||||
Available bool `json:"available" example:"true"`
|
||||
}
|
||||
|
||||
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
||||
// exists. The password is randomly generated and returned so the caller
|
||||
// can print it to the console. Idempotent — subsequent calls return
|
||||
// ("", false, nil).
|
||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
password = randomAdminPassword()
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
user := &mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(user).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := initAdminPasswordState(password); err != nil {
|
||||
// Account was already created; surface both for visibility while
|
||||
// preserving created=true and password for emergency fallback.
|
||||
return password, true, err
|
||||
}
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashInitialAdminPassword fingerprints the initial plaintext password so
|
||||
// the frontend can compare user input locally without exposing plaintext.
|
||||
func HashInitialAdminPassword(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
func initAdminPasswordState(plain string) error {
|
||||
hash := HashInitialAdminPassword(plain)
|
||||
settings := []mdb.Setting{
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||
Value: plain, Type: mdb.SettingTypeString,
|
||||
Description: "One-time readable initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
|
||||
Value: hash, Type: mdb.SettingTypeString,
|
||||
Description: "SHA-256 fingerprint for initial admin password",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
},
|
||||
{
|
||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: "false", Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
},
|
||||
}
|
||||
for _, row := range settings {
|
||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Keep in-process cache coherent for the current process.
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
// ConsumeInitialAdminPassword returns the one-time initial admin password.
|
||||
// After a successful read, the plaintext is deleted and cannot be fetched
|
||||
// again.
|
||||
func ConsumeInitialAdminPassword() (string, error) {
|
||||
var password string
|
||||
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||
row := new(mdb.Setting)
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||
Limit(1).
|
||||
Find(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.ID == 0 || row.Value == "" {
|
||||
var fetched mdb.Setting
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
|
||||
Limit(1).
|
||||
Find(&fetched).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
|
||||
return ErrInitAdminPasswordAlreadyFetched
|
||||
}
|
||||
return ErrInitAdminPasswordUnavailable
|
||||
}
|
||||
password = row.Value
|
||||
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrInitAdminPasswordAlreadyFetched
|
||||
}
|
||||
return upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||
Value: "true",
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been fetched",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||
settingsCacheMu.Unlock()
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
||||
// and the changed-state flag used by the admin frontend.
|
||||
func GetInitialAdminPasswordHashInfo() (*InitialAdminPasswordHashInfo, error) {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: "",
|
||||
PasswordChanged: true,
|
||||
Available: false,
|
||||
}, nil
|
||||
}
|
||||
return &InitialAdminPasswordHashInfo{
|
||||
Algorithm: InitialAdminPasswordHashAlgorithm,
|
||||
PasswordHash: hash,
|
||||
PasswordChanged: GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsUsingInitialAdminPassword reports whether the current admin password is
|
||||
// still considered the seeded initial password.
|
||||
func IsUsingInitialAdminPassword() bool {
|
||||
hash := strings.TrimSpace(GetSettingString(mdb.SettingKeyInitAdminPasswordHash, ""))
|
||||
if hash == "" {
|
||||
return false
|
||||
}
|
||||
return !GetSettingBool(mdb.SettingKeyInitAdminPasswordChanged, false)
|
||||
}
|
||||
|
||||
// HashPassword bcrypts a plaintext password.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a plaintext password against a bcrypt hash.
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// GetAdminUserByUsername returns the row for a username (case-insensitive).
|
||||
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).
|
||||
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
|
||||
Limit(1).Find(u).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetAdminUserByID returns the row for an ID.
|
||||
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// UpdateAdminUserPassword rehashes and persists a new password.
|
||||
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||
hash, err := HashPassword(newPlain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue := ""
|
||||
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Old installs may not have initial-password metadata; skip in
|
||||
// that case to keep password updates backward compatible.
|
||||
var initHashRow mdb.Setting
|
||||
if err := tx.Model(&mdb.Setting{}).
|
||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
|
||||
Limit(1).
|
||||
Find(&initHashRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
initHash := strings.TrimSpace(initHashRow.Value)
|
||||
if initHash == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
newHash := HashInitialAdminPassword(newPlain)
|
||||
changed := subtle.ConstantTimeCompare([]byte(initHash), []byte(newHash)) != 1
|
||||
changedValue := "true"
|
||||
if !changed {
|
||||
changedValue = "false"
|
||||
}
|
||||
if err := upsertSettingRow(tx, mdb.Setting{
|
||||
Group: mdb.SettingGroupSystem,
|
||||
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||
Value: changedValue,
|
||||
Type: mdb.SettingTypeBool,
|
||||
Description: "Whether initial admin password has been changed",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
changedCacheValue = changedValue
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changedCacheValue != "" {
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
||||
settingsCacheMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchAdminUserLastLogin stamps last_login_at to now.
|
||||
func TouchAdminUserLastLogin(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_login_at", carbon.Now().StdTime()).Error
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetEnabledApiKey looks up an enabled merchant credential row by PID.
|
||||
// A single row is valid for both EPAY and GMPAY flows; the route
|
||||
// determines the flow. Returns (row, nil) on success; (zero row, nil)
|
||||
// when no match; error only on true DB failure.
|
||||
func GetEnabledApiKey(pid string) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("pid = ?", strings.TrimSpace(pid)).
|
||||
Where("status = ?", mdb.ApiKeyStatusEnable).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetApiKeyByID fetches a row by primary key (including disabled).
|
||||
func GetApiKeyByID(id uint64) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListApiKeys returns all rows ordered by ID descending.
|
||||
func ListApiKeys() ([]mdb.ApiKey, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Model(&mdb.ApiKey{}).Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CreateApiKey persists a new row.
|
||||
func CreateApiKey(row *mdb.ApiKey) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateApiKeyFields updates a whitelist of mutable fields on a row.
|
||||
func UpdateApiKeyFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteApiKeyByID removes (soft-deletes) a row.
|
||||
func DeleteApiKeyByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ApiKey{}).Error
|
||||
}
|
||||
|
||||
// TouchApiKeyUsage increments call_count and stamps last_used_at.
|
||||
// Called from the signature middleware after a successful verify.
|
||||
func TouchApiKeyUsage(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"call_count": gorm.Expr("call_count + 1"),
|
||||
"last_used_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// basePid is the starting PID value for api_keys.
|
||||
const basePid = 1000
|
||||
|
||||
// NextPid returns the next available numeric PID for api_keys. Scans
|
||||
// ALL rows (including soft-deleted) to avoid reusing a PID whose row
|
||||
// is still in the table with a non-null deleted_at — the unique index
|
||||
// on pid doesn't know about soft deletes, so reuse would trigger a
|
||||
// DB-level unique violation on Create.
|
||||
func NextPid() (int, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Unscoped().Model(&mdb.ApiKey{}).Select("pid").Find(&rows).Error
|
||||
if err != nil {
|
||||
return basePid, err
|
||||
}
|
||||
maxPid := basePid - 1
|
||||
for _, r := range rows {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(r.Pid)); err == nil && pid > maxPid {
|
||||
maxPid = pid
|
||||
}
|
||||
}
|
||||
return maxPid + 1, nil
|
||||
}
|
||||
|
||||
// SeededApiKey carries the details of a newly created default key so
|
||||
// the caller can print them to the console (secret is never surfaced
|
||||
// again via the admin API — it has to be viewed via /api-keys/:id/secret
|
||||
// after login).
|
||||
type SeededApiKey struct {
|
||||
Pid string
|
||||
Name string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
// EnsureDefaultApiKey seeds ONE universal API key on a fresh install.
|
||||
// The seeded key is valid for all three gateway flows — the admin can
|
||||
// create additional keys later, each also universal. Idempotent: if
|
||||
// any api_keys row already exists, returns nil without seeding.
|
||||
func EnsureDefaultApiKey() (*SeededApiKey, error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.ApiKey{}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
secret := generateHex(32)
|
||||
row := &mdb.ApiKey{
|
||||
Name: "default",
|
||||
Pid: strconv.Itoa(basePid),
|
||||
SecretKey: secret,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SeededApiKey{
|
||||
Pid: row.Pid,
|
||||
Name: row.Name,
|
||||
SecretKey: secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListChains returns all chain rows ordered by id.
|
||||
func ListChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChains returns only rows with Enabled=true.
|
||||
func ListEnabledChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetChainByNetwork fetches one row by network key.
|
||||
func GetChainByNetwork(network string) (*mdb.Chain, error) {
|
||||
row := new(mdb.Chain)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// IsChainEnabled returns whether the row exists and Enabled=true. A
|
||||
// missing row is treated as disabled — scanners must be able to
|
||||
// short-circuit even before the admin creates the chain entry.
|
||||
func IsChainEnabled(network string) bool {
|
||||
row, err := GetChainByNetwork(network)
|
||||
if err != nil || row.ID == 0 {
|
||||
return false
|
||||
}
|
||||
return row.Enabled
|
||||
}
|
||||
|
||||
// UpdateChainFields patches mutable columns.
|
||||
func UpdateChainFields(network string, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Updates(fields).Error
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
|
||||
// An empty network returns all enabled tokens across all networks.
|
||||
func ListEnabledChainTokens(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
tx := dao.Mdb.Model(&mdb.ChainToken{}).Where("enabled = ?", true)
|
||||
if n := strings.ToLower(strings.TrimSpace(network)); n != "" {
|
||||
tx = tx.Where("network = ?", n)
|
||||
}
|
||||
err := tx.Order("network ASC, id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListChainTokens returns rows optionally filtered by network.
|
||||
func ListChainTokens(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
tx := dao.Mdb.Model(&mdb.ChainToken{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChainTokensByNetwork returns the enabled tokens for a network.
|
||||
// Scanners call this to learn which contract addresses to subscribe to.
|
||||
func ListEnabledChainTokensByNetwork(network string) ([]mdb.ChainToken, error) {
|
||||
var rows []mdb.ChainToken
|
||||
err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetEnabledChainTokenByContract finds one enabled token by (network,
|
||||
// contract_address). Used by EVM/TRON scanners to resolve the symbol
|
||||
// and decimals of an incoming transfer. Returns (zero row, nil) if no
|
||||
// match. The contract address match is case-insensitive.
|
||||
func GetEnabledChainTokenByContract(network, contractAddress string) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
addr := strings.TrimSpace(contractAddress)
|
||||
if addr == "" {
|
||||
return row, nil
|
||||
}
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("enabled = ?", true).
|
||||
Where("LOWER(contract_address) = ?", strings.ToLower(addr)).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetEnabledChainTokenBySymbol finds one enabled token by (network, symbol).
|
||||
// Used by Solana native-SOL path where there's no contract address.
|
||||
func GetEnabledChainTokenBySymbol(network, symbol string) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
sym := strings.TrimSpace(symbol)
|
||||
if sym == "" {
|
||||
return row, nil
|
||||
}
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Where("enabled = ?", true).
|
||||
Where("UPPER(symbol) = ?", strings.ToUpper(sym)).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetChainTokenByID fetches by primary key.
|
||||
func GetChainTokenByID(id uint64) (*mdb.ChainToken, error) {
|
||||
row := new(mdb.ChainToken)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateChainToken inserts a row.
|
||||
// If a soft-deleted row with the same (network, symbol) exists it is
|
||||
// restored with the incoming field values rather than creating a duplicate
|
||||
// that would violate the unique index.
|
||||
func CreateChainToken(row *mdb.ChainToken) error {
|
||||
deleted := new(mdb.ChainToken)
|
||||
err := dao.Mdb.Unscoped().
|
||||
Where("network = ? AND symbol = ? AND deleted_at IS NOT NULL", row.Network, row.Symbol).
|
||||
Limit(1).Find(deleted).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted.ID > 0 {
|
||||
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"contract_address": row.ContractAddress,
|
||||
"decimals": row.Decimals,
|
||||
"enabled": row.Enabled,
|
||||
"min_amount": row.MinAmount,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.ID = deleted.ID
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateChainTokenFields patches mutable columns.
|
||||
func UpdateChainTokenFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.ChainToken{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteChainTokenByID soft-deletes the row.
|
||||
func DeleteChainTokenByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ChainToken{}).Error
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// ListNotificationChannels returns all rows, optionally filtered by type.
|
||||
func ListNotificationChannels(channelType string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
tx := dao.Mdb.Model(&mdb.NotificationChannel{})
|
||||
if channelType != "" {
|
||||
tx = tx.Where("type = ?", strings.ToLower(channelType))
|
||||
}
|
||||
err := tx.Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChannelsByEvent returns enabled rows whose Events JSON has
|
||||
// the named event key set to true. Done in Go (rather than JSON_EXTRACT)
|
||||
// because the runtime DB is DB-agnostic (MySQL/Postgres/SQLite).
|
||||
func ListEnabledChannelsByEvent(event string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
if err := dao.Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, r := range rows {
|
||||
events := map[string]bool{}
|
||||
if r.Events != "" {
|
||||
_ = json.Unmarshal([]byte(r.Events), &events)
|
||||
}
|
||||
if events[event] {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetFirstEnabledTelegramChannel returns the oldest enabled telegram
|
||||
// row, or nil ID if none. Telegram bot startup uses this as the primary
|
||||
// instance backing /start and command handlers.
|
||||
func GetFirstEnabledTelegramChannel() (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetNotificationChannelByID fetches by PK.
|
||||
func GetNotificationChannelByID(id uint64) (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateNotificationChannel inserts a new row.
|
||||
func CreateNotificationChannel(row *mdb.NotificationChannel) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelFields patches mutable columns.
|
||||
func UpdateNotificationChannelFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.NotificationChannel{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteNotificationChannelByID soft-deletes a row.
|
||||
func DeleteNotificationChannelByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.NotificationChannel{}).Error
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderListFilter bundles every filter supported by the admin orders
|
||||
// list page. Zero values are ignored so callers can pass only what the
|
||||
// user actually filtered on.
|
||||
type OrderListFilter struct {
|
||||
Status int
|
||||
Network string
|
||||
Token string
|
||||
Address string
|
||||
Keyword string // matches trade_id / order_id / block_transaction_id
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
// ParentOnly restricts the result to top-level orders only
|
||||
// (parent_trade_id = ''). Sub-orders are excluded from the listing.
|
||||
ParentOnly bool
|
||||
}
|
||||
|
||||
// ListOrders returns a paginated order slice plus the total count under
|
||||
// the same filter (for the UI pagination bar).
|
||||
func ListOrders(f OrderListFilter) ([]mdb.Orders, int64, error) {
|
||||
tx := buildOrderListQuery(f)
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page := f.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := f.PageSize
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 200 {
|
||||
size = 200
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := tx.Order("id DESC").
|
||||
Offset((page - 1) * size).Limit(size).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func buildOrderListQuery(f OrderListFilter) *gorm.DB {
|
||||
tx := dao.Mdb.Model(&mdb.Orders{})
|
||||
if f.ParentOnly {
|
||||
tx = tx.Where("parent_trade_id = ?", "")
|
||||
}
|
||||
if f.Status > 0 {
|
||||
tx = tx.Where("status = ?", f.Status)
|
||||
}
|
||||
if f.Network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(f.Network))
|
||||
}
|
||||
if f.Token != "" {
|
||||
tx = tx.Where("token = ?", strings.ToUpper(f.Token))
|
||||
}
|
||||
if f.Address != "" {
|
||||
tx = tx.Where("receive_address = ?", f.Address)
|
||||
}
|
||||
if f.Keyword != "" {
|
||||
kw := "%" + strings.TrimSpace(f.Keyword) + "%"
|
||||
tx = tx.Where("trade_id LIKE ? OR order_id LIKE ? OR block_transaction_id LIKE ?", kw, kw, kw)
|
||||
}
|
||||
if f.StartAt != nil {
|
||||
tx = tx.Where("created_at >= ?", *f.StartAt)
|
||||
}
|
||||
if f.EndAt != nil {
|
||||
tx = tx.Where("created_at <= ?", *f.EndAt)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// CountOrdersByStatus returns how many orders exist in each status.
|
||||
// Used by the dashboard overview card.
|
||||
func CountOrdersByStatus() (map[int]int64, error) {
|
||||
type row struct {
|
||||
Status int
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("status, COUNT(*) AS total").
|
||||
Group("status").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Status] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CloseOrderManually transitions a pending order to expired. Only
|
||||
// touches rows currently in StatusWaitPay so idempotent / safe.
|
||||
func CloseOrderManually(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ReopenOrderCallback flips callback_confirm back to NO so the mq
|
||||
// worker picks it up on the next tick. Used by "resend callback".
|
||||
func ReopenOrderCallback(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Updates(map[string]interface{}{
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"callback_num": 0,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// CountOrdersByAddress returns order counts grouped by receive_address.
|
||||
// The admin wallet list annotates each wallet row with this number.
|
||||
func CountOrdersByAddress() (map[string]int64, error) {
|
||||
type row struct {
|
||||
Address string `gorm:"column:receive_address"`
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("receive_address, COUNT(*) AS total").
|
||||
Group("receive_address").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Address] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+74
-291
@@ -1,347 +1,130 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrTransactionLocked = errors.New("transaction amount is already locked")
|
||||
var (
|
||||
CacheWalletAddressToAvailableAmountKey = "WalletAddressToAvailableAmountKey:" // 钱包 - 可用金额 - 过期时间
|
||||
CacheWalletAddressToOrdersKey = "WalletAddressToOrdersKey:" // 钱包 - 待支付金额 - 订单号
|
||||
)
|
||||
|
||||
type PendingCallbackOrder struct {
|
||||
TradeId string `gorm:"column:trade_id"`
|
||||
CallbackNum int `gorm:"column:callback_num"`
|
||||
CallBackConfirm int `gorm:"column:callback_confirm"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func normalizeLockAmount(amount float64) (int64, string) {
|
||||
value := decimal.NewFromFloat(amount).Round(2)
|
||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||
}
|
||||
|
||||
func normalizeLockNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeLockAddress(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeLockNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeLockToken(token string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
if isEVMNetwork(network) {
|
||||
return tx.Where("lower(address) = ?", address)
|
||||
}
|
||||
return tx.Where("address = ?", address)
|
||||
}
|
||||
|
||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||
// GetOrderInfoByOrderId 通过客户订单号查询订单
|
||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// GetOrderInfoByTradeId fetches an order by epusdt trade id.
|
||||
// GetOrderInfoByTradeId 通过交易号查询订单
|
||||
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// CreateOrderWithTransaction creates an order in the active database transaction.
|
||||
// CreateOrderWithTransaction 事务创建订单
|
||||
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
|
||||
return tx.Model(order).Create(order).Error
|
||||
err := tx.Model(order).Create(order).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
|
||||
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
|
||||
// GetOrderByBlockIdWithTransaction 通过区块获取订单
|
||||
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
|
||||
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
|
||||
return order, err
|
||||
}
|
||||
|
||||
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", req.TradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Updates(map[string]interface{}{
|
||||
// OrderSuccessWithTransaction 事务支付成功
|
||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error {
|
||||
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{
|
||||
"block_transaction_id": req.BlockTransactionId,
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPendingCallbackOrders returns the minimal callback scheduling state.
|
||||
func GetPendingCallbackOrders(maxRetry int, limit int) ([]PendingCallbackOrder, error) {
|
||||
var orders []PendingCallbackOrder
|
||||
query := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("trade_id", "callback_num", "callback_confirm", "updated_at").
|
||||
Where("callback_num <= ?", maxRetry).
|
||||
// GetPendingCallbackOrders 查询出等待回调的订单
|
||||
func GetPendingCallbackOrders() ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(orders).
|
||||
Where("callback_num < ?", 5).
|
||||
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Order("updated_at asc")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&orders).Error
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// SaveCallBackOrdersResp persists a callback attempt result.
|
||||
// SaveCallBackOrdersResp 保存订单回调结果
|
||||
func SaveCallBackOrdersResp(order *mdb.Orders) error {
|
||||
return dao.Mdb.Model(order).
|
||||
Where("id = ?", order.ID).
|
||||
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
|
||||
Updates(map[string]interface{}{
|
||||
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{
|
||||
"callback_num": gorm.Expr("callback_num + ?", 1),
|
||||
"callback_confirm": order.CallBackConfirm,
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
|
||||
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
||||
result := dao.Mdb.Model(mdb.Orders{}).
|
||||
Where("id = ?", id).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Where("created_at <= ?", expirationCutoff).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
// UpdateOrderIsExpirationById 通过id设置订单过期
|
||||
func UpdateOrderIsExpirationById(id uint64) error {
|
||||
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent.
|
||||
func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
// LockPayCache 锁定支付池
|
||||
func LockPayCache(token, tradeId, amount string, expirationTime int64) error {
|
||||
// 载入Redis
|
||||
ctx := context.Background()
|
||||
pipe := dao.Rdb.TxPipeline()
|
||||
lockAvailableAmountKey := fmt.Sprintf("%s%s", CacheWalletAddressToAvailableAmountKey, token)
|
||||
// 占用金额
|
||||
pipe.HSet(ctx, lockAvailableAmountKey, amount, expirationTime)
|
||||
// 标记金额对应订单号
|
||||
lockWalletOrdersKey := fmt.Sprintf("%s%s", CacheWalletAddressToOrdersKey, token)
|
||||
pipe.HSet(ctx, lockWalletOrdersKey, amount, tradeId)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
||||
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
err := dao.Mdb.Model(order).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("token = ?", token).
|
||||
Where("network = ?", network).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Limit(1).
|
||||
Find(order).Error
|
||||
return order, err
|
||||
// ClearPayCache 清理支付池
|
||||
func ClearPayCache(token, amount string) error {
|
||||
ctx := context.Background()
|
||||
lockAvailableAmountKey := fmt.Sprintf("%s%s", CacheWalletAddressToAvailableAmountKey, token)
|
||||
lockWalletOrdersKey := fmt.Sprintf("%s%s", CacheWalletAddressToOrdersKey, token)
|
||||
pipe := dao.Rdb.TxPipeline()
|
||||
pipe.HDel(ctx, lockAvailableAmountKey, amount)
|
||||
pipe.HDel(ctx, lockWalletOrdersKey, amount)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id.
|
||||
func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("trade_id != ?", excludeTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
func GetTradeIdByAmount(ctx context.Context, token, amount string) (string, error) {
|
||||
cacheKey := fmt.Sprintf("%s%s", CacheWalletAddressToOrdersKey, token)
|
||||
result, err := dao.Rdb.HGet(ctx, cacheKey, amount).Result()
|
||||
if err == redis.Nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccess marks the parent order as paid and records which sub-order
|
||||
// settled it. Only the status, callback_confirm, and pay_by_sub_id fields are updated;
|
||||
// the parent's own block_transaction_id, actual_amount, and receive_address are
|
||||
// intentionally left unchanged because the parent was not directly paid.
|
||||
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
return MarkParentOrderSuccessWithTransaction(dao.Mdb, parentTradeId, sub)
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccessWithTransaction is the transactional variant of
|
||||
// MarkParentOrderSuccess.
|
||||
func MarkParentOrderSuccessWithTransaction(tx *gorm.DB, parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"pay_by_sub_id": sub.ID,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ExpireSiblingSubOrdersWithTransaction expires all waiting sibling
|
||||
// sub-orders under the same parent in one statement.
|
||||
func ExpireSiblingSubOrdersWithTransaction(tx *gorm.DB, parentTradeId string, excludeTradeId string) error {
|
||||
return tx.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("trade_id != ?", excludeTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired).Error
|
||||
}
|
||||
|
||||
// MarkOrderSelected sets is_selected=true for the given trade_id.
|
||||
func MarkOrderSelected(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("is_selected", true).Error
|
||||
}
|
||||
|
||||
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
|
||||
// Called on the parent order when a sub-order is created or returned.
|
||||
func RefreshOrderExpiration(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("created_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// ResetCallbackConfirmOk sets callback_confirm back to Ok.
|
||||
// Prevents the callback worker from retrying a sub-order with an empty notify_url.
|
||||
func ResetCallbackConfirmOk(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Update("callback_confirm", mdb.CallBackConfirmOk).Error
|
||||
}
|
||||
|
||||
// GetActiveSubOrders returns all active sub-orders under a parent.
|
||||
func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetAllSubOrders returns all sub-orders (any status) under a parent order,
|
||||
// ordered by creation time ascending.
|
||||
func GetAllSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Order("created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetSubOrdersByParentTradeIds batch-fetches all sub-orders (any status)
|
||||
// whose parent_trade_id is in the given set. Ordered by parent_trade_id
|
||||
// then created_at ASC so callers can build a grouped map in one pass.
|
||||
func GetSubOrdersByParentTradeIds(parentTradeIds []string) ([]mdb.Orders, error) {
|
||||
if len(parentTradeIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id IN ?", parentTradeIds).
|
||||
Order("parent_trade_id ASC, created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// ExpireOrderByTradeId marks a single order as expired if still waiting.
|
||||
func ExpireOrderByTradeId(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired).Error
|
||||
}
|
||||
|
||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
|
||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
var lock mdb.TransactionLock
|
||||
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at > ?", time.Now())
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
err := query.Limit(1).Find(&lock).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lock.ID <= 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetExpirationTimeByAmount(ctx context.Context, token, amount string) (string, error) {
|
||||
cacheKey := fmt.Sprintf("%s%s", CacheWalletAddressToAvailableAmountKey, token)
|
||||
result, err := dao.Rdb.HGet(ctx, cacheKey, amount).Result()
|
||||
if err == redis.Nil {
|
||||
return "", nil
|
||||
}
|
||||
return lock.TradeId, nil
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||
normalizedToken := normalizeLockToken(token)
|
||||
now := time.Now()
|
||||
lock := &mdb.TransactionLock{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
AmountText: amountText,
|
||||
TradeId: tradeID,
|
||||
ExpiresAt: now.Add(expirationTime),
|
||||
}
|
||||
|
||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||
expiredQuery := tx.Where("network = ?", network).
|
||||
Where("token = ?", normalizedToken).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at <= ?", now)
|
||||
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrTransactionLocked
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
query := dao.RuntimeDB.
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount)
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
return query.Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func UnLockTransactionByTradeId(tradeID string) error {
|
||||
return dao.RuntimeDB.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func CleanupExpiredTransactionLocks() error {
|
||||
return dao.RuntimeDB.Where("expires_at <= ?", time.Now()).Delete(&mdb.TransactionLock{}).Error
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// DailyStat is one bucket of day-level aggregation used by the
|
||||
// dashboard trend charts.
|
||||
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
|
||||
// "YYYY-MM-DD HH:00" for hourly buckets.
|
||||
type DailyStat struct {
|
||||
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
|
||||
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
|
||||
}
|
||||
|
||||
// AddressDailyStat buckets actual_amount by (day, receive_address) for
|
||||
// the stacked asset-trend chart.
|
||||
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
|
||||
type AddressDailyStat struct {
|
||||
Day string `json:"day"`
|
||||
Address string `json:"address"`
|
||||
ActualAmount float64 `json:"actual_amount"`
|
||||
}
|
||||
|
||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||
// zero-filling any days in the range that have no orders.
|
||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||
// what the user means by "流水". Pending orders count toward OrderCount
|
||||
// to keep成交率 computable.
|
||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`DATE(created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||
// zero-filling any hours that have no orders.
|
||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byDay := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byDay[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
key := cur.Format("2006-01-02")
|
||||
if r, ok := byDay[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillHourlyStats generates a zero-filled slice covering every whole
|
||||
// hour in [start, end]. Hours present in rows overwrite the zero entries.
|
||||
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byHour := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byHour[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endHour) {
|
||||
key := cur.Format("2006-01-02 15:00")
|
||||
if r, ok := byHour[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing days.
|
||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing hours.
|
||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||
// All addresses that appear in rows are represented for every day in range.
|
||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, false)
|
||||
}
|
||||
|
||||
// fillAddressHourlyStats zero-fills missing hour×address combinations.
|
||||
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, true)
|
||||
}
|
||||
|
||||
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
|
||||
type key struct{ day, addr string }
|
||||
byKey := make(map[key]float64, len(rows))
|
||||
addrSet := make(map[string]struct{}, 4)
|
||||
for _, r := range rows {
|
||||
byKey[key{r.Day, r.Address}] = r.ActualAmount
|
||||
addrSet[r.Address] = struct{}{}
|
||||
}
|
||||
if len(addrSet) == 0 {
|
||||
return rows
|
||||
}
|
||||
var periods []string
|
||||
if hourly {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endH) {
|
||||
periods = append(periods, cur.Format("2006-01-02 15:00"))
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
} else {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
periods = append(periods, cur.Format("2006-01-02"))
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
var out []AddressDailyStat
|
||||
for _, day := range periods {
|
||||
for addr := range addrSet {
|
||||
out = append(out, AddressDailyStat{
|
||||
Day: day,
|
||||
Address: addr,
|
||||
ActualAmount: byKey[key{day, addr}],
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SumPaidActualAmount returns the total paid actual_amount across all
|
||||
// time. Used by the overview "total asset" card.
|
||||
func SumPaidActualAmount() (float64, error) {
|
||||
var sum float64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("COALESCE(SUM(actual_amount), 0)").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Scan(&sum).Error
|
||||
return sum, err
|
||||
}
|
||||
|
||||
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
|
||||
// a given time range. Caller chooses the range (today / last 7 days).
|
||||
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
|
||||
type row struct {
|
||||
OrderCount int64
|
||||
SuccessCount int64
|
||||
ActualSum float64
|
||||
}
|
||||
var r row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&r).Error
|
||||
return r.OrderCount, r.SuccessCount, r.ActualSum, err
|
||||
}
|
||||
|
||||
// ActiveAddressCountInRange returns the number of distinct wallet addresses
|
||||
// that appear in at least one order within [start, end].
|
||||
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Distinct("receive_address").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// AveragePaymentDurationSeconds returns the mean elapsed time between
|
||||
// created_at and updated_at for orders that reached StatusPaySuccess
|
||||
// in the given range. Zero-safe when no rows match.
|
||||
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
|
||||
type row struct {
|
||||
Avg float64
|
||||
}
|
||||
var r row
|
||||
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
|
||||
// columns and compute in Go — trend windows are small enough.
|
||||
type pair struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
var pairs []pair
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("created_at, updated_at").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&pairs).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
total := 0.0
|
||||
for _, p := range pairs {
|
||||
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
|
||||
}
|
||||
r.Avg = total / float64(len(pairs))
|
||||
return r.Avg, nil
|
||||
}
|
||||
|
||||
// CountExpiredInRange returns the number of StatusExpired orders in range.
|
||||
func CountExpiredInRange(start, end time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("status = ?", mdb.StatusExpired).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentOrders returns the latest N orders regardless of status.
|
||||
func RecentOrders(limit int) ([]mdb.Orders, error) {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CountEnabledChains counts chains where Enabled=true.
|
||||
func CountEnabledChains() (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-evm-case"
|
||||
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
|
||||
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != tradeID {
|
||||
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
|
||||
}
|
||||
|
||||
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
|
||||
t.Fatalf("unlock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after unlock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-tron-case"
|
||||
address := "TCaseSensitiveAddress001"
|
||||
|
||||
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
tx := dao.Mdb.Model(&mdb.RpcNode{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetRpcNodeByID fetches one row.
|
||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
row := new(mdb.RpcNode)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row.
|
||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateRpcNodeFields patches mutable columns.
|
||||
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteRpcNodeByID soft-deletes the row.
|
||||
func DeleteRpcNodeByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).Error
|
||||
}
|
||||
|
||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||
// Falls back to enabled rows with status=unknown when no health check has
|
||||
// run yet. Explicitly down rows are not selected.
|
||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("type = ?", strings.ToLower(nodeType)).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
healthy := make([]mdb.RpcNode, 0, len(rows))
|
||||
bootstrap := make([]mdb.RpcNode, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch r.Status {
|
||||
case mdb.RpcNodeStatusOk:
|
||||
healthy = append(healthy, r)
|
||||
case "", mdb.RpcNodeStatusUnknown:
|
||||
bootstrap = append(bootstrap, r)
|
||||
}
|
||||
}
|
||||
candidates := healthy
|
||||
if len(candidates) == 0 {
|
||||
candidates = bootstrap
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return pickWeighted(candidates), nil
|
||||
}
|
||||
|
||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||
// to truly disable).
|
||||
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
|
||||
total := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
total += w
|
||||
}
|
||||
if total <= 0 {
|
||||
return &rows[0]
|
||||
}
|
||||
pick := rand.Intn(total)
|
||||
acc := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if pick < acc {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return &rows[len(rows)-1]
|
||||
}
|
||||
|
||||
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
|
||||
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latencyMs,
|
||||
"last_checked_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Simple in-process cache. Settings are read frequently (on every order
|
||||
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
|
||||
// is invalidated explicitly via SetString/Delete and expires on process
|
||||
// restart — that's enough for admin-edit semantics without TTL complexity.
|
||||
var (
|
||||
settingsCache = map[string]string{}
|
||||
settingsCacheMu sync.RWMutex
|
||||
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
|
||||
settingsLoaded bool
|
||||
)
|
||||
|
||||
func loadAllSettings() error {
|
||||
var rows []mdb.Setting
|
||||
if err := dao.Mdb.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
defer settingsCacheMu.Unlock()
|
||||
settingsCache = make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
settingsCache[r.Key] = r.Value
|
||||
}
|
||||
settingsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
|
||||
// a dedicated mutex + double-checked flag so two concurrent callers
|
||||
// don't both hit the DB. Can't use sync.Once because ReloadSettings
|
||||
// needs to be able to rebuild after a failed initial load.
|
||||
func ensureLoaded() {
|
||||
settingsCacheMu.RLock()
|
||||
loaded := settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
// Recheck under the load mutex — another goroutine may have loaded
|
||||
// while we were blocked.
|
||||
settingsCacheMu.RLock()
|
||||
loaded = settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
_ = loadAllSettings()
|
||||
}
|
||||
|
||||
// ReloadSettings forces a refresh from DB. Call after external writes.
|
||||
func ReloadSettings() error {
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
return loadAllSettings()
|
||||
}
|
||||
|
||||
// GetSettingString returns the string value for key, or fallback if unset.
|
||||
func GetSettingString(key, fallback string) string {
|
||||
ensureLoaded()
|
||||
settingsCacheMu.RLock()
|
||||
defer settingsCacheMu.RUnlock()
|
||||
v, ok := settingsCache[key]
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
|
||||
func GetSettingInt(key string, fallback int) int {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
|
||||
func GetSettingFloat(key string, fallback float64) float64 {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
|
||||
func GetSettingBool(key string, fallback bool) bool {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||
func SetSetting(group, key, value, valueType string) error {
|
||||
if valueType == "" {
|
||||
valueType = mdb.SettingTypeString
|
||||
}
|
||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[key] = value
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes a setting row and drops the cache entry.
|
||||
func DeleteSetting(key string) error {
|
||||
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, key)
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
||||
var sensitiveSettingKeys = []string{
|
||||
mdb.SettingKeyJwtSecret,
|
||||
mdb.SettingKeyInitAdminPasswordPlain,
|
||||
mdb.SettingKeyInitAdminPasswordHash,
|
||||
mdb.SettingKeyInitAdminPasswordFetched,
|
||||
mdb.SettingKeyInitAdminPasswordChanged,
|
||||
}
|
||||
|
||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||
// excluding any keys in sensitiveSettingKeys.
|
||||
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
||||
var rows []mdb.Setting
|
||||
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
|
||||
if group != "" {
|
||||
tx = tx.Where("`group` = ?", group)
|
||||
}
|
||||
err := tx.Order("`key` ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
@@ -1,93 +1,32 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
)
|
||||
|
||||
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
|
||||
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
||||
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
|
||||
}
|
||||
|
||||
// isEVMNetwork 判断是否是 EVM 网络
|
||||
func isEVMNetwork(network string) bool {
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeWalletNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeWalletAddressByNetwork(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeWalletNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
|
||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||
// AddWalletAddress 创建钱包
|
||||
func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
|
||||
exist, err := GetWalletAddressByToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.WalletAddressAlreadyExists
|
||||
}
|
||||
|
||||
// Check for a soft-deleted record with the same (network, address) and restore it.
|
||||
deleted := new(mdb.WalletAddress)
|
||||
err = dao.Mdb.Unscoped().
|
||||
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
|
||||
Limit(1).Find(deleted).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.ID > 0 {
|
||||
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"status": mdb.TokenStatusEnable,
|
||||
}).Error
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
walletAddress := &mdb.WalletAddress{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: token,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}
|
||||
err = dao.Mdb.Create(walletAddress).Error
|
||||
return walletAddress, err
|
||||
}
|
||||
|
||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
// GetWalletAddressByToken 通过钱包地址获取token
|
||||
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) {
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Limit(1).Find(walletAddress).Error
|
||||
return walletAddress, err
|
||||
}
|
||||
|
||||
// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口)
|
||||
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
||||
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error
|
||||
return walletAddress, err
|
||||
}
|
||||
|
||||
@@ -111,25 +50,6 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
return WalletAddressList, err
|
||||
}
|
||||
|
||||
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
|
||||
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).
|
||||
Where("status = ?", mdb.TokenStatusEnable).
|
||||
Where("network = ?", network).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetAllWalletAddress 获得所有钱包地址
|
||||
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
var WalletAddressList []mdb.WalletAddress
|
||||
@@ -137,22 +57,6 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
return WalletAddressList, err
|
||||
}
|
||||
|
||||
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
|
||||
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ChangeWalletAddressStatus 启用禁用钱包
|
||||
func ChangeWalletAddressStatus(id uint64, status int) error {
|
||||
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
|
||||
if err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
if row.Address != strings.ToLower(input) {
|
||||
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
|
||||
}
|
||||
|
||||
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
|
||||
if err != nil {
|
||||
t.Fatalf("load wallet by mixed-case address: %v", err)
|
||||
}
|
||||
if loaded.ID == 0 {
|
||||
t.Fatal("expected to find wallet by mixed-case query")
|
||||
}
|
||||
if loaded.Address != strings.ToLower(input) {
|
||||
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixed,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
|
||||
if err != nil {
|
||||
t.Fatalf("list wallets: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("wallet count = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Address != strings.ToLower(mixed) {
|
||||
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tronAddress := "TCaseSensitiveTronAddress001"
|
||||
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if tronRow.Address != tronAddress {
|
||||
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
|
||||
}
|
||||
|
||||
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
|
||||
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add solana wallet: %v", err)
|
||||
}
|
||||
if solRow.Address != solAddress {
|
||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
AdminUserStatusEnable = 1
|
||||
AdminUserStatusDisable = 2
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
Username string `gorm:"column:username;uniqueIndex:admin_users_username_uindex;size:64" json:"username" example:"admin"`
|
||||
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
LastLoginAt carbon.Time `gorm:"column:last_login_at" json:"last_login_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *AdminUser) TableName() string {
|
||||
return "admin_users"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
ApiKeyStatusEnable = 1
|
||||
ApiKeyStatusDisable = 2
|
||||
)
|
||||
|
||||
// ApiKey stores a universal merchant credential (PID + secret) that is
|
||||
// valid for both EPAY and GMPAY payment flows. Identification at
|
||||
// request time is always by PID; the gateway flow is chosen by the
|
||||
// route (not by any property of the row).
|
||||
type ApiKey struct {
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"My API Key"`
|
||||
Pid string `gorm:"column:pid;size:128;uniqueIndex:api_keys_pid_uindex" json:"pid" example:"1000"`
|
||||
SecretKey string `gorm:"column:secret_key;size:255" json:"-"`
|
||||
IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"`
|
||||
NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"`
|
||||
LastUsedAt carbon.Time `gorm:"column:last_used_at" json:"last_used_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *ApiKey) TableName() string {
|
||||
return "api_keys"
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package mdb
|
||||
|
||||
import (
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/golang-module/carbon/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BaseModel struct {
|
||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
|
||||
ID uint64 `gorm:"column:id;primary_key" json:"id"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package mdb
|
||||
|
||||
// Chain represents an enabled blockchain network.
|
||||
// Scanner tasks read this table on each polling tick to decide whether to
|
||||
// process the network. When Enabled=false the scanner skips the chain
|
||||
// entirely without restarting.
|
||||
type Chain struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:chains_network_uindex;size:32" json:"network" example:"tron"`
|
||||
DisplayName string `gorm:"column:display_name;size:64" json:"display_name" example:"Tron"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinConfirmations int `gorm:"column:min_confirmations;default:1" json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec int `gorm:"column:scan_interval_sec;default:5" json:"scan_interval_sec" example:"5"`
|
||||
Extra string `gorm:"column:extra;type:text" json:"extra" example:"{}"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *Chain) TableName() string {
|
||||
return "chains"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
ChainTokenStatusEnable = 1
|
||||
ChainTokenStatusDisable = 2
|
||||
)
|
||||
|
||||
// ChainToken describes a token to watch on a given chain. Replaces the
|
||||
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
|
||||
// load this table on startup (and refresh periodically) to learn which
|
||||
// contracts to subscribe to.
|
||||
type ChainToken struct {
|
||||
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
|
||||
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
|
||||
ContractAddress string `gorm:"column:contract_address;size:128" json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `gorm:"column:decimals;default:6" json:"decimals" example:"6"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinAmount float64 `gorm:"column:min_amount;default:0" json:"min_amount" example:"1.0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *ChainToken) TableName() string {
|
||||
return "chain_tokens"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
NotificationTypeTelegram = "telegram"
|
||||
NotificationTypeWebhook = "webhook"
|
||||
NotificationTypeEmail = "email"
|
||||
)
|
||||
|
||||
const (
|
||||
NotificationStatusEnable = 1
|
||||
NotificationStatusDisable = 2
|
||||
)
|
||||
|
||||
// Notification events (JSON keys in Events column).
|
||||
const (
|
||||
NotifyEventPaySuccess = "pay_success"
|
||||
NotifyEventOrderExpired = "order_expired"
|
||||
NotifyEventDailyReport = "daily_report"
|
||||
)
|
||||
|
||||
// NotificationChannel represents one push target instance.
|
||||
// Config/Events are JSON strings consumed by the notify dispatcher.
|
||||
// For telegram, Config = {"bot_token":"...","chat_id":"...","proxy":"..."}.
|
||||
// For webhook, Config = {"url":"...","headers":{...},"secret":"..."}.
|
||||
type NotificationChannel struct {
|
||||
Type string `gorm:"column:type;size:32;index:notification_channels_type_enabled_index,priority:1" json:"type" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"主通知群"`
|
||||
Config string `gorm:"column:config;type:text" json:"config" example:"{\"bot_token\":\"123:ABC\",\"chat_id\":\"456\"}"`
|
||||
Events string `gorm:"column:events;type:text" json:"events" example:"{\"pay_success\":true,\"order_expired\":true}"`
|
||||
Enabled bool `gorm:"column:enabled;default:true;index:notification_channels_type_enabled_index,priority:2" json:"enabled" example:"true"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (n *NotificationChannel) TableName() string {
|
||||
return "notification_channels"
|
||||
}
|
||||
+12
-28
@@ -8,38 +8,22 @@ const (
|
||||
CallBackConfirmNo = 2
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentTypeEpay = "Epay"
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||
Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
|
||||
Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
|
||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
|
||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
||||
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
||||
// Zero when the parent order was paid directly (no sub-order involved).
|
||||
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
||||
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号
|
||||
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id
|
||||
BlockTransactionId string `gorm:"column:block_transaction_id" json:"block_transaction_id"` // 区块id
|
||||
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
|
||||
Status int `gorm:"column:status" json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
|
||||
CallbackNum int `gorm:"column:callback_num" json:"callback_num"` // 回调次数
|
||||
CallBackConfirm int `gorm:"column:callback_confirm" json:"callback_confirm"` // 回调是否已确认 1是 2否
|
||||
BaseModel
|
||||
}
|
||||
|
||||
// TableName sets the insert table name for this struct type
|
||||
func (o *Orders) TableName() string {
|
||||
return "orders"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
RpcNodeTypeHttp = "http"
|
||||
RpcNodeTypeWs = "ws"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcNodeStatusUnknown = "unknown"
|
||||
RpcNodeStatusOk = "ok"
|
||||
RpcNodeStatusDown = "down"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Clients select a node
|
||||
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
||||
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
||||
type RpcNode struct {
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (r *RpcNode) TableName() string {
|
||||
return "rpc_nodes"
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package mdb
|
||||
|
||||
// Setting stores non-credential runtime configuration as key/value pairs.
|
||||
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
|
||||
// live in the api_keys table; notification configs live in NotificationChannel.
|
||||
// Default rows for the epay group are seeded on first startup (see
|
||||
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
||||
// empty and fall back to hardcoded defaults until an admin sets them.
|
||||
// Only exception: system.jwt_secret is auto-generated on first startup.
|
||||
const (
|
||||
SettingGroupBrand = "brand"
|
||||
SettingGroupRate = "rate"
|
||||
SettingGroupSystem = "system"
|
||||
SettingGroupEpay = "epay"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingTypeString = "string"
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeJSON = "json"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingKeyJwtSecret = "system.jwt_secret"
|
||||
SettingKeyInitAdminPasswordPlain = "system.init_admin_password_plain"
|
||||
SettingKeyInitAdminPasswordHash = "system.init_admin_password_hash"
|
||||
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||
SettingKeyBrandSiteName = "brand.site_name"
|
||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||
SettingKeyBrandPageTitle = "brand.page_title"
|
||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||
SettingKeyRateApiUrl = "rate.api_url"
|
||||
|
||||
// EPAY route defaults — can be overridden via admin settings.
|
||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
||||
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
||||
)
|
||||
|
||||
type Setting struct {
|
||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package mdb
|
||||
|
||||
import "time"
|
||||
|
||||
type TransactionLock struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *TransactionLock) TableName() string {
|
||||
return "transaction_lock"
|
||||
}
|
||||
@@ -5,30 +5,14 @@ const (
|
||||
TokenStatusDisable = 2
|
||||
)
|
||||
|
||||
const (
|
||||
NetworkTron = "tron"
|
||||
NetworkSolana = "solana"
|
||||
NetworkEthereum = "ethereum"
|
||||
NetworkBsc = "bsc"
|
||||
NetworkPolygon = "polygon"
|
||||
NetworkPlasma = "plasma"
|
||||
)
|
||||
|
||||
const (
|
||||
WalletSourceManual = "manual"
|
||||
WalletSourceImport = "import"
|
||||
)
|
||||
|
||||
// WalletAddress 钱包表
|
||||
type WalletAddress struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
|
||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
|
||||
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
|
||||
Token string `gorm:"column:token" json:"token"` // 钱包token
|
||||
Status int64 `gorm:"column:status" json:"status"` // 1:启用 2:禁用
|
||||
BaseModel
|
||||
}
|
||||
|
||||
// TableName sets the insert table name for this struct type
|
||||
func (w *WalletAddress) TableName() string {
|
||||
return "wallet_address"
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ const (
|
||||
var OrderByFuncList = []string{OrderByFuncDesc, OrderByFuncAsc}
|
||||
|
||||
type BaseRequest struct {
|
||||
Page int `json:"page" example:"1"` // 页数
|
||||
PageSize int `json:"page_size" example:"20"` // 每页条数
|
||||
OrderField string `json:"order_field" example:"created_at"` // 排序字段
|
||||
OrderFunc string `json:"order_func" enums:"DESC,ASC" example:"DESC"` // 排序方法
|
||||
Page int `json:"page"` // 页数
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
OrderField string `json:"order_field"` // 排序字段
|
||||
OrderFunc string `json:"order_func"` // 排序方法
|
||||
}
|
||||
|
||||
@@ -4,24 +4,16 @@ import "github.com/gookit/validate"
|
||||
|
||||
// CreateTransactionRequest 创建交易请求
|
||||
type CreateTransactionRequest struct {
|
||||
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
|
||||
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
||||
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||
OrderId string `json:"order_id" validate:"required|maxLen:32"`
|
||||
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
|
||||
NotifyUrl string `json:"notify_url" validate:"required|isFullURL"`
|
||||
Signature string `json:"signature" validate:"required"`
|
||||
RedirectUrl string `json:"redirect_url"`
|
||||
}
|
||||
|
||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"OrderId": "订单号",
|
||||
"Currency": "货币",
|
||||
"Token": "币种",
|
||||
"Network": "网络",
|
||||
"Amount": "支付金额",
|
||||
"NotifyUrl": "异步回调网址",
|
||||
"Signature": "签名",
|
||||
@@ -30,26 +22,8 @@ func (r CreateTransactionRequest) Translates() map[string]string {
|
||||
|
||||
// OrderProcessingRequest 订单处理
|
||||
type OrderProcessingRequest struct {
|
||||
ReceiveAddress string
|
||||
Currency string
|
||||
Token string
|
||||
Network string
|
||||
Amount float64
|
||||
Amount string
|
||||
TradeId string
|
||||
BlockTransactionId string
|
||||
}
|
||||
|
||||
// SwitchNetworkRequest 切换支付网络
|
||||
type SwitchNetworkRequest struct {
|
||||
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
|
||||
Token string `json:"token" validate:"required" example:"USDT"`
|
||||
Network string `json:"network" validate:"required" example:"ethereum"`
|
||||
}
|
||||
|
||||
func (r SwitchNetworkRequest) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"TradeId": "订单号",
|
||||
"Token": "币种",
|
||||
"Network": "网络",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package response
|
||||
|
||||
// ApiResponse is the standard JSON envelope used by all API endpoints.
|
||||
// Used for swagger documentation only — the actual response is built
|
||||
// by util/http.Resp.
|
||||
type ApiResponse struct {
|
||||
StatusCode int `json:"status_code" example:"200"`
|
||||
Message string `json:"message" example:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
RequestID string `json:"request_id" example:"req-123456"`
|
||||
}
|
||||
@@ -2,41 +2,23 @@ package response
|
||||
|
||||
// CreateTransactionResponse 创建订单成功返回
|
||||
type CreateTransactionResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
OrderId string `json:"order_id"` // 客户交易id
|
||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
Token string `json:"token"` // 收款钱包地址
|
||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url"` // 收银台地址
|
||||
}
|
||||
|
||||
// OrderNotifyResponse 订单异步回调结构体
|
||||
type OrderNotifyResponse struct {
|
||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"2"`
|
||||
}
|
||||
|
||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||
type OrderNotifyResponseEpay struct {
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
OrderId string `json:"order_id"` // 客户交易id
|
||||
Amount float64 `json:"amount"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
Token string `json:"token"` // 收款钱包地址
|
||||
BlockTransactionId string `json:"block_transaction_id"` // 区块id
|
||||
Signature string `json:"signature"` // 签名
|
||||
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
package response
|
||||
|
||||
type CheckoutCounterResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||
IsSelected bool `json:"is_selected" example:"false"`
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
|
||||
Token string `json:"token"` // 收款钱包地址
|
||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
||||
RedirectUrl string `json:"redirect_url"`
|
||||
}
|
||||
|
||||
type CheckStatusResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"1"`
|
||||
TradeId string `json:"trade_id"` // epusdt订单号
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package response
|
||||
|
||||
type NetworkTokenSupport struct {
|
||||
Network string `json:"network" example:"tron"`
|
||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||
}
|
||||
|
||||
type SupportedAssetsResponse struct {
|
||||
Supports []NetworkTokenSupport `json:"supports"`
|
||||
}
|
||||
+126
-360
@@ -1,81 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/mq"
|
||||
"github.com/assimon/luuu/mq/handle"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/golang-module/carbon/v2"
|
||||
"github.com/gookit/goutil/mathutil"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/shopspring/decimal"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const (
|
||||
CnyMinimumPaymentAmount = 0.01
|
||||
UsdtMinimumPaymentAmount = 0.01
|
||||
UsdtAmountPerIncrement = 0.01
|
||||
IncrementalMaximumNumber = 100
|
||||
)
|
||||
var gCreateTransactionLock sync.Mutex
|
||||
|
||||
var (
|
||||
gCreateTransactionLock sync.Mutex
|
||||
gOrderProcessingLock sync.Mutex
|
||||
)
|
||||
|
||||
// apiKeyID safely extracts the primary key from an ApiKey row.
|
||||
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
|
||||
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
|
||||
if apiKey == nil {
|
||||
return 0
|
||||
}
|
||||
return apiKey.ID
|
||||
}
|
||||
|
||||
func normalizeOrderAddressByNetwork(network, address string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
address = strings.TrimSpace(address)
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return strings.ToLower(address)
|
||||
default:
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTransaction creates a new payment order.
|
||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||
// CreateTransaction 创建订单
|
||||
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||
if rate <= 0 {
|
||||
return nil, constant.RateAmountErr
|
||||
// 汇率计算金额
|
||||
rmb := decimal.NewFromFloat(req.Amount)
|
||||
rate := decimal.NewFromFloat(config.GetUsdtRate())
|
||||
amount := rmb.Div(rate).InexactFloat64()
|
||||
actualAmountStr := fmt.Sprintf("%.4f", amount)
|
||||
actualAmountFloat, err := strconv.ParseFloat(actualAmountStr, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
||||
// 是否可以满足最低支付金额
|
||||
if actualAmountFloat <= 0 {
|
||||
return nil, constant.PayAmountErr
|
||||
}
|
||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||
return nil, constant.PayAmountErr
|
||||
}
|
||||
|
||||
// 已经存在了的交易
|
||||
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -83,233 +50,174 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.OrderAlreadyExists
|
||||
}
|
||||
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
// 有无可用钱包
|
||||
walletAddress, err := data.GetAvailableWalletAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(walletAddress) <= 0 {
|
||||
return nil, constant.NotAvailableWalletAddress
|
||||
}
|
||||
|
||||
tradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
||||
availableToken, availableAmountStr, err := CalculateAvailableWalletTokenAndAmount(actualAmountStr, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if availableAddress == "" {
|
||||
if availableToken == "" || availableAmountStr == "" {
|
||||
return nil, constant.NotAvailableAmountErr
|
||||
}
|
||||
|
||||
availableAmountFloat, err := strconv.ParseFloat(availableAmountStr, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx := dao.Mdb.Begin()
|
||||
order := &mdb.Orders{
|
||||
TradeId: tradeID,
|
||||
TradeId: GenerateCode(),
|
||||
OrderId: req.OrderId,
|
||||
Amount: req.Amount,
|
||||
Currency: currency,
|
||||
ActualAmount: availableAmount,
|
||||
ReceiveAddress: availableAddress,
|
||||
Token: token,
|
||||
Network: network,
|
||||
ActualAmount: availableAmountFloat,
|
||||
Token: availableToken,
|
||||
Status: mdb.StatusWaitPay,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
RedirectUrl: req.RedirectUrl,
|
||||
Name: req.Name,
|
||||
PaymentType: req.PaymentType,
|
||||
ApiKeyID: apiKeyID(apiKey),
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||
err = data.CreateOrderWithTransaction(tx, order)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(tradeID)
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
|
||||
// 锁定支付池
|
||||
err = data.LockPayCache(availableToken, order.TradeId, availableAmountStr, ExpirationTime)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(tradeID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
|
||||
tx.Commit()
|
||||
// 超时过期消息队列
|
||||
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
|
||||
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(time.Minute*time.Duration(config.GetOrderExpirationTime())))
|
||||
resp := &response.CreateTransactionResponse{
|
||||
TradeId: order.TradeId,
|
||||
OrderId: order.OrderId,
|
||||
Amount: order.Amount,
|
||||
Currency: order.Currency,
|
||||
ActualAmount: order.ActualAmount,
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Token: order.Token,
|
||||
ExpirationTime: expirationTime,
|
||||
ExpirationTime: ExpirationTime,
|
||||
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
||||
// OrderProcessing 成功处理订单
|
||||
func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
gOrderProcessingLock.Lock()
|
||||
defer gOrderProcessingLock.Unlock()
|
||||
|
||||
tx := dao.Mdb.Begin()
|
||||
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
tx.Rollback()
|
||||
return constant.OrderBlockAlreadyProcess
|
||||
}
|
||||
|
||||
updated, err := data.OrderSuccessWithTransaction(tx, req)
|
||||
// 标记订单成功
|
||||
err = data.OrderSuccessWithTransaction(tx, req)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
tx.Rollback()
|
||||
return constant.OrderStatusConflict
|
||||
}
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
||||
}
|
||||
|
||||
// Load order to check parent-child relationship
|
||||
err = data.ClearPayCache(req.Token, req.Amount)
|
||||
tx.Commit()
|
||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||
}
|
||||
|
||||
// Parent order paid directly: expire all sub-orders and release their locks
|
||||
if order.ParentTradeId == "" {
|
||||
subs, subErr := data.GetActiveSubOrders(order.TradeId)
|
||||
if subErr != nil {
|
||||
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
|
||||
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||
}
|
||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 回调队列
|
||||
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
|
||||
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5))
|
||||
// 发送机器人消息
|
||||
msgTpl := `
|
||||
<b>📢📢有新的交易支付成功!</b>
|
||||
<pre>交易号:%s</pre>
|
||||
<pre>订单号:%s</pre>
|
||||
<pre>请求支付金额:%.4f cny</pre>
|
||||
<pre>实际支付金额:%.4f usdt</pre>
|
||||
<pre>钱包地址:%s</pre>
|
||||
<pre>订单创建时间:%s</pre>
|
||||
<pre>支付成功时间:%s</pre>
|
||||
`
|
||||
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
|
||||
telegram.SendToBot(msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
|
||||
func CalculateAvailableWalletTokenAndAmount(amount string, walletAddress []mdb.WalletAddress) (string, string, error) {
|
||||
calculateAmountStr := amount
|
||||
availableAmountStr := ""
|
||||
availableToken := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
token, err := CalculateAvailableWalletToken(calculateAmountStr, walletAddress)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
|
||||
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Snapshot siblings for lock release after DB state transition commits.
|
||||
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
|
||||
// 这个金额没有拿到可用的钱包,重试,金额+0.0001
|
||||
if token == "" {
|
||||
x, err := decimal.NewFromString(calculateAmountStr)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
finalizeTx := dao.Mdb.Begin()
|
||||
|
||||
// Mark parent as paid with sub-order's payment details
|
||||
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
|
||||
if markErr != nil {
|
||||
finalizeTx.Rollback()
|
||||
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
|
||||
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
|
||||
y, err := decimal.NewFromString("0.0001")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !updatedParent {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
|
||||
}
|
||||
|
||||
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
if err = finalizeTx.Commit().Error; err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Sub-order should not trigger its own callback (notify_url is empty).
|
||||
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
|
||||
// reset it only after the parent order is successfully finalized.
|
||||
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||
}
|
||||
|
||||
// Release parent's own wallet lock
|
||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Release sibling locks after their status transitions commit.
|
||||
for _, sib := range siblings {
|
||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
|
||||
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||
availableAddress := ""
|
||||
availableAmount := amount
|
||||
|
||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||
for _, address := range walletAddress {
|
||||
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
|
||||
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
||||
if err == nil {
|
||||
return normalizedAddress, nil
|
||||
}
|
||||
if errors.Is(err, data.ErrTransactionLocked) {
|
||||
calculateAmountStr = x.Add(y).String()
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
for i := 0; i < IncrementalMaximumNumber; i++ {
|
||||
address, err := tryLockWalletFunc(availableAmount)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if address == "" {
|
||||
decimalOldAmount := decimal.NewFromFloat(availableAmount)
|
||||
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
|
||||
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
|
||||
continue
|
||||
}
|
||||
availableAddress = address
|
||||
availableAmountStr = calculateAmountStr
|
||||
availableToken = token
|
||||
break
|
||||
}
|
||||
return availableAddress, availableAmount, nil
|
||||
return availableToken, availableAmountStr, nil
|
||||
}
|
||||
|
||||
// GenerateCode creates a unique trade id.
|
||||
// CalculateAvailableWalletToken 计算可用钱包token
|
||||
func CalculateAvailableWalletToken(payAmount string, walletAddress []mdb.WalletAddress) (string, error) {
|
||||
nowTime := time.Now().Unix()
|
||||
ctx := context.Background()
|
||||
walletToken := ""
|
||||
for _, address := range walletAddress {
|
||||
result, err := data.GetExpirationTimeByAmount(ctx, address.Token, payAmount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 这个钱包金额被占用了
|
||||
if result != "" {
|
||||
endTime := mathutil.MustInt64(result)
|
||||
// 但是过期了
|
||||
if endTime < nowTime {
|
||||
// 删掉过期,还能继续用这个地址
|
||||
err = data.ClearPayCache(address.Token, payAmount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
walletToken = address.Token
|
||||
break
|
||||
}
|
||||
return walletToken, nil
|
||||
}
|
||||
|
||||
// GenerateCode 订单号生成
|
||||
func GenerateCode() string {
|
||||
date := time.Now().Format("20060102")
|
||||
r := rand.Intn(1000)
|
||||
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
|
||||
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
|
||||
return code
|
||||
}
|
||||
|
||||
// GetOrderInfoByTradeId returns a validated order.
|
||||
// GetOrderInfoByTradeId 通过交易号获取订单
|
||||
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
||||
order, err := data.GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
@@ -320,145 +228,3 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
const MaxSubOrders = 2
|
||||
|
||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
||||
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
|
||||
// 1. Load parent order
|
||||
parent, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.ID <= 0 {
|
||||
return nil, constant.OrderNotExists
|
||||
}
|
||||
if parent.ParentTradeId != "" {
|
||||
return nil, constant.CannotSwitchSubOrder
|
||||
}
|
||||
if parent.Status != mdb.StatusWaitPay {
|
||||
return nil, constant.OrderNotWaitPay
|
||||
}
|
||||
|
||||
// 2. Same token+network as parent → mark selected and return
|
||||
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
parent.IsSelected = true
|
||||
return buildCheckoutResponse(parent), nil
|
||||
}
|
||||
|
||||
// 3. Existing active sub-order for this token+network → return it
|
||||
existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
_ = data.MarkOrderSelected(existing.TradeId)
|
||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||
existing.IsSelected = true
|
||||
return buildCheckoutResponse(existing), nil
|
||||
}
|
||||
|
||||
// 4. Check sub-order limit
|
||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= MaxSubOrders {
|
||||
return nil, constant.SubOrderLimitExceeded
|
||||
}
|
||||
|
||||
// 5. Calculate amount for the new network
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||
if rate <= 0 {
|
||||
return nil, constant.RateAmountErr
|
||||
}
|
||||
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||
return nil, constant.PayAmountErr
|
||||
}
|
||||
|
||||
// 6. Find and lock wallet
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(walletAddress) <= 0 {
|
||||
return nil, constant.NotAvailableWalletAddress
|
||||
}
|
||||
|
||||
subTradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if availableAddress == "" {
|
||||
return nil, constant.NotAvailableAmountErr
|
||||
}
|
||||
|
||||
// 7. Create sub-order
|
||||
tx := dao.Mdb.Begin()
|
||||
subOrder := &mdb.Orders{
|
||||
TradeId: subTradeID,
|
||||
OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint)
|
||||
ParentTradeId: parent.TradeId,
|
||||
Amount: parent.Amount,
|
||||
Currency: parent.Currency,
|
||||
ActualAmount: availableAmount,
|
||||
ReceiveAddress: availableAddress,
|
||||
Token: token,
|
||||
Network: network,
|
||||
Status: mdb.StatusWaitPay,
|
||||
IsSelected: true,
|
||||
NotifyUrl: "",
|
||||
RedirectUrl: parent.RedirectUrl,
|
||||
Name: parent.Name,
|
||||
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
|
||||
PaymentType: parent.PaymentType,
|
||||
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(subTradeID)
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
_ = data.UnLockTransactionByTradeId(subTradeID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark parent as selected and refresh its expiration to match the sub-order
|
||||
_ = data.MarkOrderSelected(parent.TradeId)
|
||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||
|
||||
return buildCheckoutResponse(subOrder), nil
|
||||
}
|
||||
|
||||
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
||||
return &response.CheckoutCounterResponse{
|
||||
TradeId: order.TradeId,
|
||||
Amount: order.Amount,
|
||||
ActualAmount: order.ActualAmount,
|
||||
Token: order.Token,
|
||||
Currency: order.Currency,
|
||||
ReceiveAddress: order.ReceiveAddress,
|
||||
Network: order.Network,
|
||||
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||
RedirectUrl: order.RedirectUrl,
|
||||
CreatedAt: order.CreatedAt.TimestampMilli(),
|
||||
IsSelected: order.IsSelected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,709 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||
return &request.CreateTransactionRequest{
|
||||
OrderId: orderID,
|
||||
Currency: "CNY",
|
||||
Token: "USDT",
|
||||
Network: "tron",
|
||||
Amount: amount,
|
||||
NotifyUrl: "https://merchant.example/callback",
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
t.Helper()
|
||||
|
||||
oldFactory := http_client.ClientFactory
|
||||
http_client.ClientFactory = func() *resty.Client {
|
||||
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||
client.SetTimeout(10 * time.Second)
|
||||
return client
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http_client.ClientFactory = oldFactory
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
|
||||
if got := fmt.Sprintf("%.2f", resp1.ActualAmount); got != "1.00" {
|
||||
t.Fatalf("first actual amount = %s, want 1.00", got)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" {
|
||||
t.Fatalf("second actual amount = %s, want 1.01", got)
|
||||
}
|
||||
if resp1.ReceiveAddress != "wallet_1" || resp2.ReceiveAddress != "wallet_1" {
|
||||
t.Fatalf("unexpected receive addresses: %s, %s", resp1.ReceiveAddress, resp2.ReceiveAddress)
|
||||
}
|
||||
if resp1.Token != "USDT" || resp2.Token != "USDT" {
|
||||
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
|
||||
}
|
||||
|
||||
tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get first runtime lock: %v", err)
|
||||
}
|
||||
if tradeID1 != resp1.TradeId {
|
||||
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
|
||||
}
|
||||
|
||||
tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get second runtime lock: %v", err)
|
||||
}
|
||||
if tradeID2 != resp2.TradeId {
|
||||
t.Fatalf("second runtime lock = %s, want %s", tradeID2, resp2.TradeId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
||||
t.Fatalf("set rate.api_url: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
|
||||
t.Fatalf("actual amount = %s, want 1.46", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||
t.Fatalf("clear rate.api_url: %v", err)
|
||||
}
|
||||
|
||||
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
|
||||
if err != constant.RateAmountErr {
|
||||
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixedAddress,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
req := newCreateTransactionRequest("order_evm_1", 1)
|
||||
req.Network = mdb.NetworkEthereum
|
||||
|
||||
resp, err := CreateTransaction(req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
expectedAddress := strings.ToLower(mixedAddress)
|
||||
if resp.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if order.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup runtime lock: %v", err)
|
||||
}
|
||||
if tradeID != resp.TradeId {
|
||||
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing: %v", err)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("get order by trade id: %v", err)
|
||||
}
|
||||
if order.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if order.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("callback confirm = %d, want %d", order.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
if order.BlockTransactionId != "block_1" {
|
||||
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("get runtime lock after processing: %v", err)
|
||||
}
|
||||
if tradeID != "" {
|
||||
t.Fatalf("runtime lock still exists: %s", tradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_1",
|
||||
}
|
||||
if err = OrderProcessing(req); err != nil {
|
||||
t.Fatalf("first order processing: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(req)
|
||||
if err != constant.OrderBlockAlreadyProcess {
|
||||
t.Fatalf("second order processing error = %v, want %v", err, constant.OrderBlockAlreadyProcess)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order after duplicate block: %v", err)
|
||||
}
|
||||
if order.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("order status after duplicate block = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if order.BlockTransactionId != "block_1" {
|
||||
t.Fatalf("order block transaction id after duplicate block = %s, want block_1", order.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
if err = dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", resp.TradeId).
|
||||
Update("status", mdb.StatusExpired).Error; err != nil {
|
||||
t.Fatalf("force order expired: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: resp.ReceiveAddress,
|
||||
Token: resp.Token,
|
||||
Network: "tron",
|
||||
TradeId: resp.TradeId,
|
||||
Amount: resp.ActualAmount,
|
||||
BlockTransactionId: "block_expired",
|
||||
})
|
||||
if err != constant.OrderStatusConflict {
|
||||
t.Fatalf("order processing error = %v, want %v", err, constant.OrderStatusConflict)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload expired order: %v", err)
|
||||
}
|
||||
if order.Status != mdb.StatusExpired {
|
||||
t.Fatalf("expired order status = %d, want %d", order.Status, mdb.StatusExpired)
|
||||
}
|
||||
if order.BlockTransactionId != "" {
|
||||
t.Fatalf("expired order block transaction id = %s, want empty", order.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddress("wallet_2"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, tc := range []struct {
|
||||
address string
|
||||
token string
|
||||
tradeID string
|
||||
amount float64
|
||||
}{
|
||||
{address: resp1.ReceiveAddress, token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount},
|
||||
{address: resp2.ReceiveAddress, token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount},
|
||||
} {
|
||||
wg.Add(1)
|
||||
go func(address, token, tradeID string, amount float64) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: token,
|
||||
Network: "tron",
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: "shared_block",
|
||||
})
|
||||
}(tc.address, tc.token, tc.tradeID, tc.amount)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
var successCount int
|
||||
var duplicateCount int
|
||||
for err := range errs {
|
||||
switch err {
|
||||
case nil:
|
||||
successCount++
|
||||
case constant.OrderBlockAlreadyProcess:
|
||||
duplicateCount++
|
||||
default:
|
||||
t.Fatalf("unexpected order processing error: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 || duplicateCount != 1 {
|
||||
t.Fatalf("success=%d duplicate=%d, want 1 and 1", successCount, duplicateCount)
|
||||
}
|
||||
|
||||
orders := []struct {
|
||||
tradeID string
|
||||
}{
|
||||
{tradeID: resp1.TradeId},
|
||||
{tradeID: resp2.TradeId},
|
||||
}
|
||||
var paidCount int
|
||||
var pendingCount int
|
||||
for _, item := range orders {
|
||||
order, err := data.GetOrderInfoByTradeId(item.tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order %s: %v", item.tradeID, err)
|
||||
}
|
||||
switch order.Status {
|
||||
case mdb.StatusPaySuccess:
|
||||
paidCount++
|
||||
if order.BlockTransactionId != "shared_block" {
|
||||
t.Fatalf("paid order block transaction id = %s, want shared_block", order.BlockTransactionId)
|
||||
}
|
||||
case mdb.StatusWaitPay:
|
||||
pendingCount++
|
||||
if order.BlockTransactionId != "" {
|
||||
t.Fatalf("pending order block transaction id = %s, want empty", order.BlockTransactionId)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected order status for %s: %d", item.tradeID, order.Status)
|
||||
}
|
||||
}
|
||||
if paidCount != 1 || pendingCount != 1 {
|
||||
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentResp.TradeId).
|
||||
Update("status", mdb.StatusExpired).Error; err != nil {
|
||||
t.Fatalf("force parent expired: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_not_wait",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is not wait-pay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
|
||||
t.Fatalf("delete parent order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_missing",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
|
||||
// when a sub-order is paid, the parent order is marked as paid but its own
|
||||
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
|
||||
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
|
||||
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
|
||||
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot the parent's original fields before any payment.
|
||||
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load original parent: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Load the sub-order to get its DB primary-key ID.
|
||||
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load sub-order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_paid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Sub-order must be paid with the block hash and no pending callback.
|
||||
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload sub-order: %v", err)
|
||||
}
|
||||
if sub.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if sub.BlockTransactionId != "block_sub_paid" {
|
||||
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
|
||||
}
|
||||
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
|
||||
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
|
||||
}
|
||||
|
||||
// Parent must be paid but its own fields must be unchanged.
|
||||
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload parent order: %v", err)
|
||||
}
|
||||
if parent.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if parent.BlockTransactionId != "" {
|
||||
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
|
||||
}
|
||||
if parent.ReceiveAddress != originalParent.ReceiveAddress {
|
||||
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
|
||||
}
|
||||
if parent.ActualAmount != originalParent.ActualAmount {
|
||||
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
|
||||
}
|
||||
if parent.PayBySubId != subOrder.ID {
|
||||
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
|
||||
}
|
||||
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
|
||||
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
|
||||
// well as the parent's lock) are released.
|
||||
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
|
||||
t.Fatalf("add bsc wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Create two sub-orders on different networks.
|
||||
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||
}
|
||||
|
||||
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkBsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to bsc sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Pay the Ethereum sub-order.
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subEthResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subEthResp.Token),
|
||||
Network: strings.ToLower(subEthResp.Network),
|
||||
TradeId: subEthResp.TradeId,
|
||||
Amount: subEthResp.ActualAmount,
|
||||
BlockTransactionId: "block_sib_eth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing eth sub-order: %v", err)
|
||||
}
|
||||
|
||||
// BSC sibling must be expired.
|
||||
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload bsc sub-order: %v", err)
|
||||
}
|
||||
if subBsc.Status != mdb.StatusExpired {
|
||||
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
||||
}
|
||||
|
||||
// Parent runtime lock must be released.
|
||||
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check parent runtime lock: %v", err)
|
||||
}
|
||||
if parentLock != "" {
|
||||
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
|
||||
}
|
||||
|
||||
// BSC sibling runtime lock must be released.
|
||||
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||
}
|
||||
if sibLock != "" {
|
||||
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
|
||||
}
|
||||
|
||||
// Ethereum sub-order runtime lock must also be released.
|
||||
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check eth sub-order runtime lock: %v", err)
|
||||
}
|
||||
if ethLock != "" {
|
||||
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
|
||||
}
|
||||
}
|
||||
@@ -2,37 +2,27 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
)
|
||||
|
||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
||||
|
||||
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
|
||||
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单
|
||||
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
||||
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
|
||||
return nil, ErrOrder
|
||||
return nil, errors.New("不存在待支付订单或已过期!")
|
||||
}
|
||||
|
||||
resp := &response.CheckoutCounterResponse{
|
||||
TradeId: orderInfo.TradeId,
|
||||
Amount: orderInfo.Amount,
|
||||
ActualAmount: orderInfo.ActualAmount,
|
||||
Token: orderInfo.Token,
|
||||
Currency: orderInfo.Currency,
|
||||
ReceiveAddress: orderInfo.ReceiveAddress,
|
||||
Network: orderInfo.Network,
|
||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampWithMillisecond(),
|
||||
RedirectUrl: orderInfo.RedirectUrl,
|
||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||
IsSelected: orderInfo.IsSelected,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -1,659 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/gagliardetto/solana-go"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
||||
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||
|
||||
type TransferInfo struct {
|
||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||
Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers)
|
||||
Amount float64 // Human-readable amount (adjusted for decimals)
|
||||
RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals)
|
||||
Decimals *int // Optional decimals from the transaction, if available
|
||||
BlockTime int64 // Block time of the transfer unit is seconds since epoch
|
||||
}
|
||||
|
||||
// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。
|
||||
func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Load enabled Solana tokens from chain_tokens. The contract_address
|
||||
// column holds the mint for SPL tokens; a special entry with
|
||||
// symbol=SOL and empty contract_address enables native SOL payments.
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||
var nativeSolToken *mdb.ChainToken
|
||||
for i := range tokens {
|
||||
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||
mint := strings.TrimSpace(tokens[i].ContractAddress)
|
||||
if sym == "SOL" && mint == "" {
|
||||
nativeSolToken = &tokens[i]
|
||||
continue
|
||||
}
|
||||
if mint == "" {
|
||||
continue
|
||||
}
|
||||
mintTokens[mint] = &tokens[i]
|
||||
}
|
||||
|
||||
// Clean up old entries from processed cache (older than 1 hour)
|
||||
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
|
||||
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
||||
if ts, ok := value.(int64); ok && ts < cleanupCutoff {
|
||||
gProcessedSignatures.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
limit := 1000
|
||||
|
||||
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
|
||||
queryAddrs := []string{address}
|
||||
for mint := range mintTokens {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
|
||||
continue
|
||||
}
|
||||
queryAddrs = append(queryAddrs, ata)
|
||||
}
|
||||
|
||||
// 拉取签名并去重
|
||||
seen := make(map[string]bool)
|
||||
var result []solSignatureResult
|
||||
for _, queryAddr := range queryAddrs {
|
||||
respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "")
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resultBody := gjson.GetBytes(respBody, "result")
|
||||
if !resultBody.Exists() || !resultBody.IsArray() {
|
||||
log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody))
|
||||
continue
|
||||
}
|
||||
|
||||
var batch []solSignatureResult
|
||||
err = json.Unmarshal([]byte(resultBody.Raw), &batch)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sig := range batch {
|
||||
if !seen[sig.Signature] {
|
||||
seen[sig.Signature] = true
|
||||
result = append(result, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address)
|
||||
return
|
||||
}
|
||||
|
||||
// 按 blockTime 降序排列
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].BlockTime == nil {
|
||||
return false
|
||||
}
|
||||
if result[j].BlockTime == nil {
|
||||
return true
|
||||
}
|
||||
return *result[i].BlockTime > *result[j].BlockTime
|
||||
})
|
||||
|
||||
// 时间截止线:订单过期时间 + 5 分钟
|
||||
cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix()
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d",
|
||||
address, len(result), len(queryAddrs), cutoffTime)
|
||||
|
||||
// Process each transaction signature
|
||||
for sigIdx, txSig := range result {
|
||||
sig := txSig.Signature
|
||||
|
||||
// Skip failed transactions
|
||||
if txSig.Err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过截止时间的旧签名不再处理
|
||||
if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime {
|
||||
log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan",
|
||||
address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime)
|
||||
break
|
||||
}
|
||||
|
||||
// 跳过已处理的签名
|
||||
if _, ok := gProcessedSignatures.Load(sig); ok {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot)
|
||||
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err)
|
||||
continue
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions))
|
||||
|
||||
for instrIdx, instruction := range instructions {
|
||||
programID := instruction.Get("programId").String()
|
||||
parsedType := instruction.Get("parsed.type").String()
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType)
|
||||
|
||||
transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData)
|
||||
if err != nil {
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err)
|
||||
continue
|
||||
}
|
||||
if transferInfo == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d",
|
||||
address, sig, instrIdx,
|
||||
transferInfo.Source, transferInfo.Destination, transferInfo.Mint,
|
||||
transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime)
|
||||
|
||||
if !isTransferToAddress(transferInfo, address) {
|
||||
continue
|
||||
}
|
||||
|
||||
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
|
||||
if token == "" || amount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f",
|
||||
address, sig, instrIdx, token, amount, address, token, amount)
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
||||
continue
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)",
|
||||
address, sig, address, token, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||
address, tradeID, sig, token, amount)
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
||||
address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli())
|
||||
|
||||
// blockTime 秒 → 毫秒,与订单创建时间对齐
|
||||
blockTimestamp := transferInfo.BlockTime * 1000
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d",
|
||||
address, sig, blockTimestamp, createTime, blockTimestamp-createTime)
|
||||
if blockTimestamp < createTime {
|
||||
log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)",
|
||||
address, sig, blockTimestamp, createTime-blockTimestamp, createTime)
|
||||
continue
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: token,
|
||||
Network: mdb.NetworkSolana,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
BlockTransactionId: sig,
|
||||
}
|
||||
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||
address, tradeID, sig, token, amount)
|
||||
err = OrderProcessing(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification",
|
||||
address, tradeID, sig, token, amount)
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
||||
}
|
||||
|
||||
// 标记已处理
|
||||
gProcessedSignatures.Store(sig, time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// solSignatureResult getSignaturesForAddress 返回结构
|
||||
type solSignatureResult struct {
|
||||
Signature string `json:"signature"`
|
||||
Slot uint64 `json:"slot"`
|
||||
Err interface{} `json:"err"`
|
||||
BlockTime *int64 `json:"blockTime"`
|
||||
}
|
||||
|
||||
func resolveSolanaRpcURL() (string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, nil
|
||||
}
|
||||
|
||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
client := resty.New()
|
||||
client.SetRetryCount(5)
|
||||
client.SetRetryWaitTime(2 * time.Second)
|
||||
client.SetRetryMaxWaitTime(10 * time.Second)
|
||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if r.StatusCode() >= 429 || r.StatusCode() >= 500 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
rpcUrl, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}).
|
||||
Post(rpcUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody := resp.Body()
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) {
|
||||
opts := map[string]interface{}{
|
||||
"commitment": "finalized",
|
||||
"limit": limit,
|
||||
}
|
||||
if untilSig != "" {
|
||||
opts["until"] = untilSig
|
||||
}
|
||||
if beforeSig != "" {
|
||||
opts["before"] = beforeSig
|
||||
}
|
||||
|
||||
bodyData, err := SolRetryClient("getSignaturesForAddress",
|
||||
[]interface{}{address, opts})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, ok := result["result"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
return bodyData, nil
|
||||
}
|
||||
|
||||
func SolGetTransaction(sig string) ([]byte, error) {
|
||||
txData, err := SolRetryClient("getTransaction", []interface{}{
|
||||
sig,
|
||||
map[string]interface{}{
|
||||
"encoding": "jsonParsed",
|
||||
"commitment": "confirmed",
|
||||
"maxSupportedTransactionVersion": 0, // suport
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("SolRetryClient failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errField := gjson.GetBytes(txData, "result.meta.err")
|
||||
if errField.Exists() && errField.Type != gjson.Null {
|
||||
log.Sugar.Warnf("Transaction failed: %v", errField.String())
|
||||
return nil, fmt.Errorf("transaction failed: %s", errField.String())
|
||||
}
|
||||
|
||||
return txData, nil
|
||||
}
|
||||
|
||||
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||
// Native SOL transfer - check destination directly
|
||||
if transfer.Mint == "SOL" {
|
||||
return strings.EqualFold(transfer.Destination, targetAddress)
|
||||
}
|
||||
|
||||
// Skip Transfer instruction without mint info (use TransferChecked instead)
|
||||
if transfer.Mint == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// SPL Token transfer - check if destination ATA matches
|
||||
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
|
||||
}
|
||||
|
||||
// resolveSolTokenAndAmount identifies the token from a transfer using
|
||||
// the admin-configured chain_tokens list. mintTokens maps SPL mint
|
||||
// addresses to their ChainToken config; nativeSolToken is non-nil when a
|
||||
// native SOL entry exists in chain_tokens. Returns ("", 0) for
|
||||
// transfers whose mint is not configured or below min_amount.
|
||||
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
|
||||
mint := transfer.Mint
|
||||
|
||||
// Native SOL
|
||||
if mint == "SOL" {
|
||||
if nativeSolToken == nil {
|
||||
return "", 0
|
||||
}
|
||||
amount := transfer.Amount
|
||||
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
|
||||
}
|
||||
|
||||
cfg, ok := mintTokens[mint]
|
||||
if !ok || cfg == nil {
|
||||
return "", 0
|
||||
}
|
||||
decimals := cfg.Decimals
|
||||
if transfer.Decimals != nil {
|
||||
decimals = int(*transfer.Decimals)
|
||||
}
|
||||
if decimals <= 0 {
|
||||
decimals = 6
|
||||
}
|
||||
amount := ADJustAmount(transfer.RawAmount, decimals)
|
||||
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
|
||||
}
|
||||
|
||||
const (
|
||||
// Mint token
|
||||
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
||||
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
||||
|
||||
USDT_Decimals = 6
|
||||
USDC_Decimals = 6
|
||||
SOL_Decimals = 9
|
||||
)
|
||||
|
||||
const (
|
||||
// SPL Token instructions
|
||||
InstructionTransfer = 3
|
||||
InstructionTransferChecked = 12
|
||||
)
|
||||
|
||||
const (
|
||||
// System Program
|
||||
SystemProgramID = "11111111111111111111111111111111"
|
||||
InstructionSystemTransfer = 2
|
||||
)
|
||||
|
||||
const (
|
||||
TokenProgramID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
||||
)
|
||||
|
||||
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
|
||||
func ADJustAmount(amount uint64, decimals int) float64 {
|
||||
if amount == 0 {
|
||||
return 0
|
||||
}
|
||||
decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0)
|
||||
// 10^decimals
|
||||
decimalDivisor := decimal.New(1, int32(decimals))
|
||||
adjustedAmount := decimalAmount.Div(decimalDivisor)
|
||||
// Round to 2 decimal places
|
||||
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
|
||||
}
|
||||
|
||||
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDT_Mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func MatchUsdcAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDC_Mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func MatchAtaAddress(address string, mint string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(ata, ataTo)
|
||||
}
|
||||
|
||||
func FindATAAddress(owner, mint string) (string, error) {
|
||||
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid owner public key: %w", err)
|
||||
}
|
||||
|
||||
mintPubKey, err := solana.PublicKeyFromBase58(mint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid mint public key: %w", err)
|
||||
}
|
||||
|
||||
ata, _, err := solana.FindAssociatedTokenAddress(ownerPubKey, mintPubKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find associated token address failed: %w", err)
|
||||
}
|
||||
|
||||
return ata.String(), nil
|
||||
}
|
||||
|
||||
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
|
||||
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
programID := instruction.Get("programId").String()
|
||||
parsedType := instruction.Get("parsed.type").String()
|
||||
|
||||
if programID == SystemProgramID && parsedType == "transfer" {
|
||||
return parseSystemTransfer(instruction, txData)
|
||||
}
|
||||
|
||||
if programID == TokenProgramID || programID == Token2022ProgramID {
|
||||
switch parsedType {
|
||||
case "transfer":
|
||||
return parseSplTransfer(instruction, txData)
|
||||
case "transferChecked":
|
||||
return parseSplTransferChecked(instruction, txData)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
lamports := info.Get("lamports").Uint()
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: "SOL",
|
||||
Amount: ADJustAmount(lamports, SOL_Decimals),
|
||||
RawAmount: lamports,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
|
||||
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
amountStr := info.Get("amount").String()
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
// Look up mint and decimals from postTokenBalances using the destination ATA
|
||||
mint, decimals, found := findMintFromTokenBalances(destination, txData)
|
||||
if !found {
|
||||
// Try source ATA as fallback
|
||||
mint, decimals, found = findMintFromTokenBalances(source, txData)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
|
||||
}
|
||||
|
||||
d := decimals
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: mint,
|
||||
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||
RawAmount: rawAmount.Uint64(),
|
||||
Decimals: &d,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
|
||||
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||
info := instruction.Get("parsed.info")
|
||||
source := info.Get("source").String()
|
||||
destination := info.Get("destination").String()
|
||||
mint := info.Get("mint").String()
|
||||
amountStr := info.Get("tokenAmount.amount").String()
|
||||
decimals := int(info.Get("tokenAmount.decimals").Int())
|
||||
blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
|
||||
|
||||
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: mint,
|
||||
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||
RawAmount: rawAmount.Uint64(),
|
||||
Decimals: &decimals,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
|
||||
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
|
||||
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
|
||||
accountIndex := -1
|
||||
for i, key := range accountKeys {
|
||||
if key.Get("pubkey").String() == ataAddress {
|
||||
accountIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if accountIndex == -1 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
|
||||
for _, balance := range balances {
|
||||
if int(balance.Get("accountIndex").Int()) == accountIndex {
|
||||
mint := balance.Get("mint").String()
|
||||
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
|
||||
return mint, decimals, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setupSolanaRPCNode(t *testing.T, url string) func() {
|
||||
t.Helper()
|
||||
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: url,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("seed solana rpc_node: %v", err)
|
||||
}
|
||||
return cleanup
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
||||
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
|
||||
defer cleanup()
|
||||
|
||||
got, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||
}
|
||||
if got != "https://solana.example.com" {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientHealthy(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
bodyData, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
status, ok := result["result"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("RPC Health Status: %s", status)
|
||||
|
||||
if status != "ok" {
|
||||
t.Errorf("Expected health status 'ok', got '%s'", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example wallet address (replace with actual test address)
|
||||
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
signatures, ok := result["result"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("Found %d signatures for address %s", len(signatures), address)
|
||||
|
||||
}
|
||||
|
||||
func TestSolClientGetTransaction(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example transaction signature (replace with actual test signature)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
|
||||
txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(txData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
txInfo, ok := result["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Unexpected response format: %v", result)
|
||||
}
|
||||
|
||||
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
|
||||
}
|
||||
|
||||
func requireSolanaIntegration(t *testing.T) {
|
||||
t.Helper()
|
||||
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
|
||||
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
|
||||
}
|
||||
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
|
||||
t.Cleanup(cleanup)
|
||||
}
|
||||
|
||||
func TestFindATAAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
owner string
|
||||
mint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "RAY token ATA",
|
||||
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
|
||||
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
|
||||
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ata, err := FindATAAddress(tt.owner, tt.mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", tt.owner)
|
||||
t.Logf("Mint: %s", tt.mint)
|
||||
t.Logf("ATA: %s", ata)
|
||||
|
||||
if tt.want != "" && ata != tt.want {
|
||||
t.Errorf("Expected ATA %s, got %s", tt.want, ata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchATAAddress(t *testing.T) {
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
||||
expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
|
||||
|
||||
ok := MatchAtaAddress(owner, mint, expectedATA)
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("Mint: %s", mint)
|
||||
t.Logf("Expected ATA: %s", expectedATA)
|
||||
t.Logf("Match result: %v", ok)
|
||||
|
||||
if !ok {
|
||||
t.Error("Expected ATA to match, but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsdtAtaAddress(t *testing.T) {
|
||||
// Example wallet address (replace with actual test address)
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
ata, err := FindATAAddress(owner, USDT_Mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("USDT Mint: %s", USDT_Mint)
|
||||
t.Logf("USDT ATA: %s", ata)
|
||||
|
||||
ok := MatchUsdtAtaAddress(owner, ata)
|
||||
if !ok {
|
||||
t.Error("Expected USDT ATA to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUsdcAtaAddress(t *testing.T) {
|
||||
// Example wallet address (replace with actual test address)
|
||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
ata, err := FindATAAddress(owner, USDC_Mint)
|
||||
if err != nil {
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Owner: %s", owner)
|
||||
t.Logf("USDC Mint: %s", USDC_Mint)
|
||||
t.Logf("USDC ATA: %s", ata)
|
||||
|
||||
ok := MatchUsdcAtaAddress(owner, ata)
|
||||
if !ok {
|
||||
t.Error("Expected USDC ATA to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount uint64
|
||||
decimals int
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "USDT amount (6 decimals)",
|
||||
amount: 123456789,
|
||||
decimals: 6,
|
||||
want: 123.46,
|
||||
},
|
||||
{
|
||||
name: "USDC amount (6 decimals)",
|
||||
amount: 1000000,
|
||||
decimals: 6,
|
||||
want: 1.0,
|
||||
},
|
||||
{
|
||||
name: "SOL amount (9 decimals)",
|
||||
amount: 1000000000,
|
||||
decimals: 9,
|
||||
want: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Zero amount",
|
||||
amount: 0,
|
||||
decimals: 6,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "Small amount",
|
||||
amount: 1,
|
||||
decimals: 6,
|
||||
want: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
adjusted := ADJustAmount(tt.amount, tt.decimals)
|
||||
t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
|
||||
|
||||
if adjusted != tt.want {
|
||||
t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
|
||||
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
var found bool
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
||||
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint == "" {
|
||||
t.Error("Expected mint to be resolved from postTokenBalances")
|
||||
}
|
||||
if info.RawAmount != 50000 {
|
||||
t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("No transfer instruction found in transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
var found bool
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
|
||||
info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint != USDT_Mint {
|
||||
t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
|
||||
}
|
||||
if info.RawAmount != 300000 {
|
||||
t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
|
||||
}
|
||||
if info.Amount != 0.3 {
|
||||
t.Errorf("Expected amount 0.3, got %f", info.Amount)
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("No transferChecked instruction found in transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// System program SOL transfer
|
||||
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
|
||||
txData, err := SolGetTransaction(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("SolGetTransaction failed: %v", err)
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||
transferCount := 0
|
||||
for _, inst := range instructions {
|
||||
info, err := ParseTransferInfoFromInstruction(inst, txData)
|
||||
if err != nil {
|
||||
t.Logf("parse error (ok to skip): %v", err)
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
transferCount++
|
||||
t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
|
||||
transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
|
||||
|
||||
if info.Mint != "SOL" {
|
||||
t.Errorf("Expected mint SOL, got %s", info.Mint)
|
||||
}
|
||||
if info.RawAmount == 0 {
|
||||
t.Error("Expected non-zero raw amount")
|
||||
}
|
||||
if info.BlockTime == 0 {
|
||||
t.Error("Expected non-zero blockTime")
|
||||
}
|
||||
}
|
||||
if transferCount == 0 {
|
||||
t.Error("No system transfer instruction found")
|
||||
}
|
||||
t.Logf("Found %d system transfers", transferCount)
|
||||
}
|
||||
@@ -1,500 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/json"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/golang-module/carbon/v2"
|
||||
"github.com/gookit/goutil/mathutil"
|
||||
"github.com/gookit/goutil/stdutil"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tidwall/gjson"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
||||
// It reads the first healthy (or any enabled) row from the rpc_nodes table.
|
||||
func resolveTronNode() (string, string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, 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.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
|
||||
const UsdtTrc20ApiUri = "https://apilist.tronscan.org/api/token_trc20/transfers"
|
||||
|
||||
type UsdtTrc20Resp struct {
|
||||
Total int `json:"total"`
|
||||
RangeTotal int `json:"rangeTotal"`
|
||||
TokenTransfers []TokenTransfers `json:"token_transfers"`
|
||||
}
|
||||
|
||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||
type TokenInfo struct {
|
||||
TokenID string `json:"tokenId"`
|
||||
TokenAbbr string `json:"tokenAbbr"`
|
||||
TokenName string `json:"tokenName"`
|
||||
TokenDecimal int `json:"tokenDecimal"`
|
||||
TokenCanShow int `json:"tokenCanShow"`
|
||||
TokenType string `json:"tokenType"`
|
||||
TokenLogo string `json:"tokenLogo"`
|
||||
TokenLevel string `json:"tokenLevel"`
|
||||
Vip bool `json:"vip"`
|
||||
}
|
||||
type TokenTransfers struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
BlockTs int64 `json:"block_ts"`
|
||||
FromAddress string `json:"from_address"`
|
||||
ToAddress string `json:"to_address"`
|
||||
Block int `json:"block"`
|
||||
ContractAddress string `json:"contract_address"`
|
||||
Quant string `json:"quant"`
|
||||
ApprovalAmount string `json:"approval_amount"`
|
||||
EventType string `json:"event_type"`
|
||||
ContractType string `json:"contract_type"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
ContractRet string `json:"contractRet"`
|
||||
FinalResult string `json:"finalResult"`
|
||||
TokenInfo TokenInfo `json:"tokenInfo"`
|
||||
FromAddressIsContract bool `json:"fromAddressIsContract"`
|
||||
ToAddressIsContract bool `json:"toAddressIsContract"`
|
||||
Revert bool `json:"revert"`
|
||||
}
|
||||
|
||||
// Trc20CallBack trc20回调
|
||||
func Trc20CallBack(token string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
var innerWg sync.WaitGroup
|
||||
innerWg.Add(2)
|
||||
go checkTrxTransfers(address, &innerWg)
|
||||
go checkTrc20Transfers(address, &innerWg)
|
||||
innerWg.Wait()
|
||||
}
|
||||
|
||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
// Native TRX is gated by a chain_tokens row with empty
|
||||
// contract_address and symbol=TRX. Admin can disable this row to
|
||||
// stop scanning native transfers without touching the chain toggle.
|
||||
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if trxCfg == nil || trxCfg.ID == 0 {
|
||||
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
trxDecimals := trxCfg.Decimals
|
||||
if trxDecimals <= 0 {
|
||||
trxDecimals = 6
|
||||
}
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
|
||||
return
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
||||
|
||||
startTime := carbon.Now().AddHours(-24).TimestampWithMillisecond()
|
||||
endTime := carbon.Now().TimestampWithMillisecond()
|
||||
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)
|
||||
"limit": "200",
|
||||
"start": "0",
|
||||
"direction": "in",
|
||||
"tokens": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
||||
"relatedAddress": token,
|
||||
"start_timestamp": stdutil.ToString(startTime),
|
||||
"end_timestamp": stdutil.ToString(endTime),
|
||||
}).Get(UsdtTrc20ApiUri)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
|
||||
return
|
||||
panic(err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
|
||||
panic(err)
|
||||
}
|
||||
var trc20Resp UsdtTrc20Resp
|
||||
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if trc20Resp.Total <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
|
||||
return
|
||||
}
|
||||
|
||||
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" {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now().Unix()
|
||||
for _, transfer := range trc20Resp.TokenTransfers {
|
||||
if transfer.ToAddress != token || transfer.FinalResult != "SUCCESS" {
|
||||
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)
|
||||
x, _ := decimal.NewFromString(transfer.Quant)
|
||||
y, _ := decimal.NewFromString("1000000")
|
||||
quant := x.Div(y).InexactFloat64()
|
||||
amount := fmt.Sprintf("%.4f", quant)
|
||||
result, err := data.GetExpirationTimeByAmount(ctx, token, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
|
||||
panic(err)
|
||||
}
|
||||
if result != "" {
|
||||
expTime := mathutil.MustInt64(result)
|
||||
// 但是过期了
|
||||
if expTime < nowTime {
|
||||
// 删掉过期
|
||||
_ = data.ClearPayCache(token, amount)
|
||||
continue
|
||||
}
|
||||
if tron.EncodeCheck(toBytes) != address {
|
||||
continue
|
||||
}
|
||||
|
||||
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
|
||||
decimalQuant, err := decimal.NewFromString(rawAmount)
|
||||
// 该钱包下有无匹配金额订单
|
||||
tradeId, err := data.GetTradeIdByAmount(ctx, token, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
||||
panic(err)
|
||||
}
|
||||
if tradeId == "" {
|
||||
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,
|
||||
Token: token,
|
||||
TradeId: tradeId,
|
||||
Amount: amount,
|
||||
BlockTransactionId: txID,
|
||||
BlockTransactionId: transfer.TransactionID,
|
||||
}
|
||||
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)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, 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]
|
||||
}
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
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 {
|
||||
switch chainNetwork {
|
||||
case mdb.NetworkEthereum:
|
||||
return "ETH"
|
||||
case mdb.NetworkBsc:
|
||||
return "BSC"
|
||||
case mdb.NetworkPolygon:
|
||||
return "POLYGON"
|
||||
case mdb.NetworkPlasma:
|
||||
return "PLASMA"
|
||||
default:
|
||||
return "EVM"
|
||||
}
|
||||
}
|
||||
|
||||
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
|
||||
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
|
||||
// 管理后台新增 token 即可立即生效,无需代码改动。
|
||||
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
|
||||
}
|
||||
}()
|
||||
|
||||
net := evmChainLogLabel(chainNetwork)
|
||||
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
|
||||
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
|
||||
}
|
||||
|
||||
walletAddr := strings.ToLower(toAddr.Hex())
|
||||
if rawValue == nil || rawValue.Sign() <= 0 {
|
||||
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
|
||||
return
|
||||
}
|
||||
divisor := decimal.New(1, int32(decimals))
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
||||
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
|
||||
return
|
||||
}
|
||||
if amount <= 0 {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
||||
return
|
||||
}
|
||||
|
||||
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(chainNetwork, walletAddr, tokenSym, amount)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-%s][%s] lock lookup: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-%s][%s] load order: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
|
||||
return
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
||||
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{
|
||||
ReceiveAddress: walletAddr,
|
||||
Token: tokenSym,
|
||||
Network: chainNetwork,
|
||||
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("[%s-%s][%s] skip resolved trade_id=%s hash=%s err=%v", net, tokenSym, walletAddr, tradeID, txHash, err)
|
||||
return
|
||||
}
|
||||
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err)
|
||||
return
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
|
||||
}
|
||||
|
||||
func sendPaymentNotification(order *mdb.Orders) {
|
||||
msg := fmt.Sprintf(
|
||||
"🎉 <b>收款成功通知</b>\n\n"+
|
||||
"💰 <b>金额信息</b>\n"+
|
||||
"├ 订单金额:<code>%.2f %s</code>\n"+
|
||||
"└ 实际到账:<code>%.2f %s</code>\n\n"+
|
||||
"📋 <b>订单信息</b>\n"+
|
||||
"├ 交易号:<code>%s</code>\n"+
|
||||
"├ 订单号:<code>%s</code>\n"+
|
||||
"├ 网络:<code>%s</code>\n"+
|
||||
"└ 钱包地址:<code>%s</code>\n\n"+
|
||||
"⏰ <b>时间信息</b>\n"+
|
||||
"├ 创建时间:%s\n"+
|
||||
"└ 支付时间:%s",
|
||||
order.Amount,
|
||||
strings.ToUpper(order.Currency),
|
||||
order.ActualAmount,
|
||||
strings.ToUpper(order.Token),
|
||||
order.TradeId,
|
||||
order.OrderId,
|
||||
networkDisplay(order.Network),
|
||||
order.ReceiveAddress,
|
||||
order.CreatedAt.ToDateTimeString(),
|
||||
carbon.Now().ToDateTimeString(),
|
||||
)
|
||||
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
|
||||
}
|
||||
|
||||
func networkDisplay(n string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(n)) {
|
||||
case mdb.NetworkTron:
|
||||
return "Tron"
|
||||
case mdb.NetworkSolana:
|
||||
return "Solana"
|
||||
case mdb.NetworkEthereum:
|
||||
return "Ethereum"
|
||||
case mdb.NetworkBsc:
|
||||
return "BSC"
|
||||
case mdb.NetworkPolygon:
|
||||
return "Polygon"
|
||||
case mdb.NetworkPlasma:
|
||||
return "Plasma"
|
||||
default:
|
||||
if n == "" {
|
||||
return "Tron"
|
||||
}
|
||||
return strings.ToUpper(n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
|
||||
// TRON row from rpc_nodes.
|
||||
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_WithRow verifies that resolveTronNode uses the DB row
|
||||
// when an enabled TRON HTTP node is present.
|
||||
func TestResolveTronNode_WithRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Insert an enabled TRON http node.
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://custom-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "db-api-key",
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
|
||||
gotURL, gotKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTronNode(): %v", err)
|
||||
}
|
||||
if gotURL != "https://custom-tron.example.com" {
|
||||
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
|
||||
}
|
||||
if gotKey != "db-api-key" {
|
||||
t.Errorf("apiKey = %q, want \"db-api-key\"", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://disabled-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "disabled-key",
|
||||
Weight: 1,
|
||||
Enabled: true, // start enabled, then disable below
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
// Explicitly disable — GORM does not save bool zero-value on Create.
|
||||
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
|
||||
t.Fatalf("disable rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/json"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
const QueueOrderCallback = "order:callback"
|
||||
|
||||
func NewOrderCallbackQueue(order *mdb.Orders) (*asynq.Task, error) {
|
||||
payload, err := json.Cjson.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asynq.NewTask(QueueOrderCallback, payload), nil
|
||||
}
|
||||
|
||||
func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error {
|
||||
var order mdb.Orders
|
||||
err := json.Cjson.Unmarshal(t.Payload(), &order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Error(err)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
data.SaveCallBackOrdersResp(&order)
|
||||
}()
|
||||
client := http_client.GetHttpClient()
|
||||
orderResp := response.OrderNotifyResponse{
|
||||
TradeId: order.TradeId,
|
||||
OrderId: order.OrderId,
|
||||
Amount: order.Amount,
|
||||
ActualAmount: order.ActualAmount,
|
||||
Token: order.Token,
|
||||
BlockTransactionId: order.BlockTransactionId,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
}
|
||||
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orderResp.Signature = signature
|
||||
resp, err := client.R().SetHeader("powered-by", "Epusdt(https://github.com/assimon/epusdt)").SetBody(orderResp).Post(order.NotifyUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := string(resp.Body())
|
||||
if body != "ok" {
|
||||
order.CallBackConfirm = mdb.CallBackConfirmNo
|
||||
return errors.New("not ok")
|
||||
}
|
||||
order.CallBackConfirm = mdb.CallBackConfirmOk
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user