This commit is contained in:
Ubuntu committed 2026-03-28 14:55:39 +08:00
commit 9f5bc33085
138 files changed
+26307

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"playwright-skill@playwright-skill": true
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(pkill:*)",
"Bash(git add web/vite.config.ts)",
"Bash(git commit -m \"fix: add missing API proxy routes for plugins and refunds\n\n- Add /admin/plugins proxy for plugin configuration APIs\n- Add /admin/test-payment proxy for test payment API\n- Add /admin/refunds and /merchant/refunds proxy for refund APIs\n\nCo-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>\")"
]
}
}
+10
View File
@@ -0,0 +1,10 @@
# epay-go/.dockerignore
.git
.gitignore
README.md
docs
web/node_modules
web/dist
*.log
*.test
.env.local
+31
View File
@@ -0,0 +1,31 @@
# epay-go/.env.example
# 数据库配置
DB_USER=epay
DB_PASSWORD=epay123
DB_NAME=epay
DB_PORT=5432
# Go 模块代理
GOPROXY=https://goproxy.cn,direct
# JWT 密钥
JWT_SECRET=your-super-secret-key-change-in-production
# 默认管理员初始化(仅数据库中不存在管理员时生效)
DEFAULT_ADMIN_USERNAME=epay_admin
DEFAULT_ADMIN_PASSWORD=ChangeMe123!
# 支付宝配置
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
# 微信支付配置
WECHAT_APP_ID=
WECHAT_MCH_ID=
WECHAT_API_KEY=
#域名配置
SITE_ADDRESS=pay.example.com
ACME_EMAIL=admin@example.com
REDIS_PASSWORD=change-me-in-production
+49
View File
@@ -0,0 +1,49 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
epay-server
# Test binary
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
*.local
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Build
dist/
build/
# Frontend
web/node_modules/
web/dist/
# Config
config.yaml
# Worktrees
.worktrees/
+187
View File
@@ -0,0 +1,187 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
EPay-Go 是一个基于 Go 的聚合支付网关系统,支持支付宝和微信支付。系统分为三个主要部分:
- 对外支付 API(供商户调用)
- 商户中心(商户管理订单和结算)
- 管理后台(管理员管理商户和通道)
## 常用命令
### 本地开发
```bash
# 启动开发环境(需要先配置 config.yaml)
go run cmd/server/main.go
# 构建可执行文件
go build -o epay-server cmd/server/main.go
# 运行测试
go test ./...
# 下载依赖
go mod tidy
```
### Docker 部署
```bash
# 启动开发环境(PostgreSQL + Redis + 后端 + 前端)
docker-compose up -d
# 查看服务日志
docker-compose logs -f
# 停止服务
docker-compose down
# 重新构建
docker-compose build --no-cache
# 数据库备份
docker-compose exec postgres pg_dump -U epay epay > backup.sql
# 数据库恢复
cat backup.sql | docker-compose exec -T postgres psql -U epay epay
```
### 访问地址
- 前端: http://localhost
- 后端 API: http://localhost:8080
- 管理后台: http://localhost/admin/login
- 商户中心: http://localhost/merchant/login
- 健康检查: http://localhost:8080/health
## 架构设计
### 目录结构
```
cmd/server/ # 主程序入口,负责初始化和启动
internal/
├── handler/ # HTTP 请求处理层(按角色分包:admin/merchant/payment)
├── service/ # 业务逻辑层
├── repository/ # 数据访问层
├── model/ # 数据模型(GORM)
├── payment/ # 支付适配器(核心架构)
├── router/ # 路由配置
├── middleware/ # 中间件(JWT、CORS、日志等)
├── config/ # 配置管理(Viper)
├── database/ # 数据库初始化和迁移
└── cache/ # Redis 缓存
pkg/ # 可复用工具包(jwt/response/sign/utils)
web/ # 前端代码
```
### 分层架构
采用经典三层架构:
- **Handler 层**: 处理 HTTP 请求、参数验证、响应封装
- **Service 层**: 核心业务逻辑、事务管理
- **Repository 层**: 数据库操作、查询封装
示例调用链:`handler/payment/create.go` -> `service/order.go` -> `repository/order.go`
### 支付适配器模式
核心设计在 `internal/payment/` 目录:
1. **统一接口** (`adapter.go`): 定义 `PaymentAdapter` 接口,包含:
- `CreateOrder`: 创建支付订单
- `QueryOrder`: 查询订单状态
- `Refund`: 退款
- `ParseNotify`: 解析异步回调
- `NotifySuccess`: 返回回调成功响应
2. **工厂模式** (`factory.go`): 通过 `Register()` 注册适配器,`NewAdapter()` 创建实例
3. **具体实现**:
- `alipay.go`: 支付宝适配器
- `wechat.go`: 微信支付适配器
4. **注册机制**: 在 `cmd/server/main.go` 中通过匿名导入 `_ "github.com/example/epay-go/internal/payment"` 触发 `init()` 函数自动注册适配器
**添加新支付通道**:
1. 在 `internal/payment/` 创建新文件(如 `stripe.go`)
2. 实现 `PaymentAdapter` 接口
3. 在 `init()` 函数中调用 `Register("stripe", newStripeAdapter)`
4. 无需修改其他代码,系统自动支持新通道
### JWT 认证
系统使用双 Token 机制(`pkg/jwt/`):
- `TokenTypeAdmin`: 管理员 Token
- `TokenTypeMerchant`: 商户 Token
中间件 `middleware.JWTAuth(tokenType)` 根据 Token 类型进行权限校验。
### 路由组织
所有路由在 `internal/router/router.go` 中统一注册:
- `/api/pay/*`: 对外支付 API(无需登录)
- `/admin/*`: 管理后台 API(需要管理员 Token)
- `/merchant/*`: 商户中心 API(需要商户 Token)
### 异步通知机制
系统启动时会启动一个后台工作协程 (`service.NotifyService.StartNotifyWorker`),负责:
- 处理支付成功后的异步通知
- 重试失败的通知
- 通过 Context 支持优雅关闭
### 配置管理
使用 Viper 管理配置,配置文件 `config.yaml`:
- 服务器端口和运行模式
- 数据库连接(PostgreSQL)
- Redis 连接
- JWT 密钥和过期时间
**首次运行**: 复制 `config.example.yaml` 为 `config.yaml` 并修改配置。
### 数据库迁移
程序启动时会自动执行 `database.Migrate()` 进行数据库迁移,无需手动运行 SQL 脚本。
### 核心依赖
- `github.com/gin-gonic/gin`: Web 框架
- `gorm.io/gorm`: ORM
- `github.com/go-pay/gopay`: 支付 SDK(支持支付宝、微信等)
- `github.com/golang-jwt/jwt/v5`: JWT 认证
- `github.com/redis/go-redis/v9`: Redis 客户端
- `github.com/shopspring/decimal`: 金额精确计算
## 开发注意事项
### 金额处理
**必须使用** `github.com/shopspring/decimal` 处理所有金额,避免浮点数精度问题:
```go
amount := decimal.NewFromFloat(100.50) // 正确
amount := 100.50 // 错误 - 永远不要用 float64 存储金额
```
### 支付回调验证
每个支付适配器的 `ParseNotify()` 必须:
1. 验证签名
2. 验证订单金额
3. 验证订单状态
4. 返回统一的 `NotifyResult` 结构
### 事务管理
涉及资金变动的操作必须在 Service 层使用数据库事务,参考现有 Service 实现。
### 模块导入路径
项目 module 为 `github.com/example/epay-go`,导入内部包时使用完整路径:
```go
import "github.com/example/epay-go/internal/service"
```
+99
View File
@@ -0,0 +1,99 @@
# EPay Go 部署指南
## 环境要求
- Docker 20.10+
- Docker Compose 2.0+
- 2GB+ 内存
- 10GB+ 磁盘空间
## 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd epay-go
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env 文件,配置必要的环境变量
```
### 3. 启动服务
```bash
# 开发环境
docker-compose up -d
# 生产环境
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### 4. 访问服务
- 前端: http://localhost
- 后端 API: http://localhost:8080
- 管理后台: http://localhost/admin/login
- 商户中心: http://localhost/merchant/login
## 常用命令
```bash
# 查看日志
docker-compose logs -f
# 查看服务状态
docker-compose ps
# 停止服务
docker-compose down
# 重新构建
docker-compose build --no-cache
# 进入容器
docker-compose exec backend sh
docker-compose exec postgres psql -U epay
```
## 数据备份
```bash
# 备份数据库
docker-compose exec postgres pg_dump -U epay epay > backup.sql
# 恢复数据库
cat backup.sql | docker-compose exec -T postgres psql -U epay epay
```
## 更新部署
```bash
# 拉取最新代码
git pull
# 重新构建并启动
docker-compose build
docker-compose up -d
```
## 故障排查
### 后端无法连接数据库
检查 PostgreSQL 是否就绪:
```bash
docker-compose logs postgres
```
### 前端无法访问后端 API
检查 Nginx 代理配置和后端服务状态:
```bash
docker-compose logs frontend
docker-compose logs backend
```
+42
View File
@@ -0,0 +1,42 @@
# epay-go/Dockerfile
# 构建阶段
FROM golang:1.25-alpine AS builder
WORKDIR /app
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
# 安装依赖
RUN apk add --no-cache git
# 复制 go.mod 和 go.sum
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码
COPY . .
# 构建
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o epay-server ./cmd/server
# 运行阶段
FROM alpine:3.19
WORKDIR /app
# 安装必要工具
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 从构建阶段复制二进制文件
COPY --from=builder /app/epay-server .
COPY --from=builder /app/config.example.yaml ./config.yaml
# 暴露端口
EXPOSE 8080
# 运行
CMD ["./epay-server"]
+143
View File
@@ -0,0 +1,143 @@
# EPay Go
一个基于 Go + Gin + PostgreSQL + Redis + Vue 3 的支付系统示例项目,提供管理后台、商户中心、统一下单、通道管理,以及订单 / 退款 / 结算流程。
## 技术栈
- 后端:Go、Gin、GORM、PostgreSQL、Redis
- 前端:Vue 3、Vite、Arco Design
- 部署:Docker Compose、Nginx / Caddy
## 目录说明
- `cmd/server`:服务启动入口
- `internal`:后端核心业务
- `web`:前端代码
- `deploy/nginx`:Nginx 配置示例
- `deploy/caddy`:Caddy 配置
- `docker-compose.yml`:默认部署
- `docker-compose.prod.caddy.yml`:Caddy 直接对外的生产部署示例
## 快速开始
### 1. 准备环境变量
```bash
cp .env.example .env
```
然后按需修改数据库、Redis、JWT、默认管理员和支付渠道配置。
### 2. 启动项目
```bash
docker compose up -d --build
```
默认包含以下服务:
- `postgres`
- `redis`
- `backend`
- `frontend`
默认端口:
- `80`:前端
- `8080`:后端
- `5432`:PostgreSQL
- `6379`:Redis
## 环境变量
参考 `.env.example`。常用变量包括:
- `DB_USER`
- `DB_PASSWORD`
- `DB_NAME`
- `REDIS_PASSWORD`
- `JWT_SECRET`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD`
- `SITE_ADDRESS`
- `ACME_EMAIL`
- `ALIPAY_APP_ID`
- `ALIPAY_PRIVATE_KEY`
- `ALIPAY_PUBLIC_KEY`
- `WECHAT_APP_ID`
- `WECHAT_MCH_ID`
- `WECHAT_API_KEY`
系统首次启动且数据库中没有管理员时,会使用 `DEFAULT_ADMIN_USERNAME` 和 `DEFAULT_ADMIN_PASSWORD` 初始化默认管理员。
## 部署
### Caddy 直接对外
适用于宿主机未占用 `80/443`:
```bash
docker compose -f docker-compose.prod.caddy.yml up -d --build
```
### 宿主机 Nginx 反向代理
适用于宿主机已有 Nginx:
```bash
docker compose up -d --build
```
然后由宿主机 Nginx 反代到容器端口,示例配置见:
- `deploy/nginx/host.prod.conf.example`
## 支付参数说明
支付通道和支付场景是分开的:
- `type` / `pay_type`:决定渠道,例如 `wxpay`、`alipay`
- `pay_method`:决定场景,例如 `native`、`scan`、`h5`、`jsapi`、`web`
### 关键规则
- `type=native` **不允许单独使用**,因为无法判断是微信还是支付宝
- 后端支持显式别名,并会自动归一化:
- `WX_NATIVE`
- `WX_JSAPI`
- `WX_H5`
- `ALIPAY_SCAN`
- `ALIPAY_H5`
- `ALIPAY_WEB`
### 推荐传法
- **微信 Native**
- `type=WX_NATIVE`
- 或 `type=wxpay&pay_method=native`
- **微信 JSAPI**
- `type=WX_JSAPI`
- 或 `type=wxpay&pay_method=jsapi`
- **支付宝扫码**
- `type=ALIPAY_SCAN`
- 或 `type=alipay&pay_method=scan`
- **支付宝 H5**
- `type=ALIPAY_H5`
- 或 `type=alipay&pay_method=h5`
- **支付宝网页支付**
- `type=ALIPAY_WEB`
- 或 `type=alipay&pay_method=web`
## 构建说明
如果在中国大陆网络环境构建,可以在 `.env` 中设置:
```env
GOPROXY=https://goproxy.cn,direct
```
+116
View File
@@ -0,0 +1,116 @@
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/example/epay-go/internal/cache"
"github.com/example/epay-go/internal/config"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/router"
"github.com/example/epay-go/internal/service"
"github.com/gin-gonic/gin"
// 注册支付适配器
_ "github.com/example/epay-go/internal/payment"
)
func main() {
// 加载配置
if err := config.Load("config.yaml"); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
cfg := config.Get()
// 初始化数据库
if err := database.Init(); err != nil {
log.Fatalf("Failed to init database: %v", err)
}
defer database.Close()
// 数据库迁移
if err := database.Migrate(); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
// 初始化 Redis
if err := cache.Init(); err != nil {
log.Fatalf("Failed to init redis: %v", err)
}
defer cache.Close()
// 初始化默认管理员
adminService := service.NewAdminService()
if err := adminService.InitDefaultAdmin(); err != nil {
log.Printf("Failed to init default admin: %v", err)
}
// 设置 Gin 模式
gin.SetMode(cfg.Server.Mode)
// 创建 Gin 引擎
r := gin.New()
// 全局中间件
r.Use(middleware.Recovery())
r.Use(middleware.Logger())
r.Use(middleware.Cors())
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 注册所有路由
router.Setup(r)
// 启动异步通知工作协程
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
notifyService := service.NewNotifyService()
go notifyService.StartNotifyWorker(ctx)
// 创建 HTTP 服务器
addr := fmt.Sprintf(":%d", cfg.Server.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
}
// 在 goroutine 中启动服务器
go func() {
log.Printf("Server starting on %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// 取消 notify worker 的 context
cancel()
// 优雅关闭 HTTP 服务器(最多等待 5 秒)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
+22
View File
@@ -0,0 +1,22 @@
# config.example.yaml
server:
port: 8080
mode: debug # debug, release, test
database:
host: localhost
port: 5432
user: epay
password: your_password_here
dbname: epay
sslmode: disable
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: your_jwt_secret_here_change_in_production
expire_hour: 168 # 7 days
+22
View File
@@ -0,0 +1,22 @@
{
email {$ACME_EMAIL}
}
{$SITE_ADDRESS:localhost} {
@api path /api/*
reverse_proxy @api backend:8080
@legacy_submit path /submit.php
reverse_proxy @legacy_submit backend:8080
@legacy_mapi path /mapi.php
reverse_proxy @legacy_mapi backend:8080
@legacy_api path /api.php
reverse_proxy @legacy_api backend:8080
@health path /health
reverse_proxy @health backend:8080
reverse_proxy frontend:80
}
+67
View File
@@ -0,0 +1,67 @@
server {
listen 80;
server_name _;
client_max_body_size 20m;
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location = /submit.php {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location = /mapi.php {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location = /api.php {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location = /health {
access_log off;
proxy_pass http://backend:8080/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location / {
proxy_pass http://frontend:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
+54
View File
@@ -0,0 +1,54 @@
server {
listen 80;
server_name your-domain.com;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /submit.php {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /mapi.php {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api.php {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://127.0.0.1:8080/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+128
View File
@@ -0,0 +1,128 @@
services:
postgres:
image: postgres:16-alpine
container_name: epay-postgres
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${DB_USER:-epay}
POSTGRES_PASSWORD: ${DB_PASSWORD:-epay123}
POSTGRES_DB: ${DB_NAME:-epay}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-epay}"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
container_name: epay-redis
restart: unless-stopped
env_file:
- .env
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a $REDIS_PASSWORD} ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
backend:
build:
context: .
dockerfile: Dockerfile
args:
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
container_name: epay-backend
restart: unless-stopped
env_file:
- .env
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=${DB_USER:-epay}
- DB_PASSWORD=${DB_PASSWORD:-epay123}
- DB_NAME=${DB_NAME:-epay}
- DB_SSLMODE=disable
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- JWT_SECRET=${JWT_SECRET:-your-secret-key}
- GIN_MODE=release
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
frontend:
build:
context: ./web
dockerfile: Dockerfile
container_name: epay-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
caddy:
image: caddy:2.8-alpine
container_name: epay-caddy
restart: unless-stopped
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
environment:
- SITE_ADDRESS=${SITE_ADDRESS:-localhost}
- ACME_EMAIL=${ACME_EMAIL:-}
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports:
- "80:80"
- "443:443"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
caddy_data:
caddy_config:
postgres_data:
redis_data:
+94
View File
@@ -0,0 +1,94 @@
# epay-go/docker-compose.yml
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:16-alpine
container_name: epay-postgres
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${DB_USER:-epay}
POSTGRES_PASSWORD: ${DB_PASSWORD:-epay123}
POSTGRES_DB: ${DB_NAME:-epay}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-epay}"]
interval: 10s
timeout: 5s
retries: 5
# Redis 缓存
redis:
image: redis:7-alpine
container_name: epay-redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a $REDIS_PASSWORD} ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
# Go 后端
backend:
build:
context: .
dockerfile: Dockerfile
args:
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
container_name: epay-backend
restart: unless-stopped
env_file:
- .env
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=${DB_USER:-epay}
- DB_PASSWORD=${DB_PASSWORD:-epay123}
- DB_NAME=${DB_NAME:-epay}
- DB_SSLMODE=disable
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- JWT_SECRET=${JWT_SECRET:-your-secret-key}
- GIN_MODE=release
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
# Vue 前端
frontend:
build:
context: ./web
dockerfile: Dockerfile
container_name: epay-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
postgres_data:
redis_data:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,582 @@
# EPay Go 阶段五:Docker 容器化部署 实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 将 EPay Go 系统容器化,实现一键部署
**Architecture:** 使用多阶段构建优化镜像大小,docker-compose 编排多服务(Go后端、Vue前端、PostgreSQL、Redis)
**Tech Stack:** Docker, Docker Compose, Nginx, PostgreSQL, Redis
---
## Task 1: 创建后端 Dockerfile
**Files:**
- Create: `epay-go/Dockerfile`
**Step 1: 创建多阶段构建 Dockerfile**
```dockerfile
# epay-go/Dockerfile
# 构建阶段
FROM golang:1.21-alpine AS builder
WORKDIR /app
# 安装依赖
RUN apk add --no-cache git
# 复制 go.mod 和 go.sum
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码
COPY . .
# 构建
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o epay-server ./cmd/server
# 运行阶段
FROM alpine:3.19
WORKDIR /app
# 安装必要工具
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 从构建阶段复制二进制文件
COPY --from=builder /app/epay-server .
COPY --from=builder /app/configs ./configs
# 暴露端口
EXPOSE 8080
# 运行
CMD ["./epay-server"]
```
**Step 2: 创建 .dockerignore**
```
# epay-go/.dockerignore
.git
.gitignore
README.md
docs
web/node_modules
web/dist
*.log
*.test
.env.local
```
**Verification:**
```bash
cd epay-go && docker build -t epay-server:test .
```
Expected: 构建成功
---
## Task 2: 创建前端 Dockerfile
**Files:**
- Create: `epay-go/web/Dockerfile`
- Create: `epay-go/web/nginx.conf`
**Step 1: 创建前端 Dockerfile**
```dockerfile
# epay-go/web/Dockerfile
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
# 复制 package.json
COPY package*.json ./
RUN npm ci
# 复制源代码并构建
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
**Step 2: 创建 Nginx 配置**
```nginx
# epay-go/web/nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# 静态资源缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 代理
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /merchant/ {
# 区分 API 请求和前端路由
# API 请求代理到后端
if ($request_method != GET) {
proxy_pass http://backend:8080;
}
# GET 请求如果是 API 也代理
set $is_api 0;
if ($uri ~ ^/merchant/api/) {
set $is_api 1;
}
if ($is_api = 1) {
proxy_pass http://backend:8080;
}
# 其他 GET 请求返回前端页面
try_files $uri $uri/ /index.html;
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
}
```
**Step 3: 创建前端 .dockerignore**
```
# epay-go/web/.dockerignore
node_modules
dist
.git
*.log
.env.local
```
**Verification:**
```bash
cd epay-go/web && docker build -t epay-web:test .
```
Expected: 构建成功
---
## Task 3: 创建 Docker Compose 配置
**Files:**
- Create: `epay-go/docker-compose.yml`
- Create: `epay-go/.env.example`
**Step 1: 创建 docker-compose.yml**
```yaml
# epay-go/docker-compose.yml
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:16-alpine
container_name: epay-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-epay}
POSTGRES_PASSWORD: ${DB_PASSWORD:-epay123}
POSTGRES_DB: ${DB_NAME:-epay}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-epay}"]
interval: 10s
timeout: 5s
retries: 5
# Redis 缓存
redis:
image: redis:7-alpine
container_name: epay-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Go 后端
backend:
build:
context: .
dockerfile: Dockerfile
container_name: epay-backend
restart: unless-stopped
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=${DB_USER:-epay}
- DB_PASSWORD=${DB_PASSWORD:-epay123}
- DB_NAME=${DB_NAME:-epay}
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET:-your-secret-key}
- GIN_MODE=release
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Vue 前端
frontend:
build:
context: ./web
dockerfile: Dockerfile
container_name: epay-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
volumes:
postgres_data:
redis_data:
```
**Step 2: 创建环境变量示例文件**
```bash
# epay-go/.env.example
# 数据库配置
DB_USER=epay
DB_PASSWORD=epay123
DB_NAME=epay
# JWT 密钥
JWT_SECRET=your-super-secret-key-change-in-production
# 支付宝配置
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
# 微信支付配置
WECHAT_APP_ID=
WECHAT_MCH_ID=
WECHAT_API_KEY=
```
**Verification:**
```bash
cd epay-go && docker-compose config
```
Expected: 配置验证通过
---
## Task 4: 创建生产环境配置
**Files:**
- Create: `epay-go/docker-compose.prod.yml`
**Step 1: 创建生产环境 compose 覆盖文件**
```yaml
# epay-go/docker-compose.prod.yml
version: '3.8'
services:
postgres:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
# 生产环境不暴露端口
ports: []
redis:
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
ports: []
backend:
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
# 只暴露给内网
ports: []
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
frontend:
ports:
- "80:80"
- "443:443"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
---
## Task 5: 创建部署脚本和文档
**Files:**
- Create: `epay-go/scripts/deploy.sh`
- Create: `epay-go/DEPLOYMENT.md`
**Step 1: 创建部署脚本**
```bash
#!/bin/bash
# epay-go/scripts/deploy.sh
set -e
echo "=== EPay Go 部署脚本 ==="
# 检查环境变量文件
if [ ! -f .env ]; then
echo "创建 .env 文件..."
cp .env.example .env
echo "请编辑 .env 文件配置必要的环境变量"
exit 1
fi
# 构建镜像
echo "构建 Docker 镜像..."
docker-compose build
# 启动服务
echo "启动服务..."
docker-compose up -d
# 等待数据库就绪
echo "等待数据库就绪..."
sleep 10
# 运行数据库迁移
echo "运行数据库迁移..."
docker-compose exec backend ./epay-server migrate
echo "=== 部署完成 ==="
echo "前端访问: http://localhost"
echo "后端 API: http://localhost:8080"
```
**Step 2: 创建部署文档**
```markdown
# EPay Go 部署指南
## 环境要求
- Docker 20.10+
- Docker Compose 2.0+
- 2GB+ 内存
- 10GB+ 磁盘空间
## 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd epay-go
```
### 2. 配置环境变量
```bash
cp .env.example .env
# 编辑 .env 文件,配置必要的环境变量
```
### 3. 启动服务
```bash
# 开发环境
docker-compose up -d
# 生产环境
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
### 4. 访问服务
- 前端: http://localhost
- 后端 API: http://localhost:8080
- 管理后台: http://localhost/admin/login
- 商户中心: http://localhost/merchant/login
## 常用命令
```bash
# 查看日志
docker-compose logs -f
# 查看服务状态
docker-compose ps
# 停止服务
docker-compose down
# 重新构建
docker-compose build --no-cache
# 进入容器
docker-compose exec backend sh
docker-compose exec postgres psql -U epay
```
## 数据备份
```bash
# 备份数据库
docker-compose exec postgres pg_dump -U epay epay > backup.sql
# 恢复数据库
cat backup.sql | docker-compose exec -T postgres psql -U epay epay
```
## 更新部署
```bash
# 拉取最新代码
git pull
# 重新构建并启动
docker-compose build
docker-compose up -d
```
## 故障排查
### 后端无法连接数据库
检查 PostgreSQL 是否就绪:
```bash
docker-compose logs postgres
```
### 前端无法访问后端 API
检查 Nginx 代理配置和后端服务状态:
```bash
docker-compose logs frontend
docker-compose logs backend
```
```
**Step 3: 添加执行权限**
```bash
chmod +x scripts/deploy.sh
```
---
## Task 6: 最终验证
**Step 1: 完整构建测试**
```bash
cd epay-go
docker-compose build
```
Expected: 所有镜像构建成功
**Step 2: 启动服务测试**
```bash
docker-compose up -d
docker-compose ps
```
Expected: 所有服务状态为 Up
**Step 3: 健康检查**
```bash
# 检查后端
curl http://localhost:8080/health
# 检查前端
curl http://localhost
```
Expected: 返回正常响应
**Step 4: 清理测试环境**
```bash
docker-compose down -v
```
---
## 文件结构总览
```
epay-go/
├── Dockerfile # 后端 Dockerfile
├── .dockerignore # 后端忽略文件
├── docker-compose.yml # 开发环境编排
├── docker-compose.prod.yml # 生产环境覆盖
├── .env.example # 环境变量示例
├── DEPLOYMENT.md # 部署文档
├── scripts/
│ └── deploy.sh # 部署脚本
└── web/
├── Dockerfile # 前端 Dockerfile
├── .dockerignore # 前端忽略文件
└── nginx.conf # Nginx 配置
```
+607
View File
@@ -0,0 +1,607 @@
# EPay Go 重构设计文档
> 日期: 2026-02-07
> 状态: 待实施
## 一、项目概述
将原 PHP 版 EPay 聚合支付系统重构为 Go + Vue3 前后端分离架构,保留核心支付功能,提升安全性、性能和可维护性。
### 技术栈
| 模块 | 技术选型 |
|------|---------|
| 后端框架 | Go + Gin |
| 支付SDK | gopay (github.com/go-pay/gopay) |
| 数据库 | PostgreSQL 16 + GORM |
| 缓存/队列 | Redis 7 |
| 前端 | Vue3 + Arco Design + Vite |
| 状态管理 | Pinia |
| 认证 | JWT (admin/merchant 双token体系) |
| 部署 | Docker + docker-compose |
| 配置管理 | Viper (支持yaml/env) |
### 功能范围
**保留核心功能:**
- 商户管理(注册、登录、信息管理)
- 订单管理(创建、查询、退款)
- 支付通道管理(多渠道配置)
- 结算管理(申请、审核、打款)
- 支付 API 接口(对外提供)
**支持的支付渠道:**
- 支付宝(gopay 原生支持)
- 微信支付(gopay 原生支持)
- PayPal(gopay 原生支持)
- QQ 支付(gopay 原生支持)
- 通联支付(gopay 原生支持)
- 拉卡拉(gopay 原生支持)
---
## 二、整体架构
```
┌─────────────────────────────────────────────────────┐
│ Nginx / Traefik │
│ (反向代理 + SSL 终端) │
├──────────────────┬──────────────────────────────────┤
│ Vue3 前端 │ Go 后端 API │
│ (Arco Design) │ (Gin Framework) │
│ │ │
│ ┌────────────┐ │ ┌──────────────────────────────┐ │
│ │ 管理后台 │ │ │ API Gateway Layer │ │
│ │ (admin) │ │ │ - JWT 认证中间件 │ │
│ ├────────────┤ │ │ - 限流中间件 │ │
│ │ 商户中心 │ │ │ - 日志中间件 │ │
│ │ (merchant) │ │ │ - CORS 中间件 │ │
│ ├────────────┤ │ ├──────────────────────────────┤ │
│ │ 收银台页面 │ │ │ Business Layer │ │
│ │ (cashier) │ │ │ - 商户服务 │ │
│ └────────────┘ │ │ - 订单服务 │ │
│ │ │ - 支付服务 │ │
│ │ │ - 通道服务 │ │
│ │ │ - 结算服务 │ │
│ │ ├──────────────────────────────┤ │
│ │ │ Payment Adapter Layer │ │
│ │ │ (gopay SDK 适配层) │ │
│ │ │ - Alipay Adapter │ │
│ │ │ - WechatPay Adapter │ │
│ │ │ - PayPal Adapter │ │
│ │ │ - QQ/通联/拉卡拉 Adapter │ │
│ │ ├──────────────────────────────┤ │
│ │ │ Data Layer │ │
│ │ │ - PostgreSQL (GORM) │ │
│ │ │ - Redis (缓存+队列) │ │
│ │ └──────────────────────────────┘ │
└──────────────────┴──────────────────────────────────┘
```
**核心设计原则:**
- **分层架构**:API层 → 业务层 → 支付适配层 → 数据层,职责清晰
- **支付适配器模式**:所有支付渠道实现统一的 `PaymentAdapter` 接口,新增渠道只需实现接口
- **前后端完全分离**:后端只提供 RESTful API,前端通过 API 交互
- **Redis** 用于缓存配置、限流计数、异步通知队列
---
## 三、项目目录结构
```
epay-go/
├── cmd/
│ └── server/
│ └── main.go # 程序入口
├── internal/
│ ├── config/
│ │ └── config.go # 配置加载(Viper)
│ ├── middleware/
│ │ ├── auth.go # JWT 认证
│ │ ├── cors.go # 跨域
│ │ ├── ratelimit.go # 限流
│ │ └── logger.go # 请求日志
│ ├── model/
│ │ ├── merchant.go # 商户模型
│ │ ├── order.go # 订单模型
│ │ ├── channel.go # 支付通道模型
│ │ ├── settlement.go # 结算模型
│ │ └── record.go # 资金记录模型
│ ├── handler/
│ │ ├── admin/ # 管理后台 API
│ │ │ ├── merchant.go # 商户管理
│ │ │ ├── order.go # 订单管理
│ │ │ ├── channel.go # 通道管理
│ │ │ ├── settlement.go # 结算管理
│ │ │ └── dashboard.go # 仪表盘统计
│ │ ├── merchant/ # 商户端 API
│ │ │ ├── auth.go # 登录注册
│ │ │ ├── order.go # 订单查询
│ │ │ ├── settlement.go # 结算申请
│ │ │ └── profile.go # 商户信息
│ │ └── payment/ # 支付 API(对外)
│ │ ├── create.go # 创建订单
│ │ ├── query.go # 查询订单
│ │ ├── notify.go # 异步回调
│ │ └── refund.go # 退款
│ ├── service/
│ │ ├── merchant.go # 商户业务逻辑
│ │ ├── order.go # 订单业务逻辑
│ │ ├── payment.go # 支付核心逻辑
│ │ ├── channel.go # 通道选择逻辑
│ │ ├── settlement.go # 结算逻辑
│ │ └── notify.go # 回调通知逻辑
│ ├── repository/
│ │ ├── merchant.go # 商户数据访问
│ │ ├── order.go # 订单数据访问
│ │ ├── channel.go # 通道数据访问
│ │ └── settlement.go # 结算数据访问
│ └── payment/ # 支付适配器层
│ ├── adapter.go # 统一接口定义
│ ├── factory.go # 适配器工厂
│ ├── alipay.go # 支付宝适配器
│ ├── wechat.go # 微信支付适配器
│ ├── paypal.go # PayPal 适配器
│ ├── qqpay.go # QQ 支付适配器
│ ├── allinpay.go # 通联支付适配器
│ └── lakala.go # 拉卡拉适配器
├── pkg/
│ ├── response/
│ │ └── response.go # 统一响应格式
│ ├── sign/
│ │ └── sign.go # 签名工具
│ └── utils/
│ └── utils.go # 通用工具函数
├── migrations/
│ └── 001_init.sql # 数据库迁移
├── web/ # Vue3 前端项目
│ ├── src/
│ │ ├── views/
│ │ │ ├── admin/ # 管理后台页面
│ │ │ ├── merchant/ # 商户中心页面
│ │ │ └── cashier/ # 收银台页面
│ │ ├── api/ # API 请求封装
│ │ ├── store/ # Pinia 状态管理
│ │ ├── router/ # 路由配置
│ │ └── components/ # 公共组件
│ ├── package.json
│ └── vite.config.ts
├── docker-compose.yml
├── Dockerfile
├── go.mod
└── go.sum
```
**关键设计点:**
- `internal/` 包含所有不对外暴露的业务代码,Go 编译器强制保护
- `handler → service → repository` 三层分离
- `internal/payment/` 是支付适配器层,所有支付渠道实现统一的 `PaymentAdapter` 接口
- `web/` 是独立的 Vue3 项目,构建后可嵌入 Docker 镜像
---
## 四、数据库设计
```sql
-- 商户表
CREATE TABLE merchants (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password VARCHAR(128) NOT NULL,
email VARCHAR(128),
phone VARCHAR(20),
api_key VARCHAR(64) UNIQUE NOT NULL,
public_key TEXT,
private_key TEXT,
balance DECIMAL(12,2) DEFAULT 0,
frozen_balance DECIMAL(12,2) DEFAULT 0,
status SMALLINT DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 管理员表
CREATE TABLE admins (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password VARCHAR(128) NOT NULL,
role VARCHAR(20) DEFAULT 'admin',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 支付通道表
CREATE TABLE channels (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
plugin VARCHAR(32) NOT NULL,
pay_types VARCHAR(255),
config JSONB,
rate DECIMAL(5,4) DEFAULT 0,
daily_limit DECIMAL(12,2) DEFAULT 0,
status SMALLINT DEFAULT 1,
sort INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 订单表
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
trade_no VARCHAR(32) UNIQUE NOT NULL,
out_trade_no VARCHAR(64) NOT NULL,
merchant_id BIGINT REFERENCES merchants(id),
channel_id BIGINT REFERENCES channels(id),
pay_type VARCHAR(20),
amount DECIMAL(12,2) NOT NULL,
real_amount DECIMAL(12,2),
fee DECIMAL(12,2) DEFAULT 0,
name VARCHAR(255),
notify_url VARCHAR(512),
return_url VARCHAR(512),
api_trade_no VARCHAR(64),
buyer VARCHAR(64),
client_ip VARCHAR(45),
status SMALLINT DEFAULT 0,
notify_status SMALLINT DEFAULT 0,
notify_count INT DEFAULT 0,
next_notify_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 结算记录表
CREATE TABLE settlements (
id BIGSERIAL PRIMARY KEY,
settle_no VARCHAR(32) UNIQUE NOT NULL,
merchant_id BIGINT REFERENCES merchants(id),
amount DECIMAL(12,2) NOT NULL,
fee DECIMAL(12,2) DEFAULT 0,
actual_amount DECIMAL(12,2),
account_type VARCHAR(20),
account_no VARCHAR(64),
account_name VARCHAR(64),
status SMALLINT DEFAULT 0,
remark VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 资金变动记录表
CREATE TABLE balance_records (
id BIGSERIAL PRIMARY KEY,
merchant_id BIGINT REFERENCES merchants(id),
action SMALLINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
before_balance DECIMAL(12,2),
after_balance DECIMAL(12,2),
type VARCHAR(32),
trade_no VARCHAR(64),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 系统配置表
CREATE TABLE configs (
key VARCHAR(64) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 索引
CREATE INDEX idx_orders_merchant_id ON orders(merchant_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
CREATE INDEX idx_orders_notify_status ON orders(notify_status) WHERE notify_status < 2;
CREATE INDEX idx_settlements_merchant_id ON settlements(merchant_id);
CREATE INDEX idx_balance_records_merchant_id ON balance_records(merchant_id);
```
**相比原 epay 的改进:**
- 使用 `JSONB` 存储通道配置,灵活且可查询
- 使用 `bcrypt` 替代 `MD5+硬编码盐` 的密码方案
- 使用 `DECIMAL` 精确存储金额
- 通知重试机制内置到订单表
- 使用 PostgreSQL 的 `TIMESTAMPTZ` 处理时区问题
---
## 五、支付适配器接口设计
```go
// internal/payment/adapter.go
// PaymentAdapter 统一支付适配器接口
type PaymentAdapter interface {
// 创建支付订单,返回支付参数
CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)
// 查询订单状态
QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error)
// 退款
Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error)
// 解析异步回调通知
ParseNotify(ctx context.Context, req *http.Request) (*NotifyResult, error)
// 返回回调成功响应
NotifySuccess() string
// 转账(用于结算打款)
Transfer(ctx context.Context, req *TransferRequest) (*TransferResponse, error)
}
// CreateOrderRequest 统一下单请求
type CreateOrderRequest struct {
TradeNo string // 系统订单号
Amount decimal.Decimal // 金额
Subject string // 商品名称
ClientIP string // 客户端IP
NotifyURL string // 异步通知地址
ReturnURL string // 同步跳转地址
PayMethod string // 支付方式: scan/h5/jsapi/app/web
Extra map[string]string // 扩展参数
}
// CreateOrderResponse 统一下单响应
type CreateOrderResponse struct {
PayType string // redirect / qrcode / jsapi / form
PayURL string // 支付链接或二维码内容
PayParams string // JSAPI 支付参数(JSON)
RawResponse string // 原始响应
}
// NotifyResult 统一回调结果
type NotifyResult struct {
TradeNo string // 系统订单号
ApiTradeNo string // 上游订单号
Amount decimal.Decimal // 支付金额
Buyer string // 买家标识
Status string // success / fail
}
```
**适配器工厂:**
```go
// internal/payment/factory.go
var adapters = map[string]func(config json.RawMessage) (PaymentAdapter, error){
"alipay": NewAlipayAdapter,
"wechat": NewWechatAdapter,
"paypal": NewPayPalAdapter,
"qqpay": NewQQPayAdapter,
"allinpay": NewAllinpayAdapter,
"lakala": NewLakalaAdapter,
}
func NewAdapter(plugin string, config json.RawMessage) (PaymentAdapter, error) {
fn, ok := adapters[plugin]
if !ok {
return nil, fmt.Errorf("unsupported payment plugin: %s", plugin)
}
return fn(config)
}
```
**新增支付渠道只需3步:**
1. 在 `internal/payment/` 下新建文件,实现 `PaymentAdapter` 接口
2. 在 `factory.go` 的 `adapters` map 中注册
3. 在管理后台添加通道配置
---
## 六、API 接口设计
### 对外支付 API(供商户网站调用)
```
POST /api/pay/create # 创建支付订单
GET /api/pay/query # 查询订单状态
POST /api/pay/refund # 申请退款
POST /api/pay/notify/:channel # 支付渠道异步回调
GET /api/pay/return/:channel # 支付渠道同步跳转
GET /api/pay/cashier/:trade_no # 收银台页面数据
```
### 管理后台 API
```
POST /admin/auth/login # 管理员登录
POST /admin/auth/logout # 退出登录
GET /admin/dashboard # 仪表盘统计
GET /admin/merchants # 商户列表
GET /admin/merchants/:id # 商户详情
PUT /admin/merchants/:id # 编辑商户
PATCH /admin/merchants/:id/status # 启用/禁用
GET /admin/orders # 订单列表
GET /admin/orders/:trade_no # 订单详情
POST /admin/orders/:trade_no/refund # 退款
POST /admin/orders/:trade_no/renotify # 重发通知
GET /admin/channels # 通道列表
POST /admin/channels # 新增通道
PUT /admin/channels/:id # 编辑通道
DELETE /admin/channels/:id # 删除通道
GET /admin/settlements # 结算列表
PATCH /admin/settlements/:id/approve # 审核通过
PATCH /admin/settlements/:id/reject # 驳回
GET /admin/configs # 获取系统配置
PUT /admin/configs # 更新系统配置
```
### 商户端 API
```
POST /merchant/auth/login # 商户登录
POST /merchant/auth/register # 商户注册
POST /merchant/auth/logout # 退出登录
GET /merchant/profile # 商户信息
PUT /merchant/profile # 更新信息
PUT /merchant/profile/password # 修改密码
POST /merchant/profile/reset-key # 重置API密钥
GET /merchant/orders # 我的订单
GET /merchant/orders/:trade_no # 订单详情
GET /merchant/settlements # 结算记录
POST /merchant/settlements # 申请结算
GET /merchant/records # 资金变动记录
GET /merchant/dashboard # 商户仪表盘
```
### 统一响应格式
```json
// 成功
{ "code": 0, "msg": "success", "data": { ... } }
// 失败
{ "code": 1001, "msg": "参数错误: amount 不能为空" }
// 分页
{ "code": 0, "data": { "list": [...], "total": 100, "page": 1, "page_size": 20 } }
```
---
## 七、Docker 部署
### docker-compose.yml
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- postgres
- redis
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_DB=epay
- POSTGRES_USER=epay
- POSTGRES_PASSWORD=${DB_PASSWORD}
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
```
### Dockerfile(多阶段构建)
```dockerfile
# 阶段1: 构建 Vue 前端
FROM node:20-alpine AS frontend
WORKDIR /app/web
COPY web/ .
RUN npm ci && npm run build
# 阶段2: 构建 Go 后端
FROM golang:1.24-alpine AS backend
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o epay-server ./cmd/server
# 阶段3: 最终镜像
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=backend /app/epay-server /usr/local/bin/
COPY --from=frontend /app/web/dist /app/static
EXPOSE 8080
CMD ["epay-server"]
```
最终镜像约 **20-30MB**。
### 异步通知重试机制
Go 进程内置 Worker 协程,按策略重试:
```
第1次: 立即发送
第2次: 1分钟后
第3次: 3分钟后
第4次: 20分钟后
第5次: 1小时后
第6次: 2小时后
全部失败 → 标记通知失败,等待手动重发
```
---
## 八、核心改进(对比原 epay)
| 项目 | 原 epay (PHP) | 新系统 (Go) |
|------|--------------|-------------|
| 安全性 | MD5+硬编码盐、无JWT | bcrypt、JWT、无硬编码密钥 |
| 扩展性 | 每个渠道独立插件目录 | 统一适配器接口,3步新增渠道 |
| 性能 | PHP-FPM 单请求模型 | Go 原生高并发,数万 QPS |
| 部署 | PHP + Apache/Nginx + MySQL | Docker 单镜像 20-30MB |
| 异步任务 | 外部 cron.php | 内置 Worker 协程 |
| 代码质量 | 大量 SQL 拼接、无类型 | 类型安全、GORM 防注入 |
| API 兼容 | - | 签名协议兼容原 epay,商户可无缝迁移 |
---
## 九、实施计划
### 阶段一:基础框架(预计 2-3 天)
- [ ] 初始化 Go 项目结构
- [ ] 配置 GORM + PostgreSQL
- [ ] 配置 Redis 连接
- [ ] 实现 JWT 认证中间件
- [ ] 实现统一响应格式
### 阶段二:后端核心功能(预计 5-7 天)
- [ ] 商户模块(注册、登录、信息管理)
- [ ] 支付通道管理
- [ ] 订单模块(创建、查询)
- [ ] 支付适配器层(支付宝、微信)
- [ ] 异步回调处理
- [ ] 结算模块
### 阶段三:管理后台 API(预计 2-3 天)
- [ ] 管理员认证
- [ ] 仪表盘统计
- [ ] 商户管理 CRUD
- [ ] 订单管理
- [ ] 结算审核
### 阶段四:Vue3 前端(预计 5-7 天)
- [ ] 初始化 Vue3 + Arco Design 项目
- [ ] 管理后台页面
- [ ] 商户中心页面
- [ ] 收银台页面
### 阶段五:Docker 部署(预计 1-2 天)
- [ ] 编写 Dockerfile
- [ ] 编写 docker-compose.yml
- [ ] 测试容器化部署
**总计预估:15-22 天**
@@ -0,0 +1,864 @@
# 完整支付系统功能细化实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**目标:** 参考 epay 项目,为 epay-go 补全企业级聚合支付系统的完整功能,包括管理员管理、订单退款、统计报表、系统配置、安全增强等核心模块。
**架构:** 采用三层架构(Handler-Service-Repository),前端使用 Vue 3 + TypeScript + Arco Design,后端使用 Go + Gin + GORM + PostgreSQL。
**技术栈:** Go 1.21+, Gin, GORM, PostgreSQL, Redis, Vue 3, TypeScript, Arco Design, Vite
---
## 功能优先级划分
### 🔴 P0 - 核心业务功能(必须实现)
1. 订单退款功能
2. 管理员管理模块
3. 商户余额调整
4. 系统配置管理
### 🟡 P1 - 重要运营功能(优先实现)
5. 操作日志记录
6. 统计报表与图表
7. 数据导出功能
8. 订单补单功能
### 🟢 P2 - 增强功能(后续实现)
9. 登录日志与安全增强
10. 消息通知系统
11. API 文档生成
---
## Task 1: 订单退款功能
**文件:**
- Modify: `internal/model/order.go`
- Create: `internal/model/refund.go`
- Create: `internal/repository/refund.go`
- Create: `internal/service/refund.go`
- Create: `internal/handler/admin/refund.go`
- Create: `internal/handler/merchant/refund.go`
- Modify: `internal/router/router.go`
- Create: `web/src/views/admin/Refunds.vue`
- Create: `web/src/views/merchant/Refunds.vue`
- Modify: `web/src/api/admin.ts`
- Modify: `web/src/api/merchant.ts`
- Modify: `web/src/api/types.ts`
### 步骤 1: 创建退款数据模型
在 `internal/model/refund.go` 创建退款模型:
```go
// internal/model/refund.go
package model
import (
"time"
"gorm.io/gorm"
)
// Refund 退款订单
type Refund struct {
ID int64 `gorm:"primaryKey" json:"id"`
RefundNo string `gorm:"size:32;uniqueIndex;not null" json:"refund_no"` // 退款单号
TradeNo string `gorm:"size:32;index;not null" json:"trade_no"` // 原订单号
MerchantID int64 `gorm:"index;not null" json:"merchant_id"` // 商户ID
OrderID int64 `gorm:"index;not null" json:"order_id"` // 原订单ID
ChannelID int64 `gorm:"not null" json:"channel_id"` // 支付通道ID
Amount string `gorm:"type:decimal(10,2);not null" json:"amount"` // 退款金额
RefundFee string `gorm:"type:decimal(10,2);default:0.00" json:"refund_fee"` // 退款手续费
Reason string `gorm:"size:200" json:"reason"` // 退款原因
Status int8 `gorm:"default:0;index" json:"status"` // 状态:0待处理 1成功 2失败
ApiRefundNo string `gorm:"size:64" json:"api_refund_no"` // 第三方退款单号
FailReason string `gorm:"size:200" json:"fail_reason"` // 失败原因
NotifyURL string `gorm:"size:255" json:"notify_url"` // 异步通知地址
NotifyStatus int8 `gorm:"default:0" json:"notify_status"` // 通知状态:0未通知 1已通知
NotifyCount int `gorm:"default:0" json:"notify_count"` // 通知次数
NextNotifyTime *time.Time `json:"next_notify_time"` // 下次通知时间
ProcessedAt *time.Time `json:"processed_at"` // 处理完成时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// 退款状态常量
const (
RefundStatusPending = 0 // 待处理
RefundStatusSuccess = 1 // 成功
RefundStatusFailed = 2 // 失败
)
// TableName 指定表名
func (Refund) TableName() string {
return "refunds"
}
```
### 步骤 2: 添加退款表迁移
在 `internal/database/migrate.go` 的 `Migrate()` 函数中添加:
```go
&model.Refund{},
```
### 步骤 3: 创建退款 Repository
在 `internal/repository/refund.go` 创建:
```go
// internal/repository/refund.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
)
type RefundRepository struct {
db *gorm.DB
}
func NewRefundRepository() *RefundRepository {
return &RefundRepository{db: database.Get()}
}
// Create 创建退款单
func (r *RefundRepository) Create(refund *model.Refund) error {
return r.db.Create(refund).Error
}
// GetByRefundNo 根据退款单号查询
func (r *RefundRepository) GetByRefundNo(refundNo string) (*model.Refund, error) {
var refund model.Refund
err := r.db.Where("refund_no = ?", refundNo).First(&refund).Error
return &refund, err
}
// GetByTradeNo 根据订单号查询退款记录
func (r *RefundRepository) GetByTradeNo(tradeNo string) ([]*model.Refund, error) {
var refunds []*model.Refund
err := r.db.Where("trade_no = ?", tradeNo).Find(&refunds).Error
return refunds, err
}
// List 分页查询退款列表
func (r *RefundRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
var refunds []*model.Refund
var total int64
query := r.db.Model(&model.Refund{})
if merchantID != nil {
query = query.Where("merchant_id = ?", *merchantID)
}
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Order("id DESC").Limit(pageSize).Offset(offset).Find(&refunds).Error
return refunds, total, err
}
// Update 更新退款单
func (r *RefundRepository) Update(refund *model.Refund) error {
return r.db.Save(refund).Error
}
```
### 步骤 4: 创建退款 Service
在 `internal/service/refund.go` 创建:
```go
// internal/service/refund.go
package service
import (
"errors"
"time"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type RefundService struct {
refundRepo *repository.RefundRepository
orderRepo *repository.OrderRepository
merchantRepo *repository.MerchantRepository
recordRepo *repository.BalanceRecordRepository
}
func NewRefundService() *RefundService {
return &RefundService{
refundRepo: repository.NewRefundRepository(),
orderRepo: repository.NewOrderRepository(),
merchantRepo: repository.NewMerchantRepository(),
recordRepo: repository.NewBalanceRecordRepository(),
}
}
// CreateRefundRequest 创建退款请求
type CreateRefundRequest struct {
TradeNo string `json:"trade_no" binding:"required"`
Amount string `json:"amount" binding:"required"`
Reason string `json:"reason"`
NotifyURL string `json:"notify_url"`
}
// CreateRefund 创建退款单
func (s *RefundService) CreateRefund(merchantID int64, req *CreateRefundRequest) (*model.Refund, error) {
// 查询原订单
order, err := s.orderRepo.GetByTradeNo(req.TradeNo)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
return nil, err
}
// 验证商户权限
if order.MerchantID != merchantID {
return nil, errors.New("无权操作此订单")
}
// 验证订单状态
if order.Status != model.OrderStatusPaid {
return nil, errors.New("订单未支付,无法退款")
}
// 验证退款金额
refundAmount, err := decimal.NewFromString(req.Amount)
if err != nil {
return nil, errors.New("退款金额格式错误")
}
orderAmount, _ := decimal.NewFromString(order.Amount)
if refundAmount.GreaterThan(orderAmount) {
return nil, errors.New("退款金额不能大于订单金额")
}
// 检查是否已有退款记录
existingRefunds, err := s.refundRepo.GetByTradeNo(req.TradeNo)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// 计算已退款总额
totalRefunded := decimal.Zero
for _, r := range existingRefunds {
if r.Status == model.RefundStatusSuccess {
amt, _ := decimal.NewFromString(r.Amount)
totalRefunded = totalRefunded.Add(amt)
}
}
if totalRefunded.Add(refundAmount).GreaterThan(orderAmount) {
return nil, errors.New("退款总额超过订单金额")
}
// 创建退款单
refund := &model.Refund{
RefundNo: utils.GenerateOrderNo("R"),
TradeNo: order.TradeNo,
MerchantID: order.MerchantID,
OrderID: order.ID,
ChannelID: order.ChannelID,
Amount: req.Amount,
RefundFee: "0.00",
Reason: req.Reason,
Status: model.RefundStatusPending,
NotifyURL: req.NotifyURL,
}
if err := s.refundRepo.Create(refund); err != nil {
return nil, err
}
return refund, nil
}
// ProcessRefund 处理退款(管理员审核)
func (s *RefundService) ProcessRefund(refundNo string, success bool, failReason string) error {
refund, err := s.refundRepo.GetByRefundNo(refundNo)
if err != nil {
return err
}
if refund.Status != model.RefundStatusPending {
return errors.New("退款单已处理")
}
now := time.Now()
if success {
refund.Status = model.RefundStatusSuccess
refund.ProcessedAt = &now
// 退款到商户余额
amount, _ := decimal.NewFromString(refund.Amount)
merchant, err := s.merchantRepo.GetByID(refund.MerchantID)
if err != nil {
return err
}
beforeBalance, _ := decimal.NewFromString(merchant.Balance)
afterBalance := beforeBalance.Add(amount)
if err := s.merchantRepo.UpdateBalance(refund.MerchantID, afterBalance.String()); err != nil {
return err
}
// 记录资金变动
record := &model.BalanceRecord{
MerchantID: refund.MerchantID,
Action: 1, // 增加
Amount: refund.Amount,
BeforeBalance: beforeBalance.String(),
AfterBalance: afterBalance.String(),
Type: "refund",
TradeNo: refund.RefundNo,
}
s.recordRepo.Create(record)
} else {
refund.Status = model.RefundStatusFailed
refund.FailReason = failReason
refund.ProcessedAt = &now
}
return s.refundRepo.Update(refund)
}
// GetRefundByNo 根据退款单号查询
func (s *RefundService) GetRefundByNo(refundNo string) (*model.Refund, error) {
return s.refundRepo.GetByRefundNo(refundNo)
}
// List 退款列表
func (s *RefundService) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
return s.refundRepo.List(page, pageSize, merchantID, status)
}
```
### 步骤 5: 创建管理后台退款 Handler
在 `internal/handler/admin/refund.go` 创建:
```go
// internal/handler/admin/refund.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var merchantID *int64
if mid := c.Query("merchant_id"); mid != "" {
v, _ := strconv.ParseInt(mid, 10, 64)
merchantID = &v
}
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
// ProcessRefundRequest 处理退款请求
type ProcessRefundRequest struct {
Success bool `json:"success" binding:"required"`
FailReason string `json:"fail_reason"`
}
// ProcessRefund 处理退款
func ProcessRefund(c *gin.Context) {
refundNo := c.Param("refund_no")
var req ProcessRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
refundService := service.NewRefundService()
if err := refundService.ProcessRefund(refundNo, req.Success, req.FailReason); err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, nil)
}
```
### 步骤 6: 创建商户端退款 Handler
在 `internal/handler/merchant/refund.go` 创建:
```go
// internal/handler/merchant/refund.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// CreateRefund 创建退款申请
func CreateRefund(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req service.CreateRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
refundService := service.NewRefundService()
refund, err := refundService.CreateRefund(merchantID, &req)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, refund)
}
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, &merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
```
### 步骤 7: 注册路由
在 `internal/router/router.go` 添加:
```go
// 管理后台 - 退款管理
adminGroup.GET("/refunds", admin.ListRefunds)
adminGroup.POST("/refunds/:refund_no/process", admin.ProcessRefund)
// 商户中心 - 退款管理
merchantGroup.POST("/refunds", merchant.CreateRefund)
merchantGroup.GET("/refunds", merchant.ListRefunds)
```
### 步骤 8: 添加前端类型定义
在 `web/src/api/types.ts` 添加:
```typescript
export interface Refund {
id: number
refund_no: string
trade_no: string
merchant_id: number
order_id: number
channel_id: number
amount: string
refund_fee: string
reason: string
status: number // 0待处理 1成功 2失败
api_refund_no: string
fail_reason: string
notify_url: string
notify_status: number
notify_count: number
next_notify_time: string | null
processed_at: string | null
created_at: string
}
```
### 步骤 9: 添加管理后台 API
在 `web/src/api/admin.ts` 添加:
```typescript
// 退款管理
export const getRefunds = (params: any) =>
request.get<{ list: Refund[]; total: number }>('/admin/refunds', { params })
export const processRefund = (refundNo: string, data: { success: boolean; fail_reason?: string }) =>
request.post(`/admin/refunds/${refundNo}/process`, data)
```
### 步骤 10: 添加商户端 API
在 `web/src/api/merchant.ts` 添加:
```typescript
// 退款管理
export const createRefund = (data: {
trade_no: string
amount: string
reason?: string
notify_url?: string
}) => request.post('/merchant/refunds', data)
export const getRefunds = (params: any) =>
request.get<{ list: Refund[]; total: number }>('/merchant/refunds', { params })
```
### 步骤 11: 创建管理后台退款页面
创建 `web/src/views/admin/Refunds.vue`
### 步骤 12: 创建商户端退款页面
创建 `web/src/views/merchant/Refunds.vue`
### 步骤 13: 测试退款功能
```bash
# 重新构建后端
docker-compose build backend
docker-compose up -d backend
# 重启前端
cd web && npm run dev
```
测试步骤:
1. 商户端创建退款申请
2. 管理后台审核退款
3. 验证商户余额变动
4. 验证资金记录
### 步骤 14: 提交代码
```bash
git add .
git commit -m "feat: add refund functionality
- Add Refund model and database migration
- Implement refund repository, service, and handlers
- Add admin refund approval workflow
- Add merchant refund application
- Refund amount validation and balance refund
- Add frontend refund management pages
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 2: 管理员管理模块
**文件:**
- Modify: `internal/model/admin.go`
- Create: `internal/service/admin_manage.go`
- Create: `internal/handler/admin/admin_manage.go`
- Modify: `internal/router/router.go`
- Create: `web/src/views/admin/Admins.vue`
### 步骤 1: 扩展管理员模型
在 `internal/model/admin.go` 添加字段:
```go
Email string `gorm:"size:100" json:"email"`
Status int8 `gorm:"default:1" json:"status"` // 状态:0禁用 1启用
```
### 步骤 2: 创建管理员管理 Service
在 `internal/service/admin_manage.go` 实现管理员的增删改查功能。
### 步骤 3: 创建管理员管理 Handler
### 步骤 4: 实现前端页面
### 步骤 5: 测试并提交
---
## Task 3: 商户余额调整
**文件:**
- Create: `internal/handler/admin/balance.go`
- Modify: `web/src/views/admin/Merchants.vue`
### 步骤 1: 创建余额调整 Handler
实现后台手动增加/减少商户余额的功能。
### 步骤 2: 添加资金变动记录
记录所有余额变动操作。
### 步骤 3: 实现前端界面
在商户管理页面添加余额调整按钮和对话框。
### 步骤 4: 测试并提交
---
## Task 4: 系统配置管理
**文件:**
- Create: `internal/service/config.go`
- Create: `internal/handler/admin/config.go`
- Create: `web/src/views/admin/Settings.vue`
### 步骤 1: 实现配置 Service
支持系统参数的读取和更新。
### 步骤 2: 定义配置项
- 站点名称
- 结算手续费率
- 异步通知重试次数
- 异步通知间隔时间
- 商户注册开关
- 测试模式开关
### 步骤 3: 实现前端配置页面
### 步骤 4: 测试并提交
---
## Task 5: 操作日志记录
**文件:**
- Create: `internal/model/log.go`
- Create: `internal/repository/log.go`
- Create: `internal/service/log.go`
- Create: `internal/middleware/logger.go`
- Create: `internal/handler/admin/log.go`
- Create: `web/src/views/admin/Logs.vue`
### 步骤 1: 创建日志模型
### 步骤 2: 实现日志中间件
自动记录管理员和商户的重要操作。
### 步骤 3: 实现日志查询界面
### 步骤 4: 测试并提交
---
## Task 6: 统计报表与图表
**文件:**
- Create: `internal/service/statistics.go`
- Create: `internal/handler/admin/statistics.go`
- Modify: `web/src/views/admin/Dashboard.vue`
- Modify: `web/src/views/merchant/Dashboard.vue`
### 步骤 1: 实现统计 Service
- 按日期统计订单
- 按通道统计订单
- 按商户统计订单
- 收入趋势统计
### 步骤 2: 安装图表库
```bash
cd web
npm install @arco-design/web-vue echarts
```
### 步骤 3: 实现仪表盘图表
- 订单趋势折线图
- 收入统计柱状图
- 通道占比饼图
- 商户排行榜
### 步骤 4: 测试并提交
---
## Task 7: 数据导出功能
**文件:**
- Create: `internal/handler/admin/export.go`
- Modify: `web/src/views/admin/Orders.vue`
### 步骤 1: 安装 Excel 库
```bash
go get github.com/xuri/excelize/v2
```
### 步骤 2: 实现订单导出
支持导出 Excel 格式的订单数据。
### 步骤 3: 添加导出按钮
### 步骤 4: 测试并提交
---
## Task 8: 订单补单功能
**文件:**
- Create: `internal/handler/admin/order_repair.go`
- Modify: `web/src/views/admin/Orders.vue`
### 步骤 1: 实现补单逻辑
手动标记订单为已支付,并触发异步通知。
### 步骤 2: 添加权限验证
只有超级管理员可以补单。
### 步骤 3: 实现前端界面
### 步骤 4: 测试并提交
---
## Task 9: 登录日志与安全增强
**文件:**
- Create: `internal/model/login_log.go`
- Create: `internal/middleware/rate_limit.go`
- Create: `internal/middleware/ip_whitelist.go`
### 步骤 1: 记录登录日志
记录登录时间、IP、设备信息、登录结果。
### 步骤 2: 实现 IP 白名单
### 步骤 3: 实现频率限制
防止暴力破解。
### 步骤 4: 测试并提交
---
## Task 10: 消息通知系统
**文件:**
- Create: `internal/service/notification.go`
- Create: `pkg/mail/sender.go`
- Create: `pkg/sms/sender.go`
### 步骤 1: 实现邮件通知
使用 SMTP 发送邮件。
### 步骤 2: 实现短信通知
集成短信服务商 API。
### 步骤 3: 定义通知场景
- 订单支付成功
- 结算审核通过
- 余额不足提醒
### 步骤 4: 测试并提交
---
## Task 11: API 文档生成
**文件:**
- 安装 Swagger
- 添加 API 注释
### 步骤 1: 安装 Swagger
```bash
go install github.com/swaggo/swag/cmd/swag@latest
```
### 步骤 2: 添加 Swagger 注释
为所有 Handler 添加注释。
### 步骤 3: 生成文档
```bash
swag init -g cmd/server/main.go
```
### 步骤 4: 访问文档
http://localhost:8099/swagger/index.html
### 步骤 5: 提交代码
---
## 总结
本计划涵盖了 11 个核心功能模块,完整参考了 epay 项目的功能设计,将使 epay-go 成为一个功能完善的企业级聚合支付系统。
**实施建议:**
1. 按优先级顺序实施(P0 → P1 → P2)
2. 每个 Task 完成后进行充分测试
3. 保持代码质量和架构一致性
4. 及时更新文档
**预计工作量:**
- P0 功能:2-3 周
- P1 功能:2 周
- P2 功能:1-2 周
总计:5-7 周可完成全部功能细化。
@@ -0,0 +1,753 @@
# 支付通道配置优化实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**目标:** 参考 epay 项目的插件化配置机制,为 epay-go 实现动态通道配置功能,不同支付类型自动显示对应的配置字段(appid、appsecret、商户号等),并支持在线测试支付。
**架构:** 采用配置驱动的方式,每种支付插件定义自己的配置模板,前端根据选择的插件动态渲染表单,后端使用 JSON 存储配置参数。
**技术栈:** Go 1.21+, Gin, GORM, PostgreSQL, Vue 3, TypeScript, Arco Design
---
## 核心设计思路
### epay 项目的优秀设计
根据探索发现,epay 采用了以下机制:
1. **插件化配置定义**:每个支付插件通过 `$info['inputs']` 定义所需参数
2. **动态表单生成**:前端根据插件配置自动生成表单字段
3. **JSON 配置存储**:所有配置以 JSON 格式保存到数据库
4. **多接口支持**:单个插件可支持多种支付接口(扫码、H5、APP 等)
5. **绑定功能**:支持绑定微信公众号/小程序
### epay-go 实现方案
我们将在 Go 项目中实现类似机制:
1. **定义配置模板**:在代码中定义每种支付类型的配置字段
2. **扩展 Channel 模型**:添加 `AppType`(已启用接口)字段
3. **实现配置验证**:后端验证配置完整性
4. **前端动态表单**:根据选择的插件类型动态显示配置字段
5. **测试支付功能**:管理员可在配置后立即测试
---
## Task 1: 定义支付插件配置模板
**文件:**
- Create: `internal/payment/plugin_config.go`
### 步骤 1: 创建配置模板结构
```go
// internal/payment/plugin_config.go
package payment
// PluginConfigField 配置字段定义
type PluginConfigField struct {
Key string `json:"key"` // 字段键名
Name string `json:"name"` // 显示名称
Type string `json:"type"` // 类型: input/textarea/select/checkbox
Required bool `json:"required"` // 是否必填
Placeholder string `json:"placeholder"` // 占位符
Note string `json:"note"` // 说明文字
Options map[string]string `json:"options"` // 下拉选项(type=select时使用)
}
// PluginConfig 插件配置信息
type PluginConfig struct {
Name string `json:"name"` // 插件英文名
ShowName string `json:"show_name"` // 显示名称
Author string `json:"author"` // 作者
Link string `json:"link"` // 官方链接
Inputs []PluginConfigField `json:"inputs"` // 配置字段
PayTypes []PayTypeOption `json:"pay_types"` // 支持的支付接口
BindWxmp bool `json:"bind_wxmp"` // 是否绑定微信公众号
BindWxa bool `json:"bind_wxa"` // 是否绑定微信小程序
Note string `json:"note"` // 配置说明
}
// PayTypeOption 支付接口选项
type PayTypeOption struct {
Code string `json:"code"` // 接口代码
Name string `json:"name"` // 接口名称
}
// GetPluginConfigs 获取所有插件配置模板
func GetPluginConfigs() map[string]PluginConfig {
return map[string]PluginConfig{
"alipay": GetAlipayConfig(),
"wechat": GetWechatConfig(),
}
}
// GetAlipayConfig 支付宝配置模板
func GetAlipayConfig() PluginConfig {
return PluginConfig{
Name: "alipay",
ShowName: "支付宝官方支付",
Author: "支付宝",
Link: "https://open.alipay.com",
Inputs: []PluginConfigField{
{
Key: "appid",
Name: "应用APPID",
Type: "input",
Required: true,
Placeholder: "请输入支付宝开放平台应用ID",
},
{
Key: "app_private_key",
Name: "应用私钥",
Type: "textarea",
Required: true,
Placeholder: "请输入RSA2私钥(PKCS1或PKCS8格式)",
Note: "用于对请求参数进行签名",
},
{
Key: "alipay_public_key",
Name: "支付宝公钥",
Type: "textarea",
Required: false,
Placeholder: "请输入支付宝公钥",
Note: "用于验证回调签名,使用公钥证书模式可留空",
},
{
Key: "seller_id",
Name: "卖家支付宝用户ID",
Type: "input",
Required: false,
Placeholder: "可留空,默认为签约账号",
},
{
Key: "cert_mode",
Name: "证书模式",
Type: "select",
Required: true,
Options: map[string]string{
"public_key": "公钥模式",
"cert": "公钥证书模式",
},
},
},
PayTypes: []PayTypeOption{
{Code: "page", Name: "电脑网站支付"},
{Code: "wap", Name: "手机网站支付"},
{Code: "qrcode", Name: "当面付扫码"},
{Code: "jsapi", Name: "当面付JS"},
{Code: "app", Name: "APP支付"},
},
Note: `
选择可用的支付接口,只能选择已经签约的产品。
如果使用公钥证书模式,需将应用公钥证书、支付宝公钥证书、
支付宝根证书3个crt文件放置于 /certs/alipay/ 目录。
`,
}
}
// GetWechatConfig 微信支付配置模板
func GetWechatConfig() PluginConfig {
return PluginConfig{
Name: "wechat",
ShowName: "微信官方支付",
Author: "微信支付",
Link: "https://pay.weixin.qq.com",
Inputs: []PluginConfigField{
{
Key: "appid",
Name: "公众号/小程序/开放平台AppID",
Type: "input",
Required: true,
Placeholder: "请输入微信应用标识",
},
{
Key: "mch_id",
Name: "商户号",
Type: "input",
Required: true,
Placeholder: "请输入微信支付商户号",
},
{
Key: "api_key",
Name: "商户API密钥",
Type: "input",
Required: true,
Placeholder: "请输入APIv2密钥(32位)",
Note: "在微信商户平台设置",
},
{
Key: "apiv3_key",
Name: "APIv3密钥",
Type: "input",
Required: false,
Placeholder: "请输入APIv3密钥(32位)",
Note: "仅部分新接口需要",
},
{
Key: "cert_serial_no",
Name: "证书序列号",
Type: "input",
Required: false,
Placeholder: "商户API证书序列号",
Note: "如需退款功能,需填写此项",
},
},
PayTypes: []PayTypeOption{
{Code: "native", Name: "扫码支付"},
{Code: "jsapi", Name: "公众号支付"},
{Code: "h5", Name: "H5支付"},
{Code: "miniapp", Name: "小程序支付"},
{Code: "app", Name: "APP支付"},
},
BindWxmp: true,
BindWxa: true,
Note: "如需退款功能,需将API证书文件上传至 /certs/wechat/ 目录",
}
}
```
### 步骤 2: 创建获取配置接口
在 `internal/handler/admin/channel.go` 添加:
```go
// GetPluginConfig 获取插件配置模板
func GetPluginConfig(c *gin.Context) {
pluginName := c.Param("plugin")
configs := payment.GetPluginConfigs()
config, exists := configs[pluginName]
if !exists {
response.NotFound(c, "插件不存在")
return
}
response.Success(c, config)
}
// GetAllPluginConfigs 获取所有插件配置列表
func GetAllPluginConfigs(c *gin.Context) {
configs := payment.GetPluginConfigs()
// 转换为列表
list := make([]map[string]interface{}, 0)
for _, cfg := range configs {
list = append(list, map[string]interface{}{
"name": cfg.Name,
"show_name": cfg.ShowName,
"author": cfg.Author,
})
}
response.Success(c, list)
}
```
### 步骤 3: 注册路由
在 `internal/router/router.go` 添加:
```go
// 获取插件配置
adminGroup.GET("/plugins", admin.GetAllPluginConfigs)
adminGroup.GET("/plugins/:plugin/config", admin.GetPluginConfig)
```
### 步骤 4: 测试配置接口
```bash
# 获取所有插件列表
curl http://localhost:8099/admin/plugins
# 获取支付宝配置模板
curl http://localhost:8099/admin/plugins/alipay/config
# 获取微信配置模板
curl http://localhost:8099/admin/plugins/wechat/config
```
### 步骤 5: 提交代码
```bash
git add internal/payment/plugin_config.go internal/handler/admin/channel.go internal/router/router.go
git commit -m "feat: add payment plugin configuration templates
- Define plugin config structure with dynamic fields
- Implement Alipay and WeChat config templates
- Add API endpoints to get plugin configs
- Support multiple payment interfaces per plugin
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 2: 扩展 Channel 模型支持多接口
**文件:**
- Modify: `internal/model/channel.go`
- Modify: `internal/repository/channel.go`
- Modify: `internal/service/channel.go`
### 步骤 1: 扩展 Channel 模型
在 `internal/model/channel.go` 添加字段:
```go
AppType string `gorm:"size:100" json:"app_type"` // 已启用的支付接口(逗号分隔,如"page,wap,qrcode")
```
### 步骤 2: 数据库迁移
运行应用,GORM 会自动添加新字段。
### 步骤 3: 更新 Repository
在 `internal/repository/channel.go` 的 `Create` 和 `Update` 方法中支持 `AppType` 字段。
### 步骤 4: 更新 Service
在 `internal/service/channel.go` 中添加 `AppType` 验证逻辑:
```go
// CreateChannelRequest 添加字段
type CreateChannelRequest struct {
// ... 现有字段
AppType string `json:"app_type"` // 支持的接口类型
}
// 验证 AppType 不能为空
if req.AppType == "" {
return nil, errors.New("请至少选择一个支付接口")
}
```
### 步骤 5: 测试并提交
```bash
git add internal/model/channel.go internal/repository/channel.go internal/service/channel.go
git commit -m "feat: extend channel model to support multiple payment interfaces
- Add app_type field to store enabled interfaces
- Validate app_type is not empty when creating channel
- Support comma-separated interface codes
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 3: 实现前端动态配置表单
**文件:**
- Modify: `web/src/views/admin/Channels.vue`
- Modify: `web/src/api/admin.ts`
- Modify: `web/src/api/types.ts`
### 步骤 1: 添加类型定义
在 `web/src/api/types.ts` 添加:
```typescript
export interface PluginConfigField {
key: string
name: string
type: 'input' | 'textarea' | 'select' | 'checkbox'
required: boolean
placeholder: string
note: string
options?: Record<string, string>
}
export interface PayTypeOption {
code: string
name: string
}
export interface PluginConfig {
name: string
show_name: string
author: string
link: string
inputs: PluginConfigField[]
pay_types: PayTypeOption[]
bind_wxmp: boolean
bind_wxa: boolean
note: string
}
```
### 步骤 2: 添加 API 方法
在 `web/src/api/admin.ts` 添加:
```typescript
// 获取所有插件
export const getPlugins = () =>
request.get<{ name: string; show_name: string; author: string }[]>('/admin/plugins')
// 获取插件配置模板
export const getPluginConfig = (plugin: string) =>
request.get<PluginConfig>(`/admin/plugins/${plugin}/config`)
```
### 步骤 3: 修改 Channels.vue 实现动态表单
```vue
<template>
<div class="channels-page">
<!-- ... 现有的列表部分 ... -->
<!-- 创建/编辑通道对话框 -->
<a-modal
v-model:visible="modalVisible"
:title="editingId ? '编辑通道' : '新建通道'"
@ok="handleSubmit"
@cancel="handleCancel"
width="700px"
>
<a-form :model="form" layout="vertical">
<a-form-item field="name" label="通道名称" required>
<a-input v-model="form.name" placeholder="如: 支付宝扫码支付" />
</a-form-item>
<a-form-item field="plugin" label="支付插件" required>
<a-select
v-model="form.plugin"
placeholder="请选择支付插件"
@change="handlePluginChange"
>
<a-option
v-for="plugin in plugins"
:key="plugin.name"
:value="plugin.name"
>
{{ plugin.show_name }}
</a-option>
</a-select>
</a-form-item>
<!-- 动态配置字段 -->
<template v-if="currentPluginConfig">
<a-divider>支付配置</a-divider>
<a-form-item
v-for="field in currentPluginConfig.inputs"
:key="field.key"
:field="`config.${field.key}`"
:label="field.name"
:required="field.required"
>
<!-- 普通输入框 -->
<a-input
v-if="field.type === 'input'"
v-model="form.config[field.key]"
:placeholder="field.placeholder"
/>
<!-- 多行文本框 -->
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="form.config[field.key]"
:placeholder="field.placeholder"
:rows="4"
/>
<!-- 下拉选择 -->
<a-select
v-else-if="field.type === 'select'"
v-model="form.config[field.key]"
:placeholder="field.placeholder"
>
<a-option
v-for="(label, value) in field.options"
:key="value"
:value="value"
>
{{ label }}
</a-option>
</a-select>
<template v-if="field.note" #extra>
<div style="color: #86909c; font-size: 12px">{{ field.note }}</div>
</template>
</a-form-item>
<a-divider>支付接口</a-divider>
<a-form-item label="支持的支付接口" required>
<a-checkbox-group v-model="form.app_types">
<a-checkbox
v-for="payType in currentPluginConfig.pay_types"
:key="payType.code"
:value="payType.code"
>
{{ payType.name }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-alert v-if="currentPluginConfig.note" type="info">
{{ currentPluginConfig.note }}
</a-alert>
</template>
<a-divider>费率设置</a-divider>
<a-form-item field="fee_rate" label="费率(%)" required>
<a-input-number
v-model="form.fee_rate"
:precision="2"
:min="0"
:max="100"
placeholder="如: 0.6"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import {
getChannels,
createChannel,
updateChannel,
toggleChannelStatus,
getPlugins,
getPluginConfig,
} from '@/api/admin'
import type { Channel, PluginConfig } from '@/api/types'
const plugins = ref<{ name: string; show_name: string }[]>([])
const currentPluginConfig = ref<PluginConfig | null>(null)
const form = reactive({
name: '',
plugin: '',
fee_rate: 0,
config: {} as Record<string, string>,
app_types: [] as string[],
})
// 加载插件列表
const loadPlugins = async () => {
try {
const res = await getPlugins()
plugins.value = res.data
} catch (e) {
// error handled
}
}
// 插件切换时加载配置模板
const handlePluginChange = async (plugin: string) => {
try {
const res = await getPluginConfig(plugin)
currentPluginConfig.value = res.data
// 重置配置
form.config = {}
form.app_types = []
} catch (e) {
Message.error('加载插件配置失败')
}
}
// 提交时转换 app_types 为逗号分隔字符串
const handleSubmit = async () => {
try {
if (form.app_types.length === 0) {
Message.warning('请至少选择一个支付接口')
return
}
const data = {
...form,
app_type: form.app_types.join(','),
config: form.config,
}
if (editingId.value) {
await updateChannel(editingId.value, data)
Message.success('更新成功')
} else {
await createChannel(data)
Message.success('创建成功')
}
modalVisible.value = false
loadChannels()
} catch (e) {
// error handled
}
}
onMounted(() => {
loadChannels()
loadPlugins()
})
</script>
```
### 步骤 4: 测试动态表单
1. 访问 http://localhost:8090/admin/channels
2. 点击"新建通道"
3. 选择"支付宝官方支付",查看动态显示的配置字段
4. 切换到"微信官方支付",查看字段变化
5. 填写配置并提交
### 步骤 5: 提交代码
```bash
git add web/src/views/admin/Channels.vue web/src/api/admin.ts web/src/api/types.ts
git commit -m "feat: implement dynamic channel configuration form
- Add plugin config types and API methods
- Implement dynamic form field rendering
- Support different config fields per plugin
- Add payment interface checkbox selection
- Display plugin notes and field hints
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## Task 4: 实现测试支付功能
**文件:**
- Create: `internal/handler/admin/test_payment.go`
- Create: `web/src/views/admin/TestPayment.vue`
- Modify: `internal/router/router.go`
### 步骤 1: 创建测试支付 Handler
```go
// internal/handler/admin/test_payment.go
package admin
import (
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// TestPaymentRequest 测试支付请求
type TestPaymentRequest struct {
ChannelID int64 `json:"channel_id" binding:"required"`
Amount string `json:"amount" binding:"required"`
PayType string `json:"pay_type" binding:"required"`
}
// TestPayment 测试支付
func TestPayment(c *gin.Context) {
var req TestPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
// 创建测试订单
orderService := service.NewOrderService()
order, payData, err := orderService.CreateTestOrder(req.ChannelID, req.Amount, req.PayType)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, gin.H{
"order": order,
"pay_data": payData,
})
}
```
### 步骤 2: 在 OrderService 中实现测试订单
```go
// CreateTestOrder 创建测试订单
func (s *OrderService) CreateTestOrder(channelID int64, amount, payType string) (*model.Order, interface{}, error) {
// 获取通道信息
channel, err := s.channelRepo.GetByID(channelID)
if err != nil {
return nil, nil, errors.New("通道不存在")
}
// 创建测试订单(商户ID为0表示测试)
order := &model.Order{
TradeNo: utils.GenerateOrderNo("TEST"),
OutTradeNo: "TEST" + time.Now().Format("20060102150405"),
MerchantID: 0, // 测试订单
ChannelID: channelID,
PayType: payType,
Amount: amount,
Name: "测试支付",
NotifyURL: "",
ReturnURL: "",
Status: model.OrderStatusPending,
}
if err := s.orderRepo.Create(order); err != nil {
return nil, nil, err
}
// 调用支付接口
payData, err := s.callPaymentGateway(order, channel)
if err != nil {
return nil, nil, err
}
return order, payData, nil
}
```
### 步骤 3: 创建测试支付页面
创建 `web/src/views/admin/TestPayment.vue`(简化版)
### 步骤 4: 注册路由
```go
adminGroup.POST("/test-payment", admin.TestPayment)
```
### 步骤 5: 测试并提交
```bash
git add internal/handler/admin/test_payment.go internal/service/order.go internal/router/router.go web/src/views/admin/TestPayment.vue
git commit -m "feat: add test payment functionality
- Implement test payment API endpoint
- Create test orders with merchant_id=0
- Support testing different payment interfaces
- Add test payment frontend page
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
---
## 总结
本计划实现了以下核心功能:
1. ✅ **插件配置模板** - 定义支付宝、微信等插件的配置字段
2. ✅ **动态表单生成** - 前端根据选择的插件自动显示对应配置字段
3. ✅ **多接口支持** - 单个通道支持多种支付方式(扫码、H5、APP 等)
4. ✅ **配置验证** - 后端验证配置完整性和格式
5. ✅ **测试支付** - 管理员可在配置后立即测试支付功能
**参考 epay 项目的优秀设计:**
- 插件化架构
- 配置驱动的动态表单
- JSON 格式存储配置
- 支持证书模式和多接口
**预计工作量:** 2-3 天完成全部功能
+71
View File
@@ -0,0 +1,71 @@
module github.com/example/epay-go
go 1.25.1
require (
github.com/gin-gonic/gin v1.11.0
github.com/go-pay/gopay v1.5.115
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/redis/go-redis/v9 v9.17.3
github.com/shopspring/decimal v1.4.0
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.40.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-pay/crypto v0.0.1 // indirect
github.com/go-pay/errgroup v0.0.3 // indirect
github.com/go-pay/smap v0.0.2 // indirect
github.com/go-pay/util v0.0.4 // indirect
github.com/go-pay/xlog v0.0.3 // indirect
github.com/go-pay/xtime v0.0.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+163
View File
@@ -0,0 +1,163 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-pay/crypto v0.0.1 h1:B6InT8CLfSLc6nGRVx9VMJRBBazFMjr293+jl0lLXUY=
github.com/go-pay/crypto v0.0.1/go.mod h1:41oEIvHMKbNcYlWUlRWtsnC6+ASgh7u29z0gJXe5bes=
github.com/go-pay/errgroup v0.0.3 h1:DB4s8e8oWYDyETKQ1y1riMJ7y29zE1uIsMCSjEOFSbU=
github.com/go-pay/errgroup v0.0.3/go.mod h1:0+4b8mvFMS71MIzsaC+gVvB4x37I93lRb2dqrwuU8x8=
github.com/go-pay/gopay v1.5.115 h1:8WjWftPChKCiVt5Qz2xLqXeUdidsR+y9/R2S/7Q9szc=
github.com/go-pay/gopay v1.5.115/go.mod h1:p48xvWeepPolZuakAjCeucWynWwW7msoXsqahcoJpKE=
github.com/go-pay/smap v0.0.2 h1:kKflYor5T5FgZltPFBMTFfjJvqYMHr5VnIFSEyhVTcA=
github.com/go-pay/smap v0.0.2/go.mod h1:HW9oAo0okuyDYsbpbj5fJFxnNj/BZorRGFw26SxrNWw=
github.com/go-pay/util v0.0.4 h1:TuwSU9o3Qd7m9v1PbzFuIA/8uO9FJnA6P7neG/NwPyk=
github.com/go-pay/util v0.0.4/go.mod h1:Tsdhs8Ib9J9b4+NKNO1PHh5hWHhlg98PthsX0ckq6PM=
github.com/go-pay/xlog v0.0.3 h1:avyMhCL/JgBHreoGx/am/kHxfs1udDOAeVqbmzP/Yes=
github.com/go-pay/xlog v0.0.3/go.mod h1:mH47xbobrdsSHWsmFtSF5agWbMHFP+tK0ZbVCk5OAEw=
github.com/go-pay/xtime v0.0.2 h1:7YR4/iuELsEHpJ6LUO0SVK80hQxDO9MLCfuVYIiTCRM=
github.com/go-pay/xtime v0.0.2/go.mod h1:W1yRbJaSt4CSBcdAtLBQ8xajiN/Pl5hquGczUcUE9xE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+42
View File
@@ -0,0 +1,42 @@
// internal/cache/redis.go
package cache
import (
"context"
"fmt"
"log"
"time"
"github.com/example/epay-go/internal/config"
"github.com/redis/go-redis/v9"
)
var RDB *redis.Client
func Init() error {
cfg := config.Get().Redis
RDB = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := RDB.Ping(ctx).Err(); err != nil {
return fmt.Errorf("failed to connect redis: %w", err)
}
log.Println("Redis connected successfully")
return nil
}
func Get() *redis.Client {
return RDB
}
func Close() error {
return RDB.Close()
}
+76
View File
@@ -0,0 +1,76 @@
// internal/config/config.go
package config
import (
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug, release, test
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"`
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHour int `mapstructure:"expire_hour"`
}
var Cfg *Config
func Load(path string) error {
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
// 环境变量覆盖
viper.AutomaticEnv()
// 绑定 Docker 环境变量
viper.BindEnv("database.host", "DB_HOST")
viper.BindEnv("database.port", "DB_PORT")
viper.BindEnv("database.user", "DB_USER")
viper.BindEnv("database.password", "DB_PASSWORD")
viper.BindEnv("database.dbname", "DB_NAME")
viper.BindEnv("redis.host", "REDIS_HOST")
viper.BindEnv("redis.port", "REDIS_PORT")
viper.BindEnv("redis.password", "REDIS_PASSWORD")
viper.BindEnv("jwt.secret", "JWT_SECRET")
viper.BindEnv("server.mode", "GIN_MODE")
if err := viper.ReadInConfig(); err != nil {
return err
}
Cfg = &Config{}
if err := viper.Unmarshal(Cfg); err != nil {
return err
}
return nil
}
func Get() *Config {
return Cfg
}
+66
View File
@@ -0,0 +1,66 @@
// internal/database/database.go
package database
import (
"fmt"
"log"
"time"
"github.com/example/epay-go/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Init() error {
cfg := config.Get().Database
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
var gormConfig *gorm.Config
if config.Get().Server.Mode == "debug" {
gormConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
} else {
gormConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
}
var err error
DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
if err != nil {
return fmt.Errorf("failed to connect database: %w", err)
}
sqlDB, err := DB.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
}
// 连接池配置
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
log.Println("Database connected successfully")
return nil
}
func Get() *gorm.DB {
return DB
}
func Close() error {
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
+31
View File
@@ -0,0 +1,31 @@
// internal/database/migrate.go
package database
import (
"log"
"github.com/example/epay-go/internal/model"
)
// Migrate 自动迁移数据库表
func Migrate() error {
log.Println("Running database migrations...")
err := DB.AutoMigrate(
&model.Admin{},
&model.Merchant{},
&model.Channel{},
&model.Order{},
&model.Settlement{},
&model.BalanceRecord{},
&model.Config{},
&model.Refund{},
)
if err != nil {
return err
}
log.Println("Database migrations completed")
return nil
}
+80
View File
@@ -0,0 +1,80 @@
// internal/handler/admin/auth.go
package admin
import (
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/jwt"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login 管理员登录
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
adminService := service.NewAdminService()
admin, err := adminService.Login(&service.AdminLoginRequest{
Username: req.Username,
Password: req.Password,
})
if err != nil {
response.Error(c, response.CodeUnauthorized, err.Error())
return
}
// 生成 Token
token, err := jwt.GenerateToken(admin.ID, admin.Username, jwt.TokenTypeAdmin)
if err != nil {
response.ServerError(c, "生成Token失败")
return
}
response.Success(c, gin.H{
"token": token,
"admin": gin.H{
"id": admin.ID,
"username": admin.Username,
"role": admin.Role,
},
})
}
// Logout 退出登录
func Logout(c *gin.Context) {
// JWT 无状态,客户端删除 token 即可
response.Success(c, nil)
}
type UpdatePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=32"`
}
func UpdatePassword(c *gin.Context) {
adminID := middleware.GetUserID(c)
var req UpdatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
adminService := service.NewAdminService()
if err := adminService.UpdatePassword(adminID, req.OldPassword, req.NewPassword); err != nil {
response.Error(c, response.CodeParamError, err.Error())
return
}
response.Success(c, nil)
}
+134
View File
@@ -0,0 +1,134 @@
// internal/handler/admin/channel.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/payment"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListChannels 通道列表
func ListChannels(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
channelService := service.NewChannelService()
channels, total, err := channelService.List(page, pageSize)
if err != nil {
response.ServerError(c, "获取通道列表失败")
return
}
response.SuccessPage(c, channels, total, page, pageSize)
}
// GetChannel 获取通道详情
func GetChannel(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的通道ID")
return
}
channelService := service.NewChannelService()
channel, err := channelService.GetByID(id)
if err != nil {
response.NotFound(c, "通道不存在")
return
}
response.Success(c, channel)
}
// CreateChannel 创建通道
func CreateChannel(c *gin.Context) {
var req service.CreateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
channelService := service.NewChannelService()
channel, err := channelService.Create(&req)
if err != nil {
response.ServerError(c, "创建通道失败: "+err.Error())
return
}
response.Success(c, channel)
}
// UpdateChannel 更新通道
func UpdateChannel(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的通道ID")
return
}
var req service.UpdateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
channelService := service.NewChannelService()
if err := channelService.Update(id, &req); err != nil {
response.ServerError(c, "更新通道失败: "+err.Error())
return
}
response.Success(c, nil)
}
// DeleteChannel 删除通道
func DeleteChannel(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的通道ID")
return
}
channelService := service.NewChannelService()
if err := channelService.Delete(id); err != nil {
response.ServerError(c, "删除通道失败: "+err.Error())
return
}
response.Success(c, nil)
}
// GetPluginConfig 获取插件配置模板
func GetPluginConfig(c *gin.Context) {
pluginName := c.Param("plugin")
configs := payment.GetPluginConfigs()
config, exists := configs[pluginName]
if !exists {
response.NotFound(c, "插件不存在")
return
}
response.Success(c, config)
}
// GetAllPluginConfigs 获取所有插件配置列表
func GetAllPluginConfigs(c *gin.Context) {
configs := payment.GetPluginConfigs()
// 转换为列表
list := make([]map[string]interface{}, 0)
for _, cfg := range configs {
list = append(list, map[string]interface{}{
"name": cfg.Name,
"show_name": cfg.ShowName,
"author": cfg.Author,
})
}
response.Success(c, list)
}
+32
View File
@@ -0,0 +1,32 @@
// internal/handler/admin/dashboard.go
package admin
import (
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// Dashboard 仪表盘数据
func Dashboard(c *gin.Context) {
orderService := service.NewOrderService()
merchantService := service.NewMerchantService()
// 今日统计
todayCount, todayAmount, err := orderService.GetTodayStats(nil)
if err != nil {
response.ServerError(c, "获取统计数据失败")
return
}
// 商户数量
merchants, totalMerchants, _ := merchantService.List(1, 1, nil)
_ = merchants
response.Success(c, gin.H{
"today_order_count": todayCount,
"today_order_amount": todayAmount,
"total_merchants": totalMerchants,
})
}
+113
View File
@@ -0,0 +1,113 @@
// internal/handler/admin/merchant.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListMerchants 商户列表
func ListMerchants(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
merchantService := service.NewMerchantService()
merchants, total, err := merchantService.List(page, pageSize, status)
if err != nil {
response.ServerError(c, "获取商户列表失败")
return
}
response.SuccessPage(c, merchants, total, page, pageSize)
}
// GetMerchant 获取商户详情
func GetMerchant(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的商户ID")
return
}
merchantService := service.NewMerchantService()
merchant, err := merchantService.GetByID(id)
if err != nil {
response.NotFound(c, "商户不存在")
return
}
response.Success(c, merchant)
}
// UpdateMerchantRequest 更新商户请求
type UpdateMerchantRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
}
// UpdateMerchant 更新商户信息
func UpdateMerchant(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的商户ID")
return
}
var req UpdateMerchantRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
merchantService := service.NewMerchantService()
merchant, err := merchantService.GetByID(id)
if err != nil {
response.NotFound(c, "商户不存在")
return
}
merchant.Email = req.Email
merchant.Phone = req.Phone
// TODO: 调用 service 更新
response.Success(c, merchant)
}
// UpdateMerchantStatusRequest 更新状态请求
type UpdateMerchantStatusRequest struct {
Status int8 `json:"status" binding:"oneof=0 1"`
}
// UpdateMerchantStatus 更新商户状态
func UpdateMerchantStatus(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的商户ID")
return
}
var req UpdateMerchantStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
merchantService := service.NewMerchantService()
if err := merchantService.UpdateStatus(id, req.Status); err != nil {
response.ServerError(c, err.Error())
return
}
response.Success(c, nil)
}
+79
View File
@@ -0,0 +1,79 @@
// internal/handler/admin/order.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListOrders 订单列表
func ListOrders(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var merchantID *int64
if mid := c.Query("merchant_id"); mid != "" {
v, _ := strconv.ParseInt(mid, 10, 64)
merchantID = &v
}
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
orderService := service.NewOrderService()
orders, total, err := orderService.List(page, pageSize, merchantID, status)
if err != nil {
response.ServerError(c, "获取订单列表失败")
return
}
response.SuccessPage(c, orders, total, page, pageSize)
}
// GetOrder 获取订单详情
func GetOrder(c *gin.Context) {
tradeNo := c.Param("trade_no")
orderService := service.NewOrderService()
order, err := orderService.GetByTradeNo(tradeNo)
if err != nil {
response.NotFound(c, "订单不存在")
return
}
response.Success(c, order)
}
// RenotifyOrder 重新发送通知
func RenotifyOrder(c *gin.Context) {
tradeNo := c.Param("trade_no")
orderService := service.NewOrderService()
notifyService := service.NewNotifyService()
order, err := orderService.GetByTradeNo(tradeNo)
if err != nil {
response.NotFound(c, "订单不存在")
return
}
if order.Status != 1 {
response.Error(c, response.CodeParamError, "订单未支付,无法发送通知")
return
}
if err := notifyService.SendNotify(order); err != nil {
response.ServerError(c, "发送通知失败: "+err.Error())
return
}
response.Success(c, nil)
}
+81
View File
@@ -0,0 +1,81 @@
// internal/handler/admin/refund.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var merchantID *int64
if mid := c.Query("merchant_id"); mid != "" {
v, _ := strconv.ParseInt(mid, 10, 64)
merchantID = &v
}
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
// ProcessRefundRequest 处理退款请求
type ProcessRefundRequest struct {
Success bool `json:"success" binding:"required"`
FailReason string `json:"fail_reason"`
}
// CreateRefund 管理员创建退款申请
func CreateRefund(c *gin.Context) {
var req service.CreateRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
refundService := service.NewRefundService()
refund, err := refundService.CreateRefundByAdmin(&req)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, refund)
}
// ProcessRefund 处理退款
func ProcessRefund(c *gin.Context) {
refundNo := c.Param("refund_no")
var req ProcessRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
refundService := service.NewRefundService()
if err := refundService.ProcessRefund(refundNo, req.Success, req.FailReason); err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, nil)
}
+84
View File
@@ -0,0 +1,84 @@
// internal/handler/admin/settlement.go
package admin
import (
"strconv"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListSettlements 结算列表
func ListSettlements(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var merchantID *int64
if mid := c.Query("merchant_id"); mid != "" {
v, _ := strconv.ParseInt(mid, 10, 64)
merchantID = &v
}
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
settlementService := service.NewSettlementService()
settlements, total, err := settlementService.List(page, pageSize, merchantID, status)
if err != nil {
response.ServerError(c, "获取结算列表失败")
return
}
response.SuccessPage(c, settlements, total, page, pageSize)
}
// ApproveSettlement 审核通过
func ApproveSettlement(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的结算ID")
return
}
settlementService := service.NewSettlementService()
if err := settlementService.Approve(id); err != nil {
response.ServerError(c, err.Error())
return
}
response.Success(c, nil)
}
// RejectSettlementRequest 驳回请求
type RejectSettlementRequest struct {
Remark string `json:"remark" binding:"required"`
}
// RejectSettlement 驳回结算
func RejectSettlement(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.ParamError(c, "无效的结算ID")
return
}
var req RejectSettlementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "请填写驳回原因")
return
}
settlementService := service.NewSettlementService()
if err := settlementService.Reject(id, req.Remark); err != nil {
response.ServerError(c, err.Error())
return
}
response.Success(c, nil)
}
+60
View File
@@ -0,0 +1,60 @@
// internal/handler/admin/test_payment.go
package admin
import (
"log"
"strings"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// TestPaymentRequest 测试支付请求
type TestPaymentRequest struct {
ChannelID int64 `json:"channel_id" binding:"required"`
Amount string `json:"amount" binding:"required"`
PayType string `json:"pay_type" binding:"required"`
}
// TestPayment 测试支付
func TestPayment(c *gin.Context) {
log.Printf("[DEBUG admin/test-payment] request received")
var req TestPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[DEBUG admin/test-payment] bind error: %v", err)
response.ParamError(c, "DEBUG_ADMIN_TEST_PAYMENT_BIND: "+err.Error())
return
}
baseURL := getBaseURL(c)
// 创建测试订单
orderService := service.NewOrderService()
order, payData, err := orderService.CreateTestOrder(req.ChannelID, req.Amount, req.PayType, baseURL)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, gin.H{
"order": order,
"pay_data": payData,
})
}
func getBaseURL(c *gin.Context) string {
scheme := "http"
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
scheme = p
} else if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
if host == "" {
host = "localhost"
}
host = strings.TrimSpace(host)
return scheme + "://" + host
}
+101
View File
@@ -0,0 +1,101 @@
// internal/handler/merchant/auth.go
package merchant
import (
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/jwt"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// RegisterRequest 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=4,max=32"`
Password string `json:"password" binding:"required,min=6,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone" binding:"omitempty"`
}
// Register 商户注册
func Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
merchantService := service.NewMerchantService()
merchant, err := merchantService.Register(&service.RegisterRequest{
Username: req.Username,
Password: req.Password,
Email: req.Email,
Phone: req.Phone,
})
if err != nil {
response.Error(c, response.CodeParamError, err.Error())
return
}
// 生成 Token
token, err := jwt.GenerateToken(merchant.ID, merchant.Username, jwt.TokenTypeMerchant)
if err != nil {
response.ServerError(c, "生成Token失败")
return
}
response.Success(c, gin.H{
"token": token,
"merchant": gin.H{
"id": merchant.ID,
"username": merchant.Username,
"api_key": merchant.ApiKey,
},
})
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login 商户登录
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
merchantService := service.NewMerchantService()
merchant, err := merchantService.Login(&service.LoginRequest{
Username: req.Username,
Password: req.Password,
})
if err != nil {
response.Error(c, response.CodeUnauthorized, err.Error())
return
}
// 生成 Token
token, err := jwt.GenerateToken(merchant.ID, merchant.Username, jwt.TokenTypeMerchant)
if err != nil {
response.ServerError(c, "生成Token失败")
return
}
response.Success(c, gin.H{
"token": token,
"merchant": gin.H{
"id": merchant.ID,
"username": merchant.Username,
"balance": merchant.Balance,
},
})
}
// Logout 退出登录
func Logout(c *gin.Context) {
response.Success(c, nil)
}
+39
View File
@@ -0,0 +1,39 @@
// internal/handler/merchant/dashboard.go
package merchant
import (
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// Dashboard 商户仪表盘
func Dashboard(c *gin.Context) {
merchantID := middleware.GetUserID(c)
merchantService := service.NewMerchantService()
orderService := service.NewOrderService()
// 获取商户信息
merchant, err := merchantService.GetByID(merchantID)
if err != nil {
response.NotFound(c, "商户不存在")
return
}
// 今日统计
todayCount, todayAmount, err := orderService.GetTodayStats(&merchantID)
if err != nil {
response.ServerError(c, "获取统计数据失败")
return
}
response.Success(c, gin.H{
"balance": merchant.Balance,
"frozen_balance": merchant.FrozenBalance,
"today_order_count": todayCount,
"today_order_amount": todayAmount,
})
}
+55
View File
@@ -0,0 +1,55 @@
// internal/handler/merchant/order.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListOrders 订单列表
func ListOrders(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
orderService := service.NewOrderService()
orders, total, err := orderService.List(page, pageSize, &merchantID, status)
if err != nil {
response.ServerError(c, "获取订单列表失败")
return
}
response.SuccessPage(c, orders, total, page, pageSize)
}
// GetOrder 获取订单详情
func GetOrder(c *gin.Context) {
merchantID := middleware.GetUserID(c)
tradeNo := c.Param("trade_no")
orderService := service.NewOrderService()
order, err := orderService.GetByTradeNo(tradeNo)
if err != nil {
response.NotFound(c, "订单不存在")
return
}
// 验证订单归属
if order.MerchantID != merchantID {
response.Forbidden(c, "无权查看此订单")
return
}
response.Success(c, order)
}
+105
View File
@@ -0,0 +1,105 @@
// internal/handler/merchant/profile.go
package merchant
import (
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// GetProfile 获取个人信息
func GetProfile(c *gin.Context) {
merchantID := middleware.GetUserID(c)
merchantService := service.NewMerchantService()
merchant, err := merchantService.GetByID(merchantID)
if err != nil {
response.NotFound(c, "商户不存在")
return
}
response.Success(c, gin.H{
"id": merchant.ID,
"username": merchant.Username,
"email": merchant.Email,
"phone": merchant.Phone,
"api_key": merchant.ApiKey,
"balance": merchant.Balance,
"frozen_balance": merchant.FrozenBalance,
"status": merchant.Status,
"created_at": merchant.CreatedAt,
})
}
// UpdateProfileRequest 更新信息请求
type UpdateProfileRequest struct {
Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone" binding:"omitempty"`
}
// UpdateProfile 更新个人信息
func UpdateProfile(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
merchantService := service.NewMerchantService()
merchant, err := merchantService.GetByID(merchantID)
if err != nil {
response.NotFound(c, "商户不存在")
return
}
merchant.Email = req.Email
merchant.Phone = req.Phone
// TODO: 保存更新
response.Success(c, nil)
}
// UpdatePasswordRequest 修改密码请求
type UpdatePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=32"`
}
// UpdatePassword 修改密码
func UpdatePassword(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req UpdatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
merchantService := service.NewMerchantService()
if err := merchantService.UpdatePassword(merchantID, req.OldPassword, req.NewPassword); err != nil {
response.Error(c, response.CodeParamError, err.Error())
return
}
response.Success(c, nil)
}
// ResetAPIKey 重置API密钥
func ResetAPIKey(c *gin.Context) {
merchantID := middleware.GetUserID(c)
merchantService := service.NewMerchantService()
newKey, err := merchantService.ResetAPIKey(merchantID)
if err != nil {
response.ServerError(c, "重置密钥失败")
return
}
response.Success(c, gin.H{
"api_key": newKey,
})
}
+27
View File
@@ -0,0 +1,27 @@
// internal/handler/merchant/record.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// ListRecords 资金记录列表
func ListRecords(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
recordRepo := repository.NewBalanceRecordRepository()
records, total, err := recordRepo.List(page, pageSize, merchantID)
if err != nil {
response.ServerError(c, "获取资金记录失败")
return
}
response.SuccessPage(c, records, total, page, pageSize)
}
+54
View File
@@ -0,0 +1,54 @@
// internal/handler/merchant/refund.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// CreateRefund 创建退款申请
func CreateRefund(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req service.CreateRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
refundService := service.NewRefundService()
refund, err := refundService.CreateRefund(merchantID, &req)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, refund)
}
// ListRefunds 退款列表
func ListRefunds(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
refundService := service.NewRefundService()
refunds, total, err := refundService.List(page, pageSize, &merchantID, status)
if err != nil {
response.ServerError(c, "获取退款列表失败")
return
}
response.SuccessPage(c, refunds, total, page, pageSize)
}
+76
View File
@@ -0,0 +1,76 @@
// internal/handler/merchant/settlement.go
package merchant
import (
"strconv"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// ListSettlements 结算列表
func ListSettlements(c *gin.Context) {
merchantID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
var status *int8
if s := c.Query("status"); s != "" {
v, _ := strconv.ParseInt(s, 10, 8)
v8 := int8(v)
status = &v8
}
settlementService := service.NewSettlementService()
settlements, total, err := settlementService.List(page, pageSize, &merchantID, status)
if err != nil {
response.ServerError(c, "获取结算列表失败")
return
}
response.SuccessPage(c, settlements, total, page, pageSize)
}
// ApplySettlementRequest 申请结算请求
type ApplySettlementRequest struct {
Amount string `json:"amount" binding:"required"`
AccountType string `json:"account_type" binding:"required,oneof=alipay bank"`
AccountNo string `json:"account_no" binding:"required"`
AccountName string `json:"account_name" binding:"required"`
}
// ApplySettlement 申请结算
func ApplySettlement(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req ApplySettlementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
amount, err := decimal.NewFromString(req.Amount)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
response.ParamError(c, "金额格式错误")
return
}
settlementService := service.NewSettlementService()
settlement, err := settlementService.Apply(&service.ApplyRequest{
MerchantID: merchantID,
Amount: amount,
AccountType: req.AccountType,
AccountNo: req.AccountNo,
AccountName: req.AccountName,
})
if err != nil {
response.Error(c, response.CodeParamError, err.Error())
return
}
response.Success(c, settlement)
}
+89
View File
@@ -0,0 +1,89 @@
package merchant
import (
"context"
"strings"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/example/epay-go/pkg/utils"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type TestPaymentRequest struct {
Amount string `json:"amount" binding:"required"`
PayType string `json:"pay_type" binding:"required"` // alipay, wxpay
PayMethod string `json:"pay_method" binding:"omitempty,oneof=scan native h5 jsapi web"`
}
// TestPayment 商户侧测试支付(创建一笔当前商户的测试订单)
func TestPayment(c *gin.Context) {
merchantID := middleware.GetUserID(c)
var req TestPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
amount, err := decimal.NewFromString(req.Amount)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
response.ParamError(c, "金额格式错误")
return
}
baseURL := getBaseURL(c)
outTradeNo := "MTEST" + utils.GenerateTradeNo()
orderReq := &service.CreateOrderRequest{
MerchantID: merchantID,
OutTradeNo: outTradeNo,
Amount: amount,
Name: "商户测试支付",
PayType: req.PayType,
PlatformBaseURL: baseURL,
ClientIP: utils.GetClientIP(c),
PayMethod: req.PayMethod,
}
orderService := service.NewOrderService()
orderResp, err := orderService.Create(context.Background(), orderReq)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
order, err := orderService.GetByTradeNo(orderResp.TradeNo)
if err != nil {
response.Error(c, response.CodeServerError, "订单创建成功但查询失败")
return
}
response.Success(c, gin.H{
"order": order,
"pay_data": gin.H{
"pay_type": orderResp.PayType,
"pay_url": orderResp.PayURL,
"pay_params": orderResp.PayParams,
},
})
}
func getBaseURL(c *gin.Context) string {
scheme := "http"
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
scheme = p
} else if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
if host == "" {
host = "localhost"
}
host = strings.TrimSpace(host)
return scheme + "://" + host
}
+122
View File
@@ -0,0 +1,122 @@
// internal/handler/payment/create.go
package payment
import (
"context"
"net/url"
"strings"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/example/epay-go/pkg/sign"
"github.com/example/epay-go/pkg/utils"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// CreateOrderRequest 创建订单请求(兼容原epay)
type CreateOrderRequest struct {
Pid string `form:"pid" binding:"required"` // 商户ID
Type string `form:"type" binding:"required"` // 支付类型
OutTradeNo string `form:"out_trade_no" binding:"required"`// 商户订单号
NotifyURL string `form:"notify_url" binding:"required"` // 异步通知地址
ReturnURL string `form:"return_url"` // 同步跳转地址
Name string `form:"name" binding:"required"` // 商品名称
Money string `form:"money" binding:"required"` // 金额
Sign string `form:"sign" binding:"required"` // 签名
SignType string `form:"sign_type"` // 签名类型
}
// CreateOrder 创建支付订单
func CreateOrder(c *gin.Context) {
var req CreateOrderRequest
if err := c.ShouldBind(&req); err != nil {
response.ParamError(c, "参数错误: "+err.Error())
return
}
merchantService := service.NewMerchantService()
orderService := service.NewOrderService()
// 获取商户信息
merchant, err := merchantService.GetByAPIKey(req.Pid)
if err != nil {
response.Error(c, response.CodeParamError, "商户不存在")
return
}
if merchant.Status != 1 {
response.Error(c, response.CodeForbidden, "商户已被禁用")
return
}
// 验证签名
params := url.Values{}
params.Set("pid", req.Pid)
params.Set("type", req.Type)
params.Set("out_trade_no", req.OutTradeNo)
params.Set("notify_url", req.NotifyURL)
params.Set("name", req.Name)
params.Set("money", req.Money)
if req.ReturnURL != "" {
params.Set("return_url", req.ReturnURL)
}
if !sign.VerifyMD5Sign(params, merchant.ApiKey, req.Sign) {
response.Error(c, response.CodeParamError, "签名验证失败")
return
}
// 解析金额
amount, err := decimal.NewFromString(req.Money)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
response.ParamError(c, "金额格式错误")
return
}
// 创建订单
routing, err := resolvePayRouting(req.Type, c.DefaultQuery("pay_method", ""))
if err != nil {
response.ParamError(c, err.Error())
return
}
platformBaseURL := getPaymentBaseURL(c)
orderReq := &service.CreateOrderRequest{
MerchantID: merchant.ID,
OutTradeNo: req.OutTradeNo,
Amount: amount,
Name: req.Name,
PayType: routing.PayType,
NotifyURL: req.NotifyURL,
MerchantNotifyURL: req.NotifyURL,
PlatformBaseURL: platformBaseURL,
ReturnURL: req.ReturnURL,
ClientIP: utils.GetClientIP(c),
PayMethod: routing.PayMethod,
}
orderResp, err := orderService.Create(context.Background(), orderReq)
if err != nil {
response.Error(c, response.CodeServerError, err.Error())
return
}
response.Success(c, orderResp)
}
func getPaymentBaseURL(c *gin.Context) string {
scheme := "http"
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
scheme = p
} else if c.Request.TLS != nil {
scheme = "https"
}
host := strings.TrimSpace(c.Request.Host)
if host == "" {
host = "localhost"
}
return scheme + "://" + host
}
+699
View File
@@ -0,0 +1,699 @@
package payment
import (
"context"
"fmt"
"html/template"
"net/url"
"strconv"
"strings"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/sign"
"github.com/example/epay-go/pkg/utils"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type LegacyCreateOrderRequest struct {
Pid string `form:"pid" binding:"required"`
Type string `form:"type"`
OutTradeNo string `form:"out_trade_no" binding:"required"`
NotifyURL string `form:"notify_url" binding:"required"`
ReturnURL string `form:"return_url"`
Name string `form:"name" binding:"required"`
Money string `form:"money" binding:"required"`
Sign string `form:"sign" binding:"required"`
SignType string `form:"sign_type"`
ClientType string `form:"clientip"`
Device string `form:"device"`
PayMethod string `form:"pay_method"`
}
type LegacyAPIRequest struct {
Act string `form:"act" binding:"required"`
Pid string `form:"pid" binding:"required"`
Key string `form:"key" binding:"required"`
TradeNo string `form:"trade_no"`
OutTradeNo string `form:"out_trade_no"`
Page int `form:"page"`
Limit int `form:"limit"`
RefundNo string `form:"refund_no"`
Amount string `form:"money"`
Reason string `form:"reason"`
NotifyURL string `form:"notify_url"`
}
type legacyResolvedMerchant struct {
Merchant *model.Merchant
Pid string
}
func LegacySubmit(c *gin.Context) {
var req LegacyCreateOrderRequest
if err := c.ShouldBind(&req); err != nil {
legacyHTML(c, "参数错误: "+err.Error())
return
}
orderResp, _, err := createLegacyOrder(c, &req)
if err != nil {
legacyHTML(c, err.Error())
return
}
if orderResp.PayURL != "" {
if orderResp.PayType == "qrcode" {
legacyQRCodePage(c, &req, orderResp)
return
}
c.Redirect(302, orderResp.PayURL)
return
}
legacyHTML(c, "未获取到支付跳转地址")
}
func LegacyCreateOrder(c *gin.Context) {
var req LegacyCreateOrderRequest
if err := c.ShouldBind(&req); err != nil {
legacyError(c, "参数错误: "+err.Error())
return
}
orderResp, resolved, err := createLegacyOrder(c, &req)
if err != nil {
legacyError(c, err.Error())
return
}
resp := gin.H{
"code": 1,
"msg": "success",
"pid": resolved.Pid,
"trade_no": orderResp.TradeNo,
}
attachLegacyPayFields(resp, orderResp)
c.JSON(200, resp)
}
func LegacyAPI(c *gin.Context) {
var req LegacyAPIRequest
if err := c.ShouldBindQuery(&req); err != nil {
legacyError(c, "参数错误: "+err.Error())
return
}
merchantService := service.NewMerchantService()
orderService := service.NewOrderService()
refundService := service.NewRefundService()
settlementService := service.NewSettlementService()
resolved, err := resolveLegacyMerchant(merchantService, req.Pid)
if err != nil {
legacyError(c, "商户不存在")
return
}
merchant := resolved.Merchant
if merchant.ApiKey != req.Key {
legacyError(c, "密钥错误")
return
}
switch req.Act {
case "query":
c.JSON(200, gin.H{
"code": 1,
"msg": "success",
"pid": merchant.ID,
"user": merchant.Username,
"email": merchant.Email,
"status": merchant.Status,
"balance": merchant.Balance.StringFixed(2),
})
case "order":
if req.TradeNo == "" && req.OutTradeNo == "" {
legacyError(c, "trade_no 和 out_trade_no 不能同时为空")
return
}
var order *model.Order
if req.TradeNo != "" {
order, err = orderService.GetByTradeNo(req.TradeNo)
} else {
order, err = orderService.GetByOutTradeNo(merchant.ID, req.OutTradeNo)
}
if err != nil {
legacyError(c, "订单不存在")
return
}
status := 0
if order.Status == model.OrderStatusPaid {
status = 1
}
c.JSON(200, gin.H{
"code": 1,
"msg": "success",
"pid": merchant.ID,
"trade_no": order.TradeNo,
"out_trade_no": order.OutTradeNo,
"type": order.PayType,
"name": order.Name,
"money": order.Amount.StringFixed(2),
"buyer": order.Buyer,
"api_trade_no": order.ApiTradeNo,
"trade_status": status,
"status": status,
})
case "orders":
page := req.Page
if page <= 0 {
page = 1
}
limit := req.Limit
if limit <= 0 || limit > 100 {
limit = 20
}
orders, total, err := orderService.List(page, limit, &merchant.ID, nil)
if err != nil {
legacyError(c, "获取订单列表失败")
return
}
list := make([]gin.H, 0, len(orders))
for _, order := range orders {
status := 0
if order.Status == model.OrderStatusPaid {
status = 1
}
list = append(list, gin.H{
"trade_no": order.TradeNo,
"out_trade_no": order.OutTradeNo,
"type": order.PayType,
"name": order.Name,
"money": order.Amount.StringFixed(2),
"status": status,
"trade_status": status,
"created_at": order.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
c.JSON(200, gin.H{
"code": 1,
"msg": "success",
"pid": merchant.ID,
"total": total,
"page": page,
"limit": limit,
"data": list,
})
case "settle":
settlements, total, err := settlementService.List(1, 100, &merchant.ID, nil)
if err != nil {
legacyError(c, "获取结算记录失败")
return
}
list := make([]gin.H, 0, len(settlements))
for _, item := range settlements {
list = append(list, gin.H{
"settle_no": item.SettleNo,
"money": item.Amount.StringFixed(2),
"fee": item.Fee.StringFixed(2),
"actual_money": item.ActualAmount.StringFixed(2),
"account_type": item.AccountType,
"account_no": item.AccountNo,
"account_name": item.AccountName,
"status": item.Status,
"remark": item.Remark,
"created_at": item.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
c.JSON(200, gin.H{
"code": 1,
"msg": "success",
"pid": merchant.ID,
"total": total,
"data": list,
})
case "refund":
if req.TradeNo == "" && req.OutTradeNo == "" {
legacyError(c, "trade_no 和 out_trade_no 不能同时为空")
return
}
if req.Amount == "" {
legacyError(c, "退款金额不能为空")
return
}
tradeNo := req.TradeNo
if tradeNo == "" {
order, err := orderService.GetByOutTradeNo(merchant.ID, req.OutTradeNo)
if err != nil {
legacyError(c, "订单不存在")
return
}
tradeNo = order.TradeNo
}
refund, err := refundService.CreateRefund(merchant.ID, &service.CreateRefundRequest{
TradeNo: tradeNo,
Amount: req.Amount,
Reason: req.Reason,
NotifyURL: req.NotifyURL,
})
if err != nil {
legacyError(c, err.Error())
return
}
c.JSON(200, gin.H{
"code": 1,
"msg": "success",
"pid": merchant.ID,
"refund_no": refund.RefundNo,
"trade_no": refund.TradeNo,
"out_trade_no": req.OutTradeNo,
"money": refund.Amount,
"status": refund.Status,
})
default:
legacyError(c, "暂不支持的 act: "+req.Act)
}
}
func createLegacyOrder(c *gin.Context, req *LegacyCreateOrderRequest) (*service.CreateOrderResponse, *legacyResolvedMerchant, error) {
merchantService := service.NewMerchantService()
orderService := service.NewOrderService()
resolved, err := resolveLegacyMerchant(merchantService, req.Pid)
if err != nil {
return nil, nil, fmt.Errorf("商户不存在")
}
merchant := resolved.Merchant
if merchant.Status != 1 {
return nil, nil, fmt.Errorf("商户已被禁用")
}
params := url.Values{}
params.Set("pid", req.Pid)
if req.Type != "" {
params.Set("type", req.Type)
}
params.Set("out_trade_no", req.OutTradeNo)
params.Set("notify_url", req.NotifyURL)
params.Set("name", req.Name)
params.Set("money", req.Money)
if req.ReturnURL != "" {
params.Set("return_url", req.ReturnURL)
}
if req.Device != "" {
params.Set("device", req.Device)
}
if req.PayMethod != "" {
params.Set("pay_method", req.PayMethod)
}
if req.ClientType != "" {
params.Set("clientip", req.ClientType)
}
if !sign.VerifyMD5Sign(params, merchant.ApiKey, req.Sign) {
return nil, nil, fmt.Errorf("签名验证失败")
}
amount, err := decimal.NewFromString(req.Money)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
return nil, nil, fmt.Errorf("金额格式错误")
}
routing, err := resolvePayRouting(req.Type, req.PayMethod)
if err != nil {
return nil, nil, err
}
orderResp, err := orderService.Create(context.Background(), &service.CreateOrderRequest{
MerchantID: merchant.ID,
OutTradeNo: req.OutTradeNo,
Amount: amount,
Name: req.Name,
PayType: routing.PayType,
NotifyURL: req.NotifyURL,
MerchantNotifyURL: req.NotifyURL,
PlatformBaseURL: getPaymentBaseURL(c),
ReturnURL: req.ReturnURL,
ClientIP: utils.GetClientIP(c),
PayMethod: routing.PayMethod,
})
if err != nil {
return nil, nil, err
}
return orderResp, resolved, nil
}
func resolveLegacyMerchant(merchantService *service.MerchantService, pid string) (*legacyResolvedMerchant, error) {
if id, err := strconv.ParseInt(pid, 10, 64); err == nil && id > 0 {
merchant, getErr := merchantService.GetByID(id)
if getErr == nil {
return &legacyResolvedMerchant{Merchant: merchant, Pid: pid}, nil
}
}
merchant, err := merchantService.GetByAPIKey(pid)
if err != nil {
return nil, err
}
return &legacyResolvedMerchant{Merchant: merchant, Pid: strconv.FormatInt(merchant.ID, 10)}, nil
}
func attachLegacyPayFields(resp gin.H, orderResp *service.CreateOrderResponse) {
if orderResp.PayURL == "" {
return
}
resp["payurl"] = orderResp.PayURL
switch orderResp.PayType {
case "qrcode":
resp["qrcode"] = orderResp.PayURL
case "redirect":
resp["payurl"] = orderResp.PayURL
case "jsapi":
resp["urlscheme"] = orderResp.PayURL
}
}
func legacyHTML(c *gin.Context, msg string) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, "<html><body><h3>"+msg+"</h3></body></html>")
}
func legacyQRCodePage(c *gin.Context, req *LegacyCreateOrderRequest, orderResp *service.CreateOrderResponse) {
escapedName := template.HTMLEscapeString(req.Name)
escapedAmount := template.HTMLEscapeString(req.Money)
escapedTradeNo := template.HTMLEscapeString(orderResp.TradeNo)
escapedOutTradeNo := template.HTMLEscapeString(req.OutTradeNo)
escapedPayURLHTML := template.HTMLEscapeString(orderResp.PayURL)
escapedPayURL := template.JSEscapeString(orderResp.PayURL)
statusAPIURL := template.JSEscapeString(getPaymentBaseURL(c) + "/api/pay/status/" + orderResp.TradeNo)
escapedReturnURL := template.HTMLEscapeString(req.ReturnURL)
escapedReturnURLJS := template.JSEscapeString(req.ReturnURL)
qrImageURL := template.HTMLEscapeString("https://api.qrserver.com/v1/create-qr-code/?size=232x232&data=" + url.QueryEscape(orderResp.PayURL))
returnSection := ""
if strings.TrimSpace(req.ReturnURL) != "" {
returnSection = fmt.Sprintf(`<a class="action secondary" href="%s">返回商户页面</a>`, escapedReturnURL)
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>请扫码完成支付</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1f4b99 0%%, #0b1220 42%%, #060913 100%%);
color: #e5eefc;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.panel {
width: 100%%;
max-width: 980px;
background: rgba(12, 18, 33, 0.92);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 28px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
overflow: hidden;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
backdrop-filter: blur(16px);
}
.hero {
padding: 42px;
background: linear-gradient(180deg, rgba(59, 130, 246, 0.18) 0%%, rgba(37, 99, 235, 0.05) 100%%);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.badge {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.18);
color: #bfdbfe;
font-size: 13px;
letter-spacing: 0.06em;
}
.title {
margin: 20px 0 10px;
font-size: 34px;
font-weight: 700;
color: #f8fbff;
}
.subtitle {
margin: 0;
line-height: 1.75;
color: #cbd5e1;
font-size: 15px;
}
.summary {
margin-top: 32px;
display: grid;
gap: 14px;
}
.summary-item {
padding: 16px 18px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.summary-label {
color: #94a3b8;
font-size: 12px;
margin-bottom: 6px;
}
.summary-value {
color: #f8fafc;
font-size: 16px;
word-break: break-all;
}
.summary-value.amount {
font-size: 30px;
font-weight: 700;
color: #7dd3fc;
}
.pay-box {
padding: 42px 32px;
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%%, rgba(241,245,249,0.98) 100%%);
color: #0f172a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.qr-shell {
width: 260px;
height: 260px;
padding: 14px;
background: #fff;
border-radius: 24px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
display: flex;
align-items: center;
justify-content: center;
}
.qr-shell > div,
.qr-shell canvas,
.qr-shell img {
max-width: 100%%;
max-height: 100%%;
}
.pay-title {
margin: 24px 0 8px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
}
.pay-desc {
margin: 0 0 18px;
color: #475569;
text-align: center;
line-height: 1.7;
}
.status-chip {
margin: 0 0 16px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 13px;
font-weight: 600;
}
.status-chip.success {
background: #dcfce7;
color: #166534;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
margin-top: 8px;
}
.action {
appearance: none;
border: none;
cursor: pointer;
text-decoration: none;
padding: 12px 18px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
}
.action:hover {
transform: translateY(-1px);
}
.action.primary {
background: linear-gradient(135deg, #2563eb 0%%, #1d4ed8 100%%);
color: #fff;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.28);
}
.action.secondary {
background: #e2e8f0;
color: #0f172a;
}
.link-box {
width: 100%%;
margin-top: 18px;
padding: 12px 14px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #cbd5e1;
color: #475569;
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
@media (max-width: 860px) {
.panel {
grid-template-columns: 1fr;
}
.hero, .pay-box {
padding: 28px 22px;
}
.title {
font-size: 28px;
}
.qr-shell {
width: 220px;
height: 220px;
}
}
</style>
</head>
<body>
<div class="panel">
<section class="hero">
<div>
<div class="badge">安全支付 · 扫码付款</div>
<h1 class="title">请使用微信扫码完成支付</h1>
<p class="subtitle">订单已经创建成功,请使用微信扫一扫扫描右侧二维码完成付款。支付成功后,系统会自动通知商户并跳转回业务页面。</p>
<div class="summary">
<div class="summary-item">
<div class="summary-label">支付金额</div>
<div class="summary-value amount">¥%s</div>
</div>
<div class="summary-item">
<div class="summary-label">商品名称</div>
<div class="summary-value">%s</div>
</div>
<div class="summary-item">
<div class="summary-label">平台订单号</div>
<div class="summary-value">%s</div>
</div>
<div class="summary-item">
<div class="summary-label">商户订单号</div>
<div class="summary-value">%s</div>
</div>
</div>
</div>
</section>
<section class="pay-box">
<div class="qr-shell"><img src="%s" alt="支付二维码" /></div>
<h2 class="pay-title">扫码支付</h2>
<p class="pay-desc">如果二维码未显示,请点击下方按钮重新打开支付链接,或复制链接到支持的环境中打开。</p>
<div class="status-chip" id="pay-status">等待支付完成</div>
<div class="actions">
<button class="action primary" type="button" onclick="window.location.href=payUrl">打开支付链接</button>
%s
</div>
<div class="link-box">%s</div>
</section>
</div>
<script>
const payUrl = "%s";
const statusUrl = "%s";
const returnUrl = "%s";
const statusEl = document.getElementById('pay-status');
let redirected = false;
async function checkPaymentStatus() {
if (redirected) {
return;
}
try {
const response = await fetch(statusUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' },
cache: 'no-store'
});
const result = await response.json();
if (result && result.code === 0 && result.data && result.data.paid) {
redirected = true;
statusEl.textContent = '支付成功,正在跳转...';
statusEl.classList.add('success');
window.setTimeout(function () {
if (returnUrl) {
window.location.href = returnUrl;
return;
}
window.location.reload();
}, 1200);
return;
}
statusEl.textContent = '等待支付完成';
} catch (error) {
statusEl.textContent = '支付状态检查中...';
}
}
checkPaymentStatus();
window.setInterval(checkPaymentStatus, 3000);
</script>
</body>
</html>`, escapedAmount, escapedName, escapedTradeNo, escapedOutTradeNo, qrImageURL, returnSection, escapedPayURLHTML, escapedPayURL, statusAPIURL, escapedReturnURLJS)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, html)
}
func legacyError(c *gin.Context, msg string) {
c.JSON(200, gin.H{
"code": -1,
"msg": msg,
})
}
+91
View File
@@ -0,0 +1,91 @@
// internal/handler/payment/notify.go
package payment
import (
"io"
"log"
"net/http"
"github.com/example/epay-go/internal/model"
intPayment "github.com/example/epay-go/internal/payment"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/internal/service"
"github.com/gin-gonic/gin"
)
// HandleNotify 处理支付回调
func HandleNotify(c *gin.Context) {
channelPlugin := c.Param("channel")
channelRepo := repository.NewChannelRepository()
orderService := service.NewOrderService()
notifyService := service.NewNotifyService()
// 获取通道配置
channel, err := channelRepo.GetByPluginAndPayType(channelPlugin, "")
if err != nil {
log.Printf("Channel not found: %s", channelPlugin)
c.String(http.StatusOK, "fail")
return
}
// 创建适配器
adapter, err := intPayment.NewAdapter(channel.Plugin, channel.Config)
if err != nil {
log.Printf("Create adapter failed: %v", err)
c.String(http.StatusOK, "fail")
return
}
// 解析回调
result, err := adapter.ParseNotify(c.Request.Context(), c.Request)
if err != nil {
log.Printf("Parse notify failed: %v", err)
c.String(http.StatusOK, "fail")
return
}
// 处理支付结果
if result.Status == "success" {
if err := orderService.ProcessPayNotify(result.TradeNo, result.ApiTradeNo, result.Buyer, result.Amount); err != nil {
log.Printf("Process notify failed: %v", err)
c.String(http.StatusOK, "fail")
return
}
// 发送商户通知
order, _ := orderService.GetByTradeNo(result.TradeNo)
if order != nil && order.Status == model.OrderStatusPaid {
go notifyService.SendNotify(order)
}
}
// 返回成功响应
c.String(http.StatusOK, adapter.NotifySuccess())
}
// HandleReturn 处理同步跳转
func HandleReturn(c *gin.Context) {
// 从参数获取订单号
tradeNo := c.Query("out_trade_no")
if tradeNo == "" {
// 尝试从 body 读取
body, _ := io.ReadAll(c.Request.Body)
log.Printf("Return body: %s", string(body))
}
orderService := service.NewOrderService()
order, err := orderService.GetByTradeNo(tradeNo)
if err != nil {
c.Redirect(http.StatusFound, "/")
return
}
// 跳转到商户 return_url
if order.ReturnURL != "" {
c.Redirect(http.StatusFound, order.ReturnURL)
return
}
c.String(http.StatusOK, "支付完成")
}
+100
View File
@@ -0,0 +1,100 @@
// internal/handler/payment/query.go
package payment
import (
"net/url"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/service"
"github.com/example/epay-go/pkg/response"
"github.com/example/epay-go/pkg/sign"
"github.com/gin-gonic/gin"
)
// QueryOrderRequest 查询订单请求
type QueryOrderRequest struct {
Pid string `form:"pid" binding:"required"`
TradeNo string `form:"trade_no"`
OutTradeNo string `form:"out_trade_no"`
Sign string `form:"sign" binding:"required"`
}
// QueryOrder 查询订单
func QueryOrder(c *gin.Context) {
var req QueryOrderRequest
if err := c.ShouldBind(&req); err != nil {
response.ParamError(c, "参数错误")
return
}
if req.TradeNo == "" && req.OutTradeNo == "" {
response.ParamError(c, "trade_no 和 out_trade_no 不能同时为空")
return
}
merchantService := service.NewMerchantService()
orderService := service.NewOrderService()
// 获取商户
resolved, err := resolveLegacyMerchant(merchantService, req.Pid)
if err != nil {
response.Error(c, response.CodeParamError, "商户不存在")
return
}
merchant := resolved.Merchant
// 验证签名
params := url.Values{}
params.Set("pid", req.Pid)
if req.TradeNo != "" {
params.Set("trade_no", req.TradeNo)
}
if req.OutTradeNo != "" {
params.Set("out_trade_no", req.OutTradeNo)
}
if !sign.VerifyMD5Sign(params, merchant.ApiKey, req.Sign) {
response.Error(c, response.CodeParamError, "签名验证失败")
return
}
// 查询订单
var order interface{}
if req.TradeNo != "" {
order, err = orderService.GetByTradeNo(req.TradeNo)
} else {
order, err = orderService.GetByOutTradeNo(merchant.ID, req.OutTradeNo)
}
if err != nil {
response.NotFound(c, "订单不存在")
return
}
response.Success(c, order)
}
// PublicOrderStatus 提供给 submit.php 页面轮询支付状态
func PublicOrderStatus(c *gin.Context) {
tradeNo := c.Param("trade_no")
if tradeNo == "" {
response.ParamError(c, "订单号不能为空")
return
}
orderService := service.NewOrderService()
order, err := orderService.GetByTradeNo(tradeNo)
if err != nil {
response.NotFound(c, "订单不存在")
return
}
paid := order.Status == model.OrderStatusPaid
response.Success(c, gin.H{
"trade_no": order.TradeNo,
"out_trade_no": order.OutTradeNo,
"status": order.Status,
"paid": paid,
"return_url": order.ReturnURL,
})
}
+13
View File
@@ -0,0 +1,13 @@
package payment
import (
"net/http"
"github.com/gin-gonic/gin"
)
// TestNotify 用于测试单的商户通知地址,避免通知重试刷日志
func TestNotify(c *gin.Context) {
c.String(http.StatusOK, "success")
}
+117
View File
@@ -0,0 +1,117 @@
package payment
import (
"fmt"
"strings"
)
type resolvedPayRouting struct {
PayType string
PayMethod string
}
func resolvePayRouting(rawType, rawPayMethod string) (*resolvedPayRouting, error) {
normalizedType := normalizePayToken(rawType)
normalizedMethod := normalizePayMethod(rawPayMethod)
switch normalizedType {
case "wx_native", "wechat_native", "native_wx", "native_wechat":
return buildResolvedPayRouting("wxpay", "native", normalizedMethod)
case "wx_h5", "wechat_h5":
return buildResolvedPayRouting("wxpay", "h5", normalizedMethod)
case "wx_jsapi", "wechat_jsapi":
return buildResolvedPayRouting("wxpay", "jsapi", normalizedMethod)
case "alipay_scan", "ali_scan", "alipay_qrcode", "ali_qrcode", "alipay_native", "ali_native":
return buildResolvedPayRouting("alipay", "scan", normalizedMethod)
case "alipay_h5", "ali_h5", "alipay_wap", "ali_wap":
return buildResolvedPayRouting("alipay", "h5", normalizedMethod)
case "alipay_web", "ali_web", "alipay_pc", "ali_pc":
return buildResolvedPayRouting("alipay", "web", normalizedMethod)
case "native", "scan", "qrcode", "h5", "wap", "jsapi", "web", "pc", "precreate":
if normalizedMethod == "" {
return nil, fmt.Errorf("type=%s 无法唯一确定支付渠道,请改为传 wxpay/alipay,或使用 wx_native、alipay_scan 这类明确值", rawType)
}
return nil, fmt.Errorf("type=%s 仅表示支付场景,不能单独作为支付渠道;请改为传 wxpay/alipay,或使用 wx_native、alipay_scan 这类明确值", rawType)
case "wxpay", "wechat":
return buildResolvedPayRouting("wxpay", defaultPayMethodForProvider("wxpay", normalizedMethod), normalizedMethod)
case "alipay", "ali":
return buildResolvedPayRouting("alipay", defaultPayMethodForProvider("alipay", normalizedMethod), normalizedMethod)
case "":
if normalizedMethod == "" {
return buildResolvedPayRouting("wxpay", "scan", normalizedMethod)
}
if isWechatMethod(normalizedMethod) {
return buildResolvedPayRouting("wxpay", normalizedMethod, normalizedMethod)
}
if isAlipayMethod(normalizedMethod) {
return buildResolvedPayRouting("alipay", normalizedMethod, normalizedMethod)
}
return nil, fmt.Errorf("无法根据 pay_method=%s 确定支付渠道,请显式传入 type", rawPayMethod)
default:
return buildResolvedPayRouting(normalizedType, normalizedMethod, normalizedMethod)
}
}
func buildResolvedPayRouting(payType, fallbackMethod, explicitMethod string) (*resolvedPayRouting, error) {
method := explicitMethod
if method == "" {
method = fallbackMethod
}
if method == "" {
return nil, fmt.Errorf("未识别到支付方式")
}
if payType == "alipay" && method == "native" {
method = "scan"
}
return &resolvedPayRouting{PayType: payType, PayMethod: method}, nil
}
func normalizePayToken(value string) string {
replacer := strings.NewReplacer("-", "_", " ", "", ".", "_")
return strings.ToLower(strings.TrimSpace(replacer.Replace(value)))
}
func normalizePayMethod(value string) string {
switch normalizePayToken(value) {
case "", "default":
return ""
case "native", "scan", "qrcode", "precreate":
return "native"
case "wap", "h5":
return "h5"
case "web", "pc", "page":
return "web"
case "jsapi":
return "jsapi"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func defaultPayMethodForProvider(payType, payMethod string) string {
if payMethod != "" {
return payMethod
}
if payType == "alipay" {
return "scan"
}
return "native"
}
func isWechatMethod(payMethod string) bool {
switch payMethod {
case "native", "jsapi":
return true
default:
return false
}
}
func isAlipayMethod(payMethod string) bool {
switch payMethod {
case "web":
return true
default:
return false
}
}
+73
View File
@@ -0,0 +1,73 @@
// internal/middleware/auth.go
package middleware
import (
"strings"
"github.com/example/epay-go/pkg/jwt"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
const (
ContextKeyUserID = "user_id"
ContextKeyUsername = "username"
ContextKeyTokenType = "token_type"
)
// JWTAuth JWT 认证中间件
func JWTAuth(requiredType jwt.TokenType) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "请先登录")
c.Abort()
return
}
// Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Unauthorized(c, "Token 格式错误")
c.Abort()
return
}
claims, err := jwt.ParseToken(parts[1])
if err != nil {
response.Unauthorized(c, "Token 无效或已过期")
c.Abort()
return
}
// 验证 token 类型
if claims.TokenType != requiredType {
response.Forbidden(c, "无权访问")
c.Abort()
return
}
// 将用户信息存入上下文
c.Set(ContextKeyUserID, claims.UserID)
c.Set(ContextKeyUsername, claims.Username)
c.Set(ContextKeyTokenType, claims.TokenType)
c.Next()
}
}
// GetUserID 从上下文获取用户ID
func GetUserID(c *gin.Context) int64 {
if id, exists := c.Get(ContextKeyUserID); exists {
return id.(int64)
}
return 0
}
// GetUsername 从上下文获取用户名
func GetUsername(c *gin.Context) string {
if name, exists := c.Get(ContextKeyUsername); exists {
return name.(string)
}
return ""
}
+24
View File
@@ -0,0 +1,24 @@
// internal/middleware/cors.go
package middleware
import (
"github.com/gin-gonic/gin"
)
// Cors 跨域中间件
func Cors() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With")
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+32
View File
@@ -0,0 +1,32 @@
// internal/middleware/logger.go
package middleware
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
// Logger 请求日志中间件
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
method := c.Request.Method
if query != "" {
path = path + "?" + query
}
log.Printf("[GIN] %3d | %13v | %15s | %-7s %s",
status, latency, clientIP, method, path)
}
}
+27
View File
@@ -0,0 +1,27 @@
// internal/middleware/recovery.go
package middleware
import (
"log"
"net/http"
"runtime/debug"
"github.com/example/epay-go/pkg/response"
"github.com/gin-gonic/gin"
)
// Recovery panic 恢复中间件
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("[PANIC] %v\n%s", err, debug.Stack())
c.AbortWithStatusJSON(http.StatusInternalServerError, response.Response{
Code: response.CodeServerError,
Msg: "服务器内部错误",
})
}
}()
c.Next()
}
}
+17
View File
@@ -0,0 +1,17 @@
// internal/model/admin.go
package model
import "time"
// Admin 管理员
type Admin struct {
BaseModel
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
Password string `gorm:"size:128;not null" json:"-"`
Role string `gorm:"size:20;default:admin" json:"role"` // super, admin
LastLoginAt *time.Time `json:"last_login_at"`
}
func (Admin) TableName() string {
return "admins"
}
+16
View File
@@ -0,0 +1,16 @@
// internal/model/base.go
package model
import (
"time"
"gorm.io/gorm"
)
// BaseModel 基础模型
type BaseModel struct {
ID int64 `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
+26
View File
@@ -0,0 +1,26 @@
// internal/model/channel.go
package model
import (
"encoding/json"
"github.com/shopspring/decimal"
)
// Channel 支付通道
type Channel struct {
BaseModel
Name string `gorm:"size:64;not null" json:"name"`
Plugin string `gorm:"size:32;not null" json:"plugin"` // alipay, wechat, paypal...
PayTypes string `gorm:"size:255" json:"pay_types"` // 支持的支付方式,逗号分隔
AppType string `gorm:"size:100" json:"app_type"` // 已启用的支付接口(逗号分隔,如"page,wap,qrcode")
Config json.RawMessage `gorm:"type:jsonb" json:"config"` // 通道配置
Rate decimal.Decimal `gorm:"type:decimal(5,4);default:0" json:"rate"`
DailyLimit decimal.Decimal `gorm:"type:decimal(12,2);default:0" json:"daily_limit"`
Status int8 `gorm:"default:1" json:"status"` // 0禁用 1启用
Sort int `gorm:"default:0" json:"sort"`
}
func (Channel) TableName() string {
return "channels"
}
+15
View File
@@ -0,0 +1,15 @@
// internal/model/config.go
package model
import "time"
// Config 系统配置
type Config struct {
Key string `gorm:"size:64;primarykey" json:"key"`
Value string `gorm:"type:text" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Config) TableName() string {
return "configs"
}
+25
View File
@@ -0,0 +1,25 @@
// internal/model/merchant.go
package model
import (
"github.com/shopspring/decimal"
)
// Merchant 商户
type Merchant struct {
BaseModel
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
Password string `gorm:"size:128;not null" json:"-"`
Email string `gorm:"size:128" json:"email"`
Phone string `gorm:"size:20" json:"phone"`
ApiKey string `gorm:"size:64;uniqueIndex;not null" json:"api_key"`
PublicKey string `gorm:"type:text" json:"-"`
PrivateKey string `gorm:"type:text" json:"-"`
Balance decimal.Decimal `gorm:"type:decimal(12,2);default:0" json:"balance"`
FrozenBalance decimal.Decimal `gorm:"type:decimal(12,2);default:0" json:"frozen_balance"`
Status int8 `gorm:"default:1" json:"status"` // 0禁用 1正常
}
func (Merchant) TableName() string {
return "merchants"
}
+55
View File
@@ -0,0 +1,55 @@
// internal/model/order.go
package model
import (
"time"
"github.com/shopspring/decimal"
)
// Order 订单
type Order struct {
BaseModel
TradeNo string `gorm:"size:32;uniqueIndex;not null" json:"trade_no"`
OutTradeNo string `gorm:"size:64;not null" json:"out_trade_no"`
MerchantID int64 `gorm:"index;not null" json:"merchant_id"`
ChannelID int64 `gorm:"index" json:"channel_id"`
PayType string `gorm:"size:20" json:"pay_type"`
Amount decimal.Decimal `gorm:"type:decimal(12,2);not null" json:"amount"`
RealAmount decimal.Decimal `gorm:"type:decimal(12,2)" json:"real_amount"`
Fee decimal.Decimal `gorm:"type:decimal(12,2);default:0" json:"fee"`
Name string `gorm:"size:255" json:"name"`
NotifyURL string `gorm:"size:512" json:"notify_url"`
ReturnURL string `gorm:"size:512" json:"return_url"`
ApiTradeNo string `gorm:"size:64" json:"api_trade_no"`
Buyer string `gorm:"size:64" json:"buyer"`
ClientIP string `gorm:"size:45" json:"client_ip"`
Status int8 `gorm:"default:0;index" json:"status"` // 0未支付 1已支付 2已退款
NotifyStatus int8 `gorm:"default:0" json:"notify_status"` // 0未通知 1通知中 2已通知
NotifyCount int `gorm:"default:0" json:"notify_count"`
NextNotifyAt *time.Time `json:"next_notify_at"`
PaidAt *time.Time `json:"paid_at"`
// 关联
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
Channel *Channel `gorm:"foreignKey:ChannelID" json:"channel,omitempty"`
}
func (Order) TableName() string {
return "orders"
}
// 订单状态常量
const (
OrderStatusUnpaid = 0
OrderStatusPaid = 1
OrderStatusRefund = 2
)
// 通知状态常量
const (
NotifyStatusPending = 0
NotifyStatusSending = 1
NotifyStatusSuccess = 2
NotifyStatusFailed = 3
)
+31
View File
@@ -0,0 +1,31 @@
// internal/model/record.go
package model
import (
"github.com/shopspring/decimal"
)
// BalanceRecord 资金变动记录
type BalanceRecord struct {
BaseModel
MerchantID int64 `gorm:"index;not null" json:"merchant_id"`
Action int8 `gorm:"not null" json:"action"` // 1收入 2支出
Amount decimal.Decimal `gorm:"type:decimal(12,2);not null" json:"amount"`
BeforeBalance decimal.Decimal `gorm:"type:decimal(12,2)" json:"before_balance"`
AfterBalance decimal.Decimal `gorm:"type:decimal(12,2)" json:"after_balance"`
Type string `gorm:"size:32" json:"type"` // order_income, fee, settle, refund
TradeNo string `gorm:"size:64" json:"trade_no"`
// 关联
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
}
func (BalanceRecord) TableName() string {
return "balance_records"
}
// 资金变动类型常量
const (
RecordActionIncome = 1
RecordActionExpense = 2
)
+43
View File
@@ -0,0 +1,43 @@
// internal/model/refund.go
package model
import (
"time"
"gorm.io/gorm"
)
// Refund 退款订单
type Refund struct {
ID int64 `gorm:"primaryKey" json:"id"`
RefundNo string `gorm:"size:32;uniqueIndex;not null" json:"refund_no"` // 退款单号
TradeNo string `gorm:"size:32;index;not null" json:"trade_no"` // 原订单号
MerchantID int64 `gorm:"index;not null" json:"merchant_id"` // 商户ID
OrderID int64 `gorm:"index;not null" json:"order_id"` // 原订单ID
ChannelID int64 `gorm:"not null" json:"channel_id"` // 支付通道ID
Amount string `gorm:"type:decimal(10,2);not null" json:"amount"` // 退款金额
RefundFee string `gorm:"type:decimal(10,2);default:0.00" json:"refund_fee"` // 退款手续费
Reason string `gorm:"size:200" json:"reason"` // 退款原因
Status int8 `gorm:"default:0;index" json:"status"` // 状态:0待处理 1成功 2失败
ApiRefundNo string `gorm:"size:64" json:"api_refund_no"` // 第三方退款单号
FailReason string `gorm:"size:200" json:"fail_reason"` // 失败原因
NotifyURL string `gorm:"size:255" json:"notify_url"` // 异步通知地址
NotifyStatus int8 `gorm:"default:0" json:"notify_status"` // 通知状态:0未通知 1已通知
NotifyCount int `gorm:"default:0" json:"notify_count"` // 通知次数
NextNotifyTime *time.Time `json:"next_notify_time"` // 下次通知时间
ProcessedAt *time.Time `json:"processed_at"` // 处理完成时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// 退款状态常量
const (
RefundStatusPending = 0 // 待处理
RefundStatusSuccess = 1 // 成功
RefundStatusFailed = 2 // 失败
)
// TableName 指定表名
func (Refund) TableName() string {
return "refunds"
}
+36
View File
@@ -0,0 +1,36 @@
// internal/model/settlement.go
package model
import (
"github.com/shopspring/decimal"
)
// Settlement 结算记录
type Settlement struct {
BaseModel
SettleNo string `gorm:"size:32;uniqueIndex;not null" json:"settle_no"`
MerchantID int64 `gorm:"index;not null" json:"merchant_id"`
Amount decimal.Decimal `gorm:"type:decimal(12,2);not null" json:"amount"`
Fee decimal.Decimal `gorm:"type:decimal(12,2);default:0" json:"fee"`
ActualAmount decimal.Decimal `gorm:"type:decimal(12,2)" json:"actual_amount"`
AccountType string `gorm:"size:20" json:"account_type"` // alipay, bank
AccountNo string `gorm:"size:64" json:"account_no"`
AccountName string `gorm:"size:64" json:"account_name"`
Status int8 `gorm:"default:0" json:"status"` // 0待审核 1处理中 2已完成 3已驳回
Remark string `gorm:"size:255" json:"remark"`
// 关联
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
}
func (Settlement) TableName() string {
return "settlements"
}
// 结算状态常量
const (
SettleStatusPending = 0
SettleStatusProcessing = 1
SettleStatusCompleted = 2
SettleStatusRejected = 3
)
+91
View File
@@ -0,0 +1,91 @@
// internal/payment/adapter.go
package payment
import (
"context"
"encoding/json"
"net/http"
"github.com/shopspring/decimal"
)
// PaymentAdapter 统一支付适配器接口
type PaymentAdapter interface {
// CreateOrder 创建支付订单,返回支付参数
CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)
// QueryOrder 查询订单状态
QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error)
// Refund 退款
Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error)
// ParseNotify 解析异步回调通知
ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error)
// NotifySuccess 返回回调成功响应
NotifySuccess() string
}
// CreateOrderRequest 统一下单请求
type CreateOrderRequest struct {
TradeNo string `json:"trade_no"` // 系统订单号
Amount decimal.Decimal `json:"amount"` // 金额(元)
Subject string `json:"subject"` // 商品名称
ClientIP string `json:"client_ip"` // 客户端IP
NotifyURL string `json:"notify_url"` // 异步通知地址
ReturnURL string `json:"return_url"` // 同步跳转地址
PayMethod string `json:"pay_method"` // 支付方式: scan/h5/jsapi/app/web
Extra map[string]string `json:"extra"` // 扩展参数
}
// CreateOrderResponse 统一下单响应
type CreateOrderResponse struct {
PayType string `json:"pay_type"` // redirect(跳转) / qrcode(二维码) / jsapi(JS调起)
PayURL string `json:"pay_url"` // 支付链接或二维码内容
PayParams string `json:"pay_params"` // JSAPI 支付参数(JSON)
}
// QueryOrderResponse 查询订单响应
type QueryOrderResponse struct {
TradeNo string `json:"trade_no"`
ApiTradeNo string `json:"api_trade_no"`
Amount decimal.Decimal `json:"amount"`
Status string `json:"status"` // pending/paid/refunded/closed
PaidAt string `json:"paid_at"`
}
// RefundRequest 退款请求
type RefundRequest struct {
TradeNo string `json:"trade_no"`
RefundNo string `json:"refund_no"`
TotalAmount decimal.Decimal `json:"total_amount"`
Amount decimal.Decimal `json:"amount"`
RefundDesc string `json:"refund_desc"`
}
// RefundResponse 退款响应
type RefundResponse struct {
RefundNo string `json:"refund_no"`
ApiRefundNo string `json:"api_refund_no"`
Status string `json:"status"` // success/processing/failed
ErrorMessage string `json:"error_message,omitempty"`
}
// NotifyResult 统一回调结果
type NotifyResult struct {
TradeNo string `json:"trade_no"` // 系统订单号
ApiTradeNo string `json:"api_trade_no"` // 上游订单号
Amount decimal.Decimal `json:"amount"` // 支付金额
Buyer string `json:"buyer"` // 买家标识
Status string `json:"status"` // success / fail
}
// ChannelConfig 通道配置解析
type ChannelConfig struct {
Raw json.RawMessage
}
func (c *ChannelConfig) Unmarshal(v interface{}) error {
return json.Unmarshal(c.Raw, v)
}
+246
View File
@@ -0,0 +1,246 @@
// internal/payment/alipay.go
package payment
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/alipay"
"github.com/shopspring/decimal"
)
// AlipayConfig 支付宝配置
type AlipayConfig struct {
AppID string `json:"app_id"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"` // 支付宝公钥
AppPublicKey string `json:"app_public_key"` // 应用公钥证书(可选)
IsProd bool `json:"is_prod"` // 是否生产环境
SignType string `json:"sign_type"` // RSA2
}
// AlipayAdapter 支付宝适配器
type AlipayAdapter struct {
client *alipay.Client
config *AlipayConfig
}
// NewAlipayAdapter 创建支付宝适配器
func NewAlipayAdapter(configJSON json.RawMessage) (PaymentAdapter, error) {
// 兼容旧字段名(历史通道配置)
var m map[string]interface{}
_ = json.Unmarshal(configJSON, &m)
if m != nil {
// appid -> app_id
if _, ok := m["app_id"]; !ok {
if v, ok2 := m["appid"]; ok2 {
m["app_id"] = v
}
}
// app_private_key -> private_key
if _, ok := m["private_key"]; !ok {
if v, ok2 := m["app_private_key"]; ok2 {
m["private_key"] = v
}
}
// alipay_public_key -> public_key
if _, ok := m["public_key"]; !ok {
if v, ok2 := m["alipay_public_key"]; ok2 {
m["public_key"] = v
}
}
// cert_mode -> is_prod/sign_type (无法可靠推断,保持默认即可)
if _, ok := m["sign_type"]; !ok {
m["sign_type"] = "RSA2"
}
b, _ := json.Marshal(m)
configJSON = b
}
var cfg AlipayConfig
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, err
}
client, err := alipay.NewClient(cfg.AppID, cfg.PrivateKey, cfg.IsProd)
if err != nil {
return nil, err
}
// 设置支付宝公钥
err = client.SetCertSnByContent(nil, nil, []byte(cfg.PublicKey))
if err != nil {
// 如果证书方式失败,尝试直接设置公钥内容
client.AutoVerifySign([]byte(cfg.PublicKey))
}
return &AlipayAdapter{
client: client,
config: &cfg,
}, nil
}
// CreateOrder 创建支付订单
func (a *AlipayAdapter) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
amount := req.Amount.StringFixed(2)
bm := make(gopay.BodyMap)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("total_amount", amount)
bm.Set("subject", req.Subject)
bm.Set("notify_url", req.NotifyURL)
switch req.PayMethod {
case "scan", "qrcode":
// 扫码支付
bm.Set("product_code", "FACE_TO_FACE_PAYMENT")
resp, err := a.client.TradePrecreate(ctx, bm)
if err != nil {
return nil, err
}
if resp.Response.Code != "10000" {
return nil, errors.New(resp.Response.SubMsg)
}
return &CreateOrderResponse{
PayType: "qrcode",
PayURL: resp.Response.QrCode,
}, nil
case "h5", "wap":
// H5支付
bm.Set("product_code", "QUICK_WAP_WAY")
bm.Set("return_url", req.ReturnURL)
payURL, err := a.client.TradeWapPay(ctx, bm)
if err != nil {
return nil, err
}
return &CreateOrderResponse{
PayType: "redirect",
PayURL: payURL,
}, nil
case "web", "pc":
// PC网页支付
bm.Set("product_code", "FAST_INSTANT_TRADE_PAY")
bm.Set("return_url", req.ReturnURL)
payURL, err := a.client.TradePagePay(ctx, bm)
if err != nil {
return nil, err
}
return &CreateOrderResponse{
PayType: "redirect",
PayURL: payURL,
}, nil
default:
return nil, errors.New("unsupported pay method: " + req.PayMethod)
}
}
// QueryOrder 查询订单
func (a *AlipayAdapter) QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error) {
bm := make(gopay.BodyMap)
bm.Set("out_trade_no", tradeNo)
resp, err := a.client.TradeQuery(ctx, bm)
if err != nil {
return nil, err
}
if resp.Response.Code != "10000" {
return nil, errors.New(resp.Response.SubMsg)
}
status := "pending"
switch resp.Response.TradeStatus {
case "TRADE_SUCCESS", "TRADE_FINISHED":
status = "paid"
case "TRADE_CLOSED":
status = "closed"
}
amount, _ := decimal.NewFromString(resp.Response.TotalAmount)
return &QueryOrderResponse{
TradeNo: tradeNo,
ApiTradeNo: resp.Response.TradeNo,
Amount: amount,
Status: status,
PaidAt: resp.Response.SendPayDate,
}, nil
}
// Refund 退款
func (a *AlipayAdapter) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) {
bm := make(gopay.BodyMap)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("out_request_no", req.RefundNo)
bm.Set("refund_amount", req.Amount.StringFixed(2))
if req.RefundDesc != "" {
bm.Set("refund_reason", req.RefundDesc)
}
resp, err := a.client.TradeRefund(ctx, bm)
if err != nil {
return nil, err
}
if resp.Response.Code != "10000" {
return &RefundResponse{
RefundNo: req.RefundNo,
Status: "failed",
ErrorMessage: resp.Response.SubMsg,
}, nil
}
return &RefundResponse{
RefundNo: req.RefundNo,
ApiRefundNo: resp.Response.TradeNo,
Status: "success",
}, nil
}
// ParseNotify 解析回调通知
func (a *AlipayAdapter) ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
notifyReq, err := alipay.ParseNotifyToBodyMap(r)
if err != nil {
return nil, err
}
// 验签
ok, err := alipay.VerifySign(a.config.PublicKey, notifyReq)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("签名验证失败")
}
tradeStatus := notifyReq.Get("trade_status")
status := "fail"
if tradeStatus == "TRADE_SUCCESS" || tradeStatus == "TRADE_FINISHED" {
status = "success"
}
amount, _ := decimal.NewFromString(notifyReq.Get("total_amount"))
return &NotifyResult{
TradeNo: notifyReq.Get("out_trade_no"),
ApiTradeNo: notifyReq.Get("trade_no"),
Amount: amount,
Buyer: notifyReq.Get("buyer_id"),
Status: status,
}, nil
}
// NotifySuccess 返回成功响应
func (a *AlipayAdapter) NotifySuccess() string {
return "success"
}
func init() {
Register("alipay", NewAlipayAdapter)
}
+36
View File
@@ -0,0 +1,36 @@
// internal/payment/factory.go
package payment
import (
"encoding/json"
"fmt"
)
// AdapterFactory 适配器工厂函数类型
type AdapterFactory func(config json.RawMessage) (PaymentAdapter, error)
// 注册的适配器工厂
var adapters = make(map[string]AdapterFactory)
// Register 注册适配器
func Register(plugin string, factory AdapterFactory) {
adapters[plugin] = factory
}
// NewAdapter 创建适配器实例
func NewAdapter(plugin string, config json.RawMessage) (PaymentAdapter, error) {
factory, ok := adapters[plugin]
if !ok {
return nil, fmt.Errorf("unsupported payment plugin: %s", plugin)
}
return factory(config)
}
// GetSupportedPlugins 获取支持的插件列表
func GetSupportedPlugins() []string {
plugins := make([]string, 0, len(adapters))
for plugin := range adapters {
plugins = append(plugins, plugin)
}
return plugins
}
+178
View File
@@ -0,0 +1,178 @@
// internal/payment/plugin_config.go
package payment
// PluginConfigField 配置字段定义
type PluginConfigField struct {
Key string `json:"key"` // 字段键名
Name string `json:"name"` // 显示名称
Type string `json:"type"` // 类型: input/textarea/select/checkbox
Required bool `json:"required"` // 是否必填
Placeholder string `json:"placeholder"` // 占位符
Note string `json:"note"` // 说明文字
Options map[string]string `json:"options"` // 下拉选项(type=select时使用)
}
// PluginConfig 插件配置信息
type PluginConfig struct {
Name string `json:"name"` // 插件英文名
ShowName string `json:"show_name"` // 显示名称
Author string `json:"author"` // 作者
Link string `json:"link"` // 官方链接
Inputs []PluginConfigField `json:"inputs"` // 配置字段
PayTypes []PayTypeOption `json:"pay_types"` // 支持的支付接口
BindWxmp bool `json:"bind_wxmp"` // 是否绑定微信公众号
BindWxa bool `json:"bind_wxa"` // 是否绑定微信小程序
Note string `json:"note"` // 配置说明
}
// PayTypeOption 支付接口选项
type PayTypeOption struct {
Code string `json:"code"` // 接口代码
Name string `json:"name"` // 接口名称
}
// GetPluginConfigs 获取所有插件配置模板
func GetPluginConfigs() map[string]PluginConfig {
return map[string]PluginConfig{
"alipay": GetAlipayConfig(),
"wechat": GetWechatConfig(),
}
}
// GetAlipayConfig 支付宝配置模板
func GetAlipayConfig() PluginConfig {
return PluginConfig{
Name: "alipay",
ShowName: "支付宝官方支付",
Author: "支付宝",
Link: "https://open.alipay.com",
Inputs: []PluginConfigField{
{
Key: "app_id",
Name: "应用APPID",
Type: "input",
Required: true,
Placeholder: "请输入支付宝开放平台应用ID",
},
{
Key: "private_key",
Name: "应用私钥(RSA2)",
Type: "textarea",
Required: true,
Placeholder: "请输入RSA2私钥(PKCS1或PKCS8格式)",
Note: "用于对请求参数进行签名",
},
{
Key: "public_key",
Name: "支付宝公钥",
Type: "textarea",
Required: true,
Placeholder: "请输入支付宝公钥",
Note: "用于验证回调签名(如使用证书模式,可填支付宝公钥证书内容/或保持与后端策略一致)",
},
{
Key: "is_prod",
Name: "是否生产环境",
Type: "select",
Required: true,
Options: map[string]string{
"false": "否(沙箱/测试)",
"true": "是(生产)",
},
Note: "沙箱请选 false;生产请选择 true",
},
{
Key: "sign_type",
Name: "签名类型",
Type: "select",
Required: true,
Options: map[string]string{
"RSA2": "RSA2",
},
Note: "支付宝接口默认使用 RSA2",
},
},
PayTypes: []PayTypeOption{
{Code: "page", Name: "电脑网站支付"},
{Code: "wap", Name: "手机网站支付"},
{Code: "qrcode", Name: "当面付扫码"},
{Code: "jsapi", Name: "当面付JS"},
{Code: "app", Name: "APP支付"},
},
Note: "选择可用的支付接口,只能选择已经签约的产品。",
}
}
// GetWechatConfig 微信支付配置模板
func GetWechatConfig() PluginConfig {
return PluginConfig{
Name: "wechat",
ShowName: "微信官方支付",
Author: "微信支付",
Link: "https://pay.weixin.qq.com",
Inputs: []PluginConfigField{
{
Key: "app_id",
Name: "公众号/小程序/开放平台AppID",
Type: "input",
Required: true,
Placeholder: "请输入微信应用标识",
},
{
Key: "mch_id",
Name: "商户号",
Type: "input",
Required: true,
Placeholder: "请输入微信支付商户号",
},
{
Key: "api_v3_key",
Name: "APIv3密钥",
Type: "input",
Required: true,
Placeholder: "请输入APIv3密钥(32位)",
Note: "微信支付平台设置的 APIv3 Key",
},
{
Key: "serial_no",
Name: "商户证书序列号",
Type: "input",
Required: true,
Placeholder: "请输入商户API证书序列号",
Note: "位于商户API证书详情页",
},
{
Key: "private_key",
Name: "商户私钥内容",
Type: "textarea",
Required: true,
Placeholder: "请粘贴商户私钥 PEM 内容(含 BEGIN/END 行)",
Note: "用于微信支付 V3 签名,请勿上传证书文件本身",
},
{
Key: "platform_serial_no",
Name: "平台证书序列号(可选)",
Type: "input",
Required: false,
Placeholder: "可留空(自动下载平台证书的场景需配合实现)",
},
{
Key: "platform_cert_content",
Name: "平台证书内容(可选)",
Type: "textarea",
Required: false,
Placeholder: "可留空;如你手动配置平台证书,请粘贴 PEM 内容",
},
},
PayTypes: []PayTypeOption{
{Code: "native", Name: "扫码支付"},
{Code: "jsapi", Name: "公众号支付"},
{Code: "h5", Name: "H5支付"},
{Code: "miniapp", Name: "小程序支付"},
{Code: "app", Name: "APP支付"},
},
BindWxmp: true,
BindWxa: true,
Note: "当前后端使用微信支付 V3:必须配置 APIv3密钥、商户证书序列号、商户私钥内容。",
}
}
+281
View File
@@ -0,0 +1,281 @@
// internal/payment/wechat.go
package payment
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/wechat/v3"
"github.com/shopspring/decimal"
)
// WechatConfig 微信支付配置
type WechatConfig struct {
MchID string `json:"mch_id"` // 商户号
AppID string `json:"app_id"` // 应用ID
APIv3Key string `json:"api_v3_key"` // APIv3密钥
SerialNo string `json:"serial_no"` // 证书序列号
PrivateKey string `json:"private_key"` // 私钥内容
PlatformSerialNo string `json:"platform_serial_no"` // 平台证书序列号
PlatformCertContent string `json:"platform_cert_content"` // 平台证书内容
}
// WechatAdapter 微信支付适配器
type WechatAdapter struct {
client *wechat.ClientV3
config *WechatConfig
}
// NewWechatAdapter 创建微信支付适配器
func NewWechatAdapter(configJSON json.RawMessage) (PaymentAdapter, error) {
// 兼容旧字段名(历史通道配置)
var m map[string]interface{}
_ = json.Unmarshal(configJSON, &m)
if m != nil {
// appid -> app_id
if _, ok := m["app_id"]; !ok {
if v, ok2 := m["appid"]; ok2 {
m["app_id"] = v
}
}
// apiv3_key / apiv3Key -> api_v3_key
if _, ok := m["api_v3_key"]; !ok {
if v, ok2 := m["apiv3_key"]; ok2 {
m["api_v3_key"] = v
} else if v, ok2 := m["apiv3Key"]; ok2 {
m["api_v3_key"] = v
}
}
// cert_serial_no -> serial_no
if _, ok := m["serial_no"]; !ok {
if v, ok2 := m["cert_serial_no"]; ok2 {
m["serial_no"] = v
}
}
b, _ := json.Marshal(m)
configJSON = b
}
var cfg WechatConfig
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, err
}
client, err := wechat.NewClientV3(cfg.MchID, cfg.SerialNo, cfg.APIv3Key, cfg.PrivateKey)
if err != nil {
return nil, err
}
// 设置平台证书
if cfg.PlatformCertContent != "" {
client.SetPlatformCert([]byte(cfg.PlatformCertContent), cfg.PlatformSerialNo)
}
return &WechatAdapter{
client: client,
config: &cfg,
}, nil
}
// CreateOrder 创建支付订单
func (w *WechatAdapter) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
// 金额转为分
amountFen := req.Amount.Mul(decimal.NewFromInt(100)).IntPart()
bm := make(gopay.BodyMap)
bm.Set("appid", w.config.AppID)
bm.Set("mchid", w.config.MchID)
bm.Set("description", req.Subject)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("notify_url", req.NotifyURL)
bm.SetBodyMap("amount", func(b gopay.BodyMap) {
b.Set("total", amountFen)
b.Set("currency", "CNY")
})
switch req.PayMethod {
case "scan", "qrcode", "native", "precreate":
// Native 扫码支付
resp, err := w.client.V3TransactionNative(ctx, bm)
if err != nil {
return nil, err
}
if resp.Code != wechat.Success {
return nil, errors.New(resp.Error)
}
return &CreateOrderResponse{
PayType: "qrcode",
PayURL: resp.Response.CodeUrl,
}, nil
case "h5", "wap":
// H5 支付
bm.SetBodyMap("scene_info", func(b gopay.BodyMap) {
b.Set("payer_client_ip", req.ClientIP)
b.SetBodyMap("h5_info", func(h gopay.BodyMap) {
h.Set("type", "Wap")
})
})
resp, err := w.client.V3TransactionH5(ctx, bm)
if err != nil {
return nil, err
}
if resp.Code != wechat.Success {
return nil, errors.New(resp.Error)
}
// 拼接 redirect_url
payURL := resp.Response.H5Url
if req.ReturnURL != "" {
payURL += "&redirect_url=" + req.ReturnURL
}
return &CreateOrderResponse{
PayType: "redirect",
PayURL: payURL,
}, nil
case "jsapi":
// JSAPI 支付(需要 openid)
openid := req.Extra["openid"]
if openid == "" {
return nil, errors.New("jsapi pay requires openid")
}
bm.SetBodyMap("payer", func(b gopay.BodyMap) {
b.Set("openid", openid)
})
resp, err := w.client.V3TransactionJsapi(ctx, bm)
if err != nil {
return nil, err
}
if resp.Code != wechat.Success {
return nil, errors.New(resp.Error)
}
// 生成 JSAPI 调起参数
jsapiParams, err := w.client.PaySignOfJSAPI(w.config.AppID, resp.Response.PrepayId)
if err != nil {
return nil, err
}
paramsJSON, _ := json.Marshal(jsapiParams)
return &CreateOrderResponse{
PayType: "jsapi",
PayParams: string(paramsJSON),
}, nil
default:
return nil, errors.New("unsupported pay method: " + req.PayMethod)
}
}
// QueryOrder 查询订单
func (w *WechatAdapter) QueryOrder(ctx context.Context, tradeNo string) (*QueryOrderResponse, error) {
resp, err := w.client.V3TransactionQueryOrder(ctx, wechat.OutTradeNo, tradeNo)
if err != nil {
return nil, err
}
if resp.Code != wechat.Success {
return nil, errors.New(resp.Error)
}
status := "pending"
switch resp.Response.TradeState {
case "SUCCESS":
status = "paid"
case "CLOSED", "REVOKED", "PAYERROR":
status = "closed"
case "REFUND":
status = "refunded"
}
// 金额从分转为元
amount := decimal.NewFromInt(int64(resp.Response.Amount.Total)).Div(decimal.NewFromInt(100))
return &QueryOrderResponse{
TradeNo: tradeNo,
ApiTradeNo: resp.Response.TransactionId,
Amount: amount,
Status: status,
PaidAt: resp.Response.SuccessTime,
}, nil
}
// Refund 退款
func (w *WechatAdapter) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) {
// 金额转为分
totalAmountFen := req.TotalAmount.Mul(decimal.NewFromInt(100)).IntPart()
amountFen := req.Amount.Mul(decimal.NewFromInt(100)).IntPart()
bm := make(gopay.BodyMap)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("out_refund_no", req.RefundNo)
bm.Set("reason", req.RefundDesc)
bm.SetBodyMap("amount", func(b gopay.BodyMap) {
b.Set("refund", amountFen)
b.Set("total", totalAmountFen)
b.Set("currency", "CNY")
})
resp, err := w.client.V3Refund(ctx, bm)
if err != nil {
return nil, err
}
if resp.Code != wechat.Success {
return &RefundResponse{
RefundNo: req.RefundNo,
Status: "failed",
ErrorMessage: resp.Error,
}, nil
}
status := "processing"
if resp.Response.Status == "SUCCESS" {
status = "success"
}
return &RefundResponse{
RefundNo: req.RefundNo,
ApiRefundNo: resp.Response.RefundId,
Status: status,
}, nil
}
// ParseNotify 解析回调通知
func (w *WechatAdapter) ParseNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
notifyReq, err := wechat.V3ParseNotify(r)
if err != nil {
return nil, err
}
// 解密支付回调内容
result, err := notifyReq.DecryptPayCipherText(w.config.APIv3Key)
if err != nil {
return nil, err
}
status := "fail"
if result.TradeState == "SUCCESS" {
status = "success"
}
// 金额从分转为元
amount := decimal.NewFromInt(int64(result.Amount.Total)).Div(decimal.NewFromInt(100))
return &NotifyResult{
TradeNo: result.OutTradeNo,
ApiTradeNo: result.TransactionId,
Amount: amount,
Buyer: result.Payer.Openid,
Status: status,
}, nil
}
// NotifySuccess 返回成功响应
func (w *WechatAdapter) NotifySuccess() string {
return `{"code":"SUCCESS","message":"成功"}`
}
func init() {
Register("wechat", NewWechatAdapter)
}
+67
View File
@@ -0,0 +1,67 @@
// internal/repository/admin.go
package repository
import (
"time"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"gorm.io/gorm"
)
type AdminRepository struct {
db *gorm.DB
}
func NewAdminRepository() *AdminRepository {
return &AdminRepository{db: database.Get()}
}
// Create 创建管理员
func (r *AdminRepository) Create(admin *model.Admin) error {
return r.db.Create(admin).Error
}
func (r *AdminRepository) Update(admin *model.Admin) error {
return r.db.Save(admin).Error
}
// GetByID 根据ID获取管理员
func (r *AdminRepository) GetByID(id int64) (*model.Admin, error) {
var admin model.Admin
err := r.db.First(&admin, id).Error
if err != nil {
return nil, err
}
return &admin, nil
}
// GetByUsername 根据用户名获取管理员
func (r *AdminRepository) GetByUsername(username string) (*model.Admin, error) {
var admin model.Admin
err := r.db.Where("username = ?", username).First(&admin).Error
if err != nil {
return nil, err
}
return &admin, nil
}
// UpdateLastLogin 更新最后登录时间
func (r *AdminRepository) UpdateLastLogin(id int64) error {
now := time.Now()
return r.db.Model(&model.Admin{}).Where("id = ?", id).Update("last_login_at", &now).Error
}
// ExistsByUsername 检查用户名是否存在
func (r *AdminRepository) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.Admin{}).Where("username = ?", username).Count(&count).Error
return count > 0, err
}
// Count 统计管理员数量
func (r *AdminRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&model.Admin{}).Count(&count).Error
return count, err
}
+105
View File
@@ -0,0 +1,105 @@
// internal/repository/channel.go
package repository
import (
"strings"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"gorm.io/gorm"
)
type ChannelRepository struct {
db *gorm.DB
}
func NewChannelRepository() *ChannelRepository {
return &ChannelRepository{db: database.Get()}
}
// Create 创建通道
func (r *ChannelRepository) Create(channel *model.Channel) error {
return r.db.Create(channel).Error
}
// GetByID 根据ID获取通道
func (r *ChannelRepository) GetByID(id int64) (*model.Channel, error) {
var channel model.Channel
err := r.db.First(&channel, id).Error
if err != nil {
return nil, err
}
return &channel, nil
}
// Update 更新通道
func (r *ChannelRepository) Update(channel *model.Channel) error {
return r.db.Save(channel).Error
}
// Delete 删除通道
func (r *ChannelRepository) Delete(id int64) error {
return r.db.Delete(&model.Channel{}, id).Error
}
// List 分页查询通道列表
func (r *ChannelRepository) List(page, pageSize int) ([]model.Channel, int64, error) {
var channels []model.Channel
var total int64
err := r.db.Model(&model.Channel{}).Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = r.db.Offset(offset).Limit(pageSize).Order("sort ASC, id ASC").Find(&channels).Error
if err != nil {
return nil, 0, err
}
return channels, total, nil
}
// ListEnabled 获取所有启用的通道
func (r *ChannelRepository) ListEnabled() ([]model.Channel, error) {
var channels []model.Channel
err := r.db.Where("status = ?", 1).Order("sort ASC, id ASC").Find(&channels).Error
return channels, err
}
// GetByPluginAndPayType 根据插件和支付类型获取可用通道
func (r *ChannelRepository) GetByPluginAndPayType(plugin, payType string) (*model.Channel, error) {
var channel model.Channel
query := r.db.Where("plugin = ? AND status = 1", plugin)
if strings.TrimSpace(payType) != "" {
query = query.Where("pay_types LIKE ?", "%"+payType+"%")
}
err := query.Order("sort ASC").First(&channel).Error
if err != nil {
return nil, err
}
return &channel, nil
}
// GetAvailableByPayType 根据支付类型获取可用通道
func (r *ChannelRepository) GetAvailableByPayType(payType string) (*model.Channel, error) {
var channel model.Channel
normalizedPayType := strings.ToLower(strings.TrimSpace(payType))
query := r.db.Where("status = 1")
switch normalizedPayType {
case "wxpay", "wechat":
query = query.Where("plugin = ? OR pay_types LIKE ?", "wechat", "%"+normalizedPayType+"%")
case "alipay":
query = query.Where("plugin = ? OR pay_types LIKE ?", "alipay", "%"+normalizedPayType+"%")
default:
query = query.Where("pay_types LIKE ?", "%"+normalizedPayType+"%")
}
err := query.Order("sort ASC").First(&channel).Error
if err != nil {
return nil, err
}
return &channel, nil
}
+103
View File
@@ -0,0 +1,103 @@
// internal/repository/merchant.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"gorm.io/gorm"
)
type MerchantRepository struct {
db *gorm.DB
}
func NewMerchantRepository() *MerchantRepository {
return &MerchantRepository{db: database.Get()}
}
// Create 创建商户
func (r *MerchantRepository) Create(merchant *model.Merchant) error {
return r.db.Create(merchant).Error
}
// GetByID 根据ID获取商户
func (r *MerchantRepository) GetByID(id int64) (*model.Merchant, error) {
var merchant model.Merchant
err := r.db.First(&merchant, id).Error
if err != nil {
return nil, err
}
return &merchant, nil
}
// GetByUsername 根据用户名获取商户
func (r *MerchantRepository) GetByUsername(username string) (*model.Merchant, error) {
var merchant model.Merchant
err := r.db.Where("username = ?", username).First(&merchant).Error
if err != nil {
return nil, err
}
return &merchant, nil
}
// GetByAPIKey 根据API Key获取商户
func (r *MerchantRepository) GetByAPIKey(apiKey string) (*model.Merchant, error) {
var merchant model.Merchant
err := r.db.Where("api_key = ?", apiKey).First(&merchant).Error
if err != nil {
return nil, err
}
return &merchant, nil
}
// Update 更新商户
func (r *MerchantRepository) Update(merchant *model.Merchant) error {
return r.db.Save(merchant).Error
}
// UpdateBalance 更新余额 (使用事务)
func (r *MerchantRepository) UpdateBalance(tx *gorm.DB, id int64, amount float64) error {
return tx.Model(&model.Merchant{}).Where("id = ?", id).
Update("balance", gorm.Expr("balance + ?", amount)).Error
}
// List 分页查询商户列表
func (r *MerchantRepository) List(page, pageSize int, status *int8) ([]model.Merchant, int64, error) {
var merchants []model.Merchant
var total int64
query := r.db.Model(&model.Merchant{})
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Offset(offset).Limit(pageSize).Order("id DESC").Find(&merchants).Error
if err != nil {
return nil, 0, err
}
return merchants, total, nil
}
// ExistsByUsername 检查用户名是否存在
func (r *MerchantRepository) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.Merchant{}).Where("username = ?", username).Count(&count).Error
return count > 0, err
}
// GetFirst 获取一个商户(按 id 升序),用于测试等场景
func (r *MerchantRepository) GetFirst() (*model.Merchant, error) {
var merchant model.Merchant
err := r.db.Order("id ASC").First(&merchant).Error
if err != nil {
return nil, err
}
return &merchant, nil
}
+159
View File
@@ -0,0 +1,159 @@
// internal/repository/order.go
package repository
import (
"time"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type OrderRepository struct {
db *gorm.DB
}
func NewOrderRepository() *OrderRepository {
return &OrderRepository{db: database.Get()}
}
// Create 创建订单
func (r *OrderRepository) Create(order *model.Order) error {
return r.db.Create(order).Error
}
// GetByID 根据ID获取订单
func (r *OrderRepository) GetByID(id int64) (*model.Order, error) {
var order model.Order
err := r.db.Preload("Merchant").Preload("Channel").First(&order, id).Error
if err != nil {
return nil, err
}
return &order, nil
}
// GetByTradeNo 根据系统订单号获取订单
func (r *OrderRepository) GetByTradeNo(tradeNo string) (*model.Order, error) {
var order model.Order
err := r.db.Preload("Merchant").Preload("Channel").
Where("trade_no = ?", tradeNo).First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// GetByOutTradeNo 根据商户订单号获取订单
func (r *OrderRepository) GetByOutTradeNo(merchantID int64, outTradeNo string) (*model.Order, error) {
var order model.Order
err := r.db.Where("merchant_id = ? AND out_trade_no = ?", merchantID, outTradeNo).
First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// Update 更新订单
func (r *OrderRepository) Update(order *model.Order) error {
return r.db.Save(order).Error
}
// UpdateStatus 更新订单状态
func (r *OrderRepository) UpdateStatus(tradeNo string, status int8) error {
updates := map[string]interface{}{"status": status}
if status == model.OrderStatusPaid {
now := time.Now()
updates["paid_at"] = &now
}
return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(updates).Error
}
// UpdateNotifyStatus 更新通知状态
func (r *OrderRepository) UpdateNotifyStatus(tradeNo string, status int8, nextNotifyAt *time.Time) error {
updates := map[string]interface{}{
"notify_status": status,
"notify_count": gorm.Expr("notify_count + 1"),
}
if nextNotifyAt != nil {
updates["next_notify_at"] = nextNotifyAt
}
return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(updates).Error
}
// UpdatePayInfo 更新支付信息
func (r *OrderRepository) UpdatePayInfo(tradeNo, apiTradeNo, buyer string) error {
now := time.Now()
return r.db.Model(&model.Order{}).Where("trade_no = ?", tradeNo).Updates(map[string]interface{}{
"api_trade_no": apiTradeNo,
"buyer": buyer,
"status": model.OrderStatusPaid,
"paid_at": &now,
}).Error
}
// List 分页查询订单列表
func (r *OrderRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Order, int64, error) {
var orders []model.Order
var total int64
query := r.db.Model(&model.Order{})
if merchantID != nil {
query = query.Where("merchant_id = ?", *merchantID)
}
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("Merchant").Preload("Channel").
Offset(offset).Limit(pageSize).Order("id DESC").Find(&orders).Error
if err != nil {
return nil, 0, err
}
return orders, total, nil
}
// GetPendingNotifyOrders 获取待通知的订单
func (r *OrderRepository) GetPendingNotifyOrders(limit int) ([]model.Order, error) {
var orders []model.Order
err := r.db.Where("status = ? AND notify_status < ? AND (next_notify_at IS NULL OR next_notify_at <= ?)",
model.OrderStatusPaid, model.NotifyStatusSuccess, time.Now()).
Limit(limit).Find(&orders).Error
return orders, err
}
// GetTodayStats 获取今日统计
func (r *OrderRepository) GetTodayStats(merchantID *int64) (int64, decimal.Decimal, error) {
var count int64
today := time.Now().Format("2006-01-02")
query := r.db.Model(&model.Order{}).
Where("status = ? AND DATE(created_at) = ?", model.OrderStatusPaid, today)
if merchantID != nil {
query = query.Where("merchant_id = ?", *merchantID)
}
err := query.Count(&count).Error
if err != nil {
return 0, decimal.Zero, err
}
var result struct {
Total decimal.Decimal
}
err = query.Select("COALESCE(SUM(amount), 0) as total").Scan(&result).Error
if err != nil {
return 0, decimal.Zero, err
}
return count, result.Total, nil
}
+62
View File
@@ -0,0 +1,62 @@
// internal/repository/record.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type BalanceRecordRepository struct {
db *gorm.DB
}
func NewBalanceRecordRepository() *BalanceRecordRepository {
return &BalanceRecordRepository{db: database.Get()}
}
// Create 创建资金记录
func (r *BalanceRecordRepository) Create(record *model.BalanceRecord) error {
return r.db.Create(record).Error
}
// CreateWithTx 在事务中创建资金记录
func (r *BalanceRecordRepository) CreateWithTx(tx *gorm.DB, record *model.BalanceRecord) error {
return tx.Create(record).Error
}
// List 分页查询资金记录
func (r *BalanceRecordRepository) List(page, pageSize int, merchantID int64) ([]model.BalanceRecord, int64, error) {
var records []model.BalanceRecord
var total int64
query := r.db.Model(&model.BalanceRecord{}).Where("merchant_id = ?", merchantID)
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Offset(offset).Limit(pageSize).Order("id DESC").Find(&records).Error
if err != nil {
return nil, 0, err
}
return records, total, nil
}
// AddBalanceRecord 添加资金变动记录 (带事务)
func AddBalanceRecord(tx *gorm.DB, merchantID int64, action int8, amount decimal.Decimal, beforeBalance, afterBalance decimal.Decimal, recordType, tradeNo string) error {
record := &model.BalanceRecord{
MerchantID: merchantID,
Action: action,
Amount: amount,
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Type: recordType,
TradeNo: tradeNo,
}
return tx.Create(record).Error
}
+64
View File
@@ -0,0 +1,64 @@
// internal/repository/refund.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"gorm.io/gorm"
)
type RefundRepository struct {
db *gorm.DB
}
func NewRefundRepository() *RefundRepository {
return &RefundRepository{db: database.Get()}
}
// Create 创建退款单
func (r *RefundRepository) Create(refund *model.Refund) error {
return r.db.Create(refund).Error
}
// GetByRefundNo 根据退款单号查询
func (r *RefundRepository) GetByRefundNo(refundNo string) (*model.Refund, error) {
var refund model.Refund
err := r.db.Where("refund_no = ?", refundNo).First(&refund).Error
return &refund, err
}
// GetByTradeNo 根据订单号查询退款记录
func (r *RefundRepository) GetByTradeNo(tradeNo string) ([]*model.Refund, error) {
var refunds []*model.Refund
err := r.db.Where("trade_no = ?", tradeNo).Find(&refunds).Error
return refunds, err
}
// List 分页查询退款列表
func (r *RefundRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
var refunds []*model.Refund
var total int64
query := r.db.Model(&model.Refund{})
if merchantID != nil {
query = query.Where("merchant_id = ?", *merchantID)
}
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Order("id DESC").Limit(pageSize).Offset(offset).Find(&refunds).Error
return refunds, total, err
}
// Update 更新退款单
func (r *RefundRepository) Update(refund *model.Refund) error {
return r.db.Save(refund).Error
}
+91
View File
@@ -0,0 +1,91 @@
// internal/repository/settlement.go
package repository
import (
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"gorm.io/gorm"
)
type SettlementRepository struct {
db *gorm.DB
}
func NewSettlementRepository() *SettlementRepository {
return &SettlementRepository{db: database.Get()}
}
// Create 创建结算记录
func (r *SettlementRepository) Create(settlement *model.Settlement) error {
return r.db.Create(settlement).Error
}
// GetByID 根据ID获取结算记录
func (r *SettlementRepository) GetByID(id int64) (*model.Settlement, error) {
var settlement model.Settlement
err := r.db.Preload("Merchant").First(&settlement, id).Error
if err != nil {
return nil, err
}
return &settlement, nil
}
// GetBySettleNo 根据结算单号获取记录
func (r *SettlementRepository) GetBySettleNo(settleNo string) (*model.Settlement, error) {
var settlement model.Settlement
err := r.db.Where("settle_no = ?", settleNo).First(&settlement).Error
if err != nil {
return nil, err
}
return &settlement, nil
}
// Update 更新结算记录
func (r *SettlementRepository) Update(settlement *model.Settlement) error {
return r.db.Save(settlement).Error
}
// UpdateStatus 更新结算状态
func (r *SettlementRepository) UpdateStatus(id int64, status int8, remark string) error {
updates := map[string]interface{}{
"status": status,
"remark": remark,
}
return r.db.Model(&model.Settlement{}).Where("id = ?", id).Updates(updates).Error
}
// List 分页查询结算列表
func (r *SettlementRepository) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Settlement, int64, error) {
var settlements []model.Settlement
var total int64
query := r.db.Model(&model.Settlement{})
if merchantID != nil {
query = query.Where("merchant_id = ?", *merchantID)
}
if status != nil {
query = query.Where("status = ?", *status)
}
err := query.Count(&total).Error
if err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err = query.Preload("Merchant").Offset(offset).Limit(pageSize).Order("id DESC").Find(&settlements).Error
if err != nil {
return nil, 0, err
}
return settlements, total, nil
}
// HasPendingSettlement 检查是否有待处理的结算
func (r *SettlementRepository) HasPendingSettlement(merchantID int64) (bool, error) {
var count int64
err := r.db.Model(&model.Settlement{}).
Where("merchant_id = ? AND status IN ?", merchantID, []int8{model.SettleStatusPending, model.SettleStatusProcessing}).
Count(&count).Error
return count > 0, err
}
+123
View File
@@ -0,0 +1,123 @@
// internal/router/router.go
package router
import (
"github.com/example/epay-go/internal/handler/admin"
"github.com/example/epay-go/internal/handler/merchant"
"github.com/example/epay-go/internal/handler/payment"
"github.com/example/epay-go/internal/middleware"
"github.com/example/epay-go/pkg/jwt"
"github.com/gin-gonic/gin"
)
// Setup 设置所有路由
func Setup(r *gin.Engine) {
// 兼容旧版易支付接口
r.GET("/submit.php", payment.LegacySubmit)
r.POST("/submit.php", payment.LegacySubmit)
r.POST("/mapi.php", payment.LegacyCreateOrder)
r.GET("/api.php", payment.LegacyAPI)
// 对外支付 API(无需登录)
payAPI := r.Group("/api/pay")
{
payAPI.POST("/create", payment.CreateOrder)
payAPI.GET("/query", payment.QueryOrder)
payAPI.GET("/status/:trade_no", payment.PublicOrderStatus)
payAPI.POST("/notify/:channel", payment.HandleNotify)
payAPI.GET("/return/:channel", payment.HandleReturn)
// 测试单专用:商户通知兜底(返回 success)
payAPI.GET("/test-notify", payment.TestNotify)
}
// 管理后台 API
adminAPI := r.Group("/api/admin")
{
// 无需认证
adminAPI.POST("/auth/login", admin.Login)
// 需要认证
adminAuth := adminAPI.Group("")
adminAuth.Use(middleware.JWTAuth(jwt.TokenTypeAdmin))
{
adminAuth.POST("/auth/logout", admin.Logout)
adminAuth.PUT("/profile/password", admin.UpdatePassword)
adminAuth.GET("/dashboard", admin.Dashboard)
// 商户管理
adminAuth.GET("/merchants", admin.ListMerchants)
adminAuth.GET("/merchants/:id", admin.GetMerchant)
adminAuth.PUT("/merchants/:id", admin.UpdateMerchant)
adminAuth.PATCH("/merchants/:id/status", admin.UpdateMerchantStatus)
// 订单管理
adminAuth.GET("/orders", admin.ListOrders)
adminAuth.GET("/orders/:trade_no", admin.GetOrder)
adminAuth.POST("/orders/:trade_no/renotify", admin.RenotifyOrder)
// 通道管理
adminAuth.GET("/channels", admin.ListChannels)
adminAuth.POST("/channels", admin.CreateChannel)
adminAuth.GET("/channels/:id", admin.GetChannel)
adminAuth.PUT("/channels/:id", admin.UpdateChannel)
adminAuth.DELETE("/channels/:id", admin.DeleteChannel)
// 插件配置
adminAuth.GET("/plugins", admin.GetAllPluginConfigs)
adminAuth.GET("/plugins/:plugin/config", admin.GetPluginConfig)
// 测试支付
adminAuth.POST("/test-payment", admin.TestPayment)
// 结算管理
adminAuth.GET("/settlements", admin.ListSettlements)
adminAuth.PATCH("/settlements/:id/approve", admin.ApproveSettlement)
adminAuth.PATCH("/settlements/:id/reject", admin.RejectSettlement)
// 退款管理
adminAuth.POST("/refunds", admin.CreateRefund)
adminAuth.GET("/refunds", admin.ListRefunds)
adminAuth.POST("/refunds/:refund_no/process", admin.ProcessRefund)
}
}
// 商户端 API
merchantAPI := r.Group("/api/merchant")
{
// 无需认证
merchantAPI.POST("/auth/login", merchant.Login)
merchantAPI.POST("/auth/register", merchant.Register)
// 需要认证
merchantAuth := merchantAPI.Group("")
merchantAuth.Use(middleware.JWTAuth(jwt.TokenTypeMerchant))
{
merchantAuth.POST("/auth/logout", merchant.Logout)
merchantAuth.GET("/dashboard", merchant.Dashboard)
// 个人信息
merchantAuth.GET("/profile", merchant.GetProfile)
merchantAuth.PUT("/profile", merchant.UpdateProfile)
merchantAuth.PUT("/profile/password", merchant.UpdatePassword)
merchantAuth.POST("/profile/reset-key", merchant.ResetAPIKey)
// 订单
merchantAuth.GET("/orders", merchant.ListOrders)
merchantAuth.GET("/orders/:trade_no", merchant.GetOrder)
// 结算
merchantAuth.GET("/settlements", merchant.ListSettlements)
merchantAuth.POST("/settlements", merchant.ApplySettlement)
// 资金记录
merchantAuth.GET("/records", merchant.ListRecords)
// 退款管理
merchantAuth.POST("/refunds", merchant.CreateRefund)
merchantAuth.GET("/refunds", merchant.ListRefunds)
// 测试支付(商户侧)
merchantAuth.POST("/test-payment", merchant.TestPayment)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// internal/service/admin.go
package service
import (
"errors"
"os"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"gorm.io/gorm"
)
type AdminService struct {
repo *repository.AdminRepository
}
func NewAdminService() *AdminService {
return &AdminService{
repo: repository.NewAdminRepository(),
}
}
// InitDefaultAdmin 初始化默认管理员(如果不存在)
func (s *AdminService) InitDefaultAdmin() error {
count, err := s.repo.Count()
if err != nil {
return err
}
if count > 0 {
return nil // 已有管理员,跳过
}
// 创建默认管理员
username := os.Getenv("DEFAULT_ADMIN_USERNAME")
if username == "" {
username = "epay_admin"
}
password := os.Getenv("DEFAULT_ADMIN_PASSWORD")
if password == "" {
password = "ChangeMe123!"
}
hashedPassword, err := utils.HashPassword(password)
if err != nil {
return err
}
admin := &model.Admin{
Username: username,
Password: hashedPassword,
Role: "super",
}
return s.repo.Create(admin)
}
// AdminLoginRequest 管理员登录请求
type AdminLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login 管理员登录
func (s *AdminService) Login(req *AdminLoginRequest) (*model.Admin, error) {
admin, err := s.repo.GetByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户名或密码错误")
}
return nil, err
}
if !utils.CheckPassword(req.Password, admin.Password) {
return nil, errors.New("用户名或密码错误")
}
// 更新最后登录时间
_ = s.repo.UpdateLastLogin(admin.ID)
return admin, nil
}
// GetByID 根据ID获取管理员
func (s *AdminService) GetByID(id int64) (*model.Admin, error) {
return s.repo.GetByID(id)
}
// UpdatePassword 更新密码
func (s *AdminService) UpdatePassword(id int64, oldPassword, newPassword string) error {
admin, err := s.repo.GetByID(id)
if err != nil {
return err
}
if !utils.CheckPassword(oldPassword, admin.Password) {
return errors.New("原密码错误")
}
hashedPassword, err := utils.HashPassword(newPassword)
if err != nil {
return err
}
admin.Password = hashedPassword
return s.repo.Update(admin)
}
+133
View File
@@ -0,0 +1,133 @@
// internal/service/channel.go
package service
import (
"encoding/json"
"fmt"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
"github.com/shopspring/decimal"
)
type ChannelService struct {
repo *repository.ChannelRepository
}
func NewChannelService() *ChannelService {
return &ChannelService{
repo: repository.NewChannelRepository(),
}
}
// CreateChannelRequest 创建通道请求
type CreateChannelRequest struct {
Name string `json:"name" binding:"required"`
Plugin string `json:"plugin" binding:"required"`
PayTypes string `json:"pay_types"`
AppType string `json:"app_type"` // 支持的接口类型
Config map[string]interface{} `json:"config"`
Rate float64 `json:"rate"`
DailyLimit float64 `json:"daily_limit"`
Status int8 `json:"status"`
Sort int `json:"sort"`
}
// Create 创建通道
func (s *ChannelService) Create(req *CreateChannelRequest) (*model.Channel, error) {
// 验证 AppType 不能为空
if req.AppType == "" {
return nil, fmt.Errorf("请至少选择一个支付接口")
}
configJSON, err := json.Marshal(req.Config)
if err != nil {
return nil, err
}
channel := &model.Channel{
Name: req.Name,
Plugin: req.Plugin,
PayTypes: req.PayTypes,
AppType: req.AppType,
Config: configJSON,
Rate: decimal.NewFromFloat(req.Rate),
DailyLimit: decimal.NewFromFloat(req.DailyLimit),
Status: req.Status,
Sort: req.Sort,
}
if err := s.repo.Create(channel); err != nil {
return nil, err
}
return channel, nil
}
// GetByID 根据ID获取通道
func (s *ChannelService) GetByID(id int64) (*model.Channel, error) {
return s.repo.GetByID(id)
}
// UpdateChannelRequest 更新通道请求
type UpdateChannelRequest struct {
Name string `json:"name"`
PayTypes string `json:"pay_types"`
AppType string `json:"app_type"`
Config map[string]interface{} `json:"config"`
Rate float64 `json:"rate"`
DailyLimit float64 `json:"daily_limit"`
Status int8 `json:"status"`
Sort int `json:"sort"`
}
// Update 更新通道
func (s *ChannelService) Update(id int64, req *UpdateChannelRequest) error {
channel, err := s.repo.GetByID(id)
if err != nil {
return err
}
if req.Name != "" {
channel.Name = req.Name
}
if req.PayTypes != "" {
channel.PayTypes = req.PayTypes
}
if req.AppType != "" {
channel.AppType = req.AppType
}
if req.Config != nil {
configJSON, err := json.Marshal(req.Config)
if err != nil {
return err
}
channel.Config = configJSON
}
channel.Rate = decimal.NewFromFloat(req.Rate)
channel.DailyLimit = decimal.NewFromFloat(req.DailyLimit)
channel.Status = req.Status
channel.Sort = req.Sort
return s.repo.Update(channel)
}
// Delete 删除通道
func (s *ChannelService) Delete(id int64) error {
return s.repo.Delete(id)
}
// List 分页查询通道列表
func (s *ChannelService) List(page, pageSize int) ([]model.Channel, int64, error) {
return s.repo.List(page, pageSize)
}
// ListEnabled 获取所有启用的通道
func (s *ChannelService) ListEnabled() ([]model.Channel, error) {
return s.repo.ListEnabled()
}
// GetAvailableChannel 根据支付类型获取可用通道
func (s *ChannelService) GetAvailableChannel(payType string) (*model.Channel, error) {
return s.repo.GetAvailableByPayType(payType)
}
+150
View File
@@ -0,0 +1,150 @@
// internal/service/merchant.go
package service
import (
"errors"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type MerchantService struct {
repo *repository.MerchantRepository
}
func NewMerchantService() *MerchantService {
return &MerchantService{
repo: repository.NewMerchantRepository(),
}
}
// RegisterRequest 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=4,max=32"`
Password string `json:"password" binding:"required,min=6,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone" binding:"omitempty"`
}
// Register 商户注册
func (s *MerchantService) Register(req *RegisterRequest) (*model.Merchant, error) {
// 检查用户名是否存在
exists, err := s.repo.ExistsByUsername(req.Username)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("用户名已存在")
}
// 加密密码
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
return nil, err
}
// 创建商户
merchant := &model.Merchant{
Username: req.Username,
Password: hashedPassword,
Email: req.Email,
Phone: req.Phone,
ApiKey: utils.GenerateAPIKey(),
Balance: decimal.Zero,
Status: 1,
}
if err := s.repo.Create(merchant); err != nil {
return nil, err
}
return merchant, nil
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login 商户登录
func (s *MerchantService) Login(req *LoginRequest) (*model.Merchant, error) {
merchant, err := s.repo.GetByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户名或密码错误")
}
return nil, err
}
if merchant.Status != 1 {
return nil, errors.New("账号已被禁用")
}
if !utils.CheckPassword(req.Password, merchant.Password) {
return nil, errors.New("用户名或密码错误")
}
return merchant, nil
}
// GetByID 根据ID获取商户
func (s *MerchantService) GetByID(id int64) (*model.Merchant, error) {
return s.repo.GetByID(id)
}
// GetByAPIKey 根据API Key获取商户
func (s *MerchantService) GetByAPIKey(apiKey string) (*model.Merchant, error) {
return s.repo.GetByAPIKey(apiKey)
}
// List 分页查询商户列表
func (s *MerchantService) List(page, pageSize int, status *int8) ([]model.Merchant, int64, error) {
return s.repo.List(page, pageSize, status)
}
// UpdateStatus 更新商户状态
func (s *MerchantService) UpdateStatus(id int64, status int8) error {
merchant, err := s.repo.GetByID(id)
if err != nil {
return err
}
merchant.Status = status
return s.repo.Update(merchant)
}
// ResetAPIKey 重置API密钥
func (s *MerchantService) ResetAPIKey(id int64) (string, error) {
merchant, err := s.repo.GetByID(id)
if err != nil {
return "", err
}
merchant.ApiKey = utils.GenerateAPIKey()
if err := s.repo.Update(merchant); err != nil {
return "", err
}
return merchant.ApiKey, nil
}
// UpdatePassword 更新密码
func (s *MerchantService) UpdatePassword(id int64, oldPassword, newPassword string) error {
merchant, err := s.repo.GetByID(id)
if err != nil {
return err
}
if !utils.CheckPassword(oldPassword, merchant.Password) {
return errors.New("原密码错误")
}
hashedPassword, err := utils.HashPassword(newPassword)
if err != nil {
return err
}
merchant.Password = hashedPassword
return s.repo.Update(merchant)
}
+193
View File
@@ -0,0 +1,193 @@
// internal/service/notify.go
package service
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
)
type NotifyService struct {
orderRepo *repository.OrderRepository
merchantRepo *repository.MerchantRepository
httpClient *http.Client
}
func NewNotifyService() *NotifyService {
return &NotifyService{
orderRepo: repository.NewOrderRepository(),
merchantRepo: repository.NewMerchantRepository(),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// NotifyRetryIntervals 通知重试间隔
var NotifyRetryIntervals = []time.Duration{
0, // 立即
1 * time.Minute, // 1分钟后
3 * time.Minute, // 3分钟后
20 * time.Minute, // 20分钟后
1 * time.Hour, // 1小时后
2 * time.Hour, // 2小时后
}
// SendNotify 发送回调通知
func (s *NotifyService) SendNotify(order *model.Order) error {
if strings.TrimSpace(order.NotifyURL) == "" {
log.Printf("Skip merchant notify: trade_no=%s reason=empty_notify_url", order.TradeNo)
return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusSuccess, nil)
}
// 获取商户信息
merchant, err := s.merchantRepo.GetByID(order.MerchantID)
if err != nil {
return err
}
// 构建通知参数
params := s.buildNotifyParams(order, merchant)
log.Printf("Send merchant notify: trade_no=%s notify_url=%s notify_count=%d", order.TradeNo, order.NotifyURL, order.NotifyCount)
// 发送请求
success := s.doNotify(order.NotifyURL, params)
// 更新通知状态
if success {
log.Printf("Merchant notify success: trade_no=%s notify_url=%s", order.TradeNo, order.NotifyURL)
return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusSuccess, nil)
}
// 通知失败,计算下次重试时间
nextCount := order.NotifyCount + 1
if nextCount >= len(NotifyRetryIntervals) {
// 重试次数用尽
return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusFailed, nil)
}
nextTime := time.Now().Add(NotifyRetryIntervals[nextCount])
log.Printf("Merchant notify scheduled retry: trade_no=%s notify_url=%s next_notify_at=%s", order.TradeNo, order.NotifyURL, nextTime.Format(time.RFC3339))
return s.orderRepo.UpdateNotifyStatus(order.TradeNo, model.NotifyStatusSending, &nextTime)
}
// buildNotifyParams 构建通知参数
func (s *NotifyService) buildNotifyParams(order *model.Order, merchant *model.Merchant) url.Values {
params := url.Values{}
params.Set("pid", fmt.Sprintf("%d", order.MerchantID))
params.Set("trade_no", order.TradeNo)
params.Set("out_trade_no", order.OutTradeNo)
params.Set("type", order.PayType)
params.Set("name", order.Name)
params.Set("money", order.Amount.String())
params.Set("trade_status", "TRADE_SUCCESS")
if order.ApiTradeNo != "" {
params.Set("api_trade_no", order.ApiTradeNo)
}
if order.Buyer != "" {
params.Set("buyer", order.Buyer)
}
// 生成签名
sign := s.generateSign(params, merchant.ApiKey)
params.Set("sign", sign)
params.Set("sign_type", "MD5")
return params
}
// generateSign 生成MD5签名(与原epay兼容)
func (s *NotifyService) generateSign(params url.Values, key string) string {
// 按key排序
var keys []string
for k := range params {
if k != "sign" && k != "sign_type" && params.Get(k) != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
// 拼接字符串
var buf strings.Builder
for i, k := range keys {
if i > 0 {
buf.WriteString("&")
}
buf.WriteString(k)
buf.WriteString("=")
buf.WriteString(params.Get(k))
}
buf.WriteString(key)
// MD5
hash := md5.Sum([]byte(buf.String()))
return hex.EncodeToString(hash[:])
}
// doNotify 执行通知请求
func (s *NotifyService) doNotify(notifyURL string, params url.Values) bool {
// 构建完整URL
fullURL := notifyURL
if strings.Contains(notifyURL, "?") {
fullURL += "&" + params.Encode()
} else {
fullURL += "?" + params.Encode()
}
resp, err := s.httpClient.Get(fullURL)
if err != nil {
log.Printf("Notify request failed: %v", err)
return false
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
response := strings.ToLower(strings.TrimSpace(string(body)))
log.Printf("Merchant notify response: url=%s status=%d body=%s", notifyURL, resp.StatusCode, strings.TrimSpace(string(body)))
// 检查响应是否为 success
return response == "success"
}
// StartNotifyWorker 启动通知工作协程
func (s *NotifyService) StartNotifyWorker(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Notify worker stopped")
return
case <-ticker.C:
s.processNotifyQueue()
}
}
}
// processNotifyQueue 处理通知队列
func (s *NotifyService) processNotifyQueue() {
orders, err := s.orderRepo.GetPendingNotifyOrders(50)
if err != nil {
log.Printf("Get pending notify orders failed: %v", err)
return
}
for _, order := range orders {
if err := s.SendNotify(&order); err != nil {
log.Printf("Send notify failed for %s: %v", order.TradeNo, err)
}
}
}
+297
View File
@@ -0,0 +1,297 @@
// internal/service/order.go
package service
import (
"context"
"errors"
"fmt"
"log"
"strings"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/payment"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"github.com/shopspring/decimal"
)
type OrderService struct {
orderRepo *repository.OrderRepository
channelRepo *repository.ChannelRepository
merchantRepo *repository.MerchantRepository
recordRepo *repository.BalanceRecordRepository
}
func NewOrderService() *OrderService {
return &OrderService{
orderRepo: repository.NewOrderRepository(),
channelRepo: repository.NewChannelRepository(),
merchantRepo: repository.NewMerchantRepository(),
recordRepo: repository.NewBalanceRecordRepository(),
}
}
// CreateOrderRequest 创建订单请求
type CreateOrderRequest struct {
MerchantID int64 `json:"-"`
OutTradeNo string `json:"out_trade_no" binding:"required"`
Amount decimal.Decimal `json:"money" binding:"required"`
Name string `json:"name" binding:"required"`
PayType string `json:"type" binding:"required"` // alipay, wxpay
NotifyURL string `json:"notify_url" binding:"omitempty,url"`
MerchantNotifyURL string `json:"-"`
PlatformBaseURL string `json:"-"`
ReturnURL string `json:"return_url" binding:"omitempty,url"`
ClientIP string `json:"-"`
PayMethod string `json:"pay_method"` // scan, h5, jsapi, web
Extra map[string]string `json:"extra"`
}
// CreateOrderResponse 创建订单响应
type CreateOrderResponse struct {
TradeNo string `json:"trade_no"`
PayType string `json:"pay_type"`
PayURL string `json:"pay_url,omitempty"`
PayParams string `json:"pay_params,omitempty"`
}
// Create 创建订单
func (s *OrderService) Create(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error) {
// 检查商户订单号是否重复
existOrder, _ := s.orderRepo.GetByOutTradeNo(req.MerchantID, req.OutTradeNo)
if existOrder != nil {
return nil, errors.New("商户订单号已存在")
}
// 获取可用通道
channel, err := s.channelRepo.GetAvailableByPayType(req.PayType)
if err != nil {
return nil, errors.New("暂无可用的支付通道")
}
// 创建支付适配器
adapter, err := payment.NewAdapter(channel.Plugin, channel.Config)
if err != nil {
log.Printf("create payment adapter failed: channel_id=%d plugin=%s err=%v", channel.ID, channel.Plugin, err)
return nil, fmt.Errorf("支付通道配置错误: %w", err)
}
// 生成订单号
tradeNo := utils.GenerateTradeNo()
// 计算手续费
fee := req.Amount.Mul(channel.Rate).Round(2)
realAmount := req.Amount
// 创建订单记录
order := &model.Order{
TradeNo: tradeNo,
OutTradeNo: req.OutTradeNo,
MerchantID: req.MerchantID,
ChannelID: channel.ID,
PayType: req.PayType,
Amount: req.Amount,
RealAmount: realAmount,
Fee: fee,
Name: req.Name,
NotifyURL: req.MerchantNotifyURL,
ReturnURL: req.ReturnURL,
ClientIP: req.ClientIP,
Status: model.OrderStatusUnpaid,
NotifyStatus: model.NotifyStatusPending,
}
if err := s.orderRepo.Create(order); err != nil {
return nil, err
}
// 调用支付接口
payMethod := req.PayMethod
if payMethod == "" {
payMethod = "scan" // 默认扫码
}
providerNotifyURL := req.NotifyURL
if req.PlatformBaseURL != "" {
providerNotifyURL = strings.TrimRight(req.PlatformBaseURL, "/") + "/api/pay/notify/" + channel.Plugin
}
payReq := &payment.CreateOrderRequest{
TradeNo: tradeNo,
Amount: realAmount,
Subject: req.Name,
ClientIP: req.ClientIP,
NotifyURL: providerNotifyURL,
ReturnURL: req.ReturnURL,
PayMethod: payMethod,
Extra: req.Extra,
}
payResp, err := adapter.CreateOrder(ctx, payReq)
if err != nil {
return nil, err
}
return &CreateOrderResponse{
TradeNo: tradeNo,
PayType: payResp.PayType,
PayURL: payResp.PayURL,
PayParams: payResp.PayParams,
}, nil
}
// GetByTradeNo 根据订单号获取订单
func (s *OrderService) GetByTradeNo(tradeNo string) (*model.Order, error) {
return s.orderRepo.GetByTradeNo(tradeNo)
}
// GetByOutTradeNo 根据商户订单号获取订单
func (s *OrderService) GetByOutTradeNo(merchantID int64, outTradeNo string) (*model.Order, error) {
return s.orderRepo.GetByOutTradeNo(merchantID, outTradeNo)
}
// List 分页查询订单
func (s *OrderService) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Order, int64, error) {
return s.orderRepo.List(page, pageSize, merchantID, status)
}
// ProcessPayNotify 处理支付回调
func (s *OrderService) ProcessPayNotify(tradeNo, apiTradeNo, buyer string, amount decimal.Decimal) error {
order, err := s.orderRepo.GetByTradeNo(tradeNo)
if err != nil {
return errors.New("订单不存在")
}
if order.Status != model.OrderStatusUnpaid {
return nil // 订单已处理,跳过
}
// 验证金额
if !order.Amount.Equal(amount) {
return errors.New("支付金额不匹配")
}
// 开启事务
tx := database.Get().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 更新订单状态
if err := s.orderRepo.UpdatePayInfo(tradeNo, apiTradeNo, buyer); err != nil {
tx.Rollback()
return err
}
// 获取商户信息
merchant, err := s.merchantRepo.GetByID(order.MerchantID)
if err != nil {
tx.Rollback()
return err
}
// 计算商户收入(订单金额 - 手续费)
income := order.Amount.Sub(order.Fee)
newBalance := merchant.Balance.Add(income)
// 更新商户余额
if err := s.merchantRepo.UpdateBalance(tx, merchant.ID, income.InexactFloat64()); err != nil {
tx.Rollback()
return err
}
// 添加资金记录
if err := repository.AddBalanceRecord(tx, merchant.ID, model.RecordActionIncome, income, merchant.Balance, newBalance, "order_income", tradeNo); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return nil
}
// GetTodayStats 获取今日统计
func (s *OrderService) GetTodayStats(merchantID *int64) (int64, decimal.Decimal, error) {
return s.orderRepo.GetTodayStats(merchantID)
}
// CreateTestOrder 创建测试订单
func (s *OrderService) CreateTestOrder(channelID int64, amount, payType, platformBaseURL string) (*model.Order, interface{}, error) {
// 获取通道信息
channel, err := s.channelRepo.GetByID(channelID)
if err != nil {
return nil, nil, errors.New("通道不存在")
}
// 选择一个真实存在的商户挂载测试订单,避免外键约束失败
merchant, err := s.merchantRepo.GetFirst()
if err != nil {
return nil, nil, errors.New("请先创建一个商户再进行测试支付")
}
// 解析金额
amountDecimal, err := decimal.NewFromString(amount)
if err != nil {
return nil, nil, errors.New("金额格式错误")
}
// 创建测试订单(挂载到现有商户)
tradeNo := utils.GenerateTradeNo()
order := &model.Order{
TradeNo: tradeNo,
OutTradeNo: "TEST" + tradeNo,
MerchantID: merchant.ID,
ChannelID: channelID,
PayType: payType,
Amount: amountDecimal,
RealAmount: amountDecimal,
Fee: decimal.Zero,
Name: "测试支付",
NotifyURL: "",
ReturnURL: "",
Status: model.OrderStatusUnpaid,
NotifyStatus: model.NotifyStatusPending,
}
if err := s.orderRepo.Create(order); err != nil {
return nil, nil, err
}
// 创建支付适配器
adapter, err := payment.NewAdapter(channel.Plugin, channel.Config)
if err != nil {
log.Printf("create test payment adapter failed: channel_id=%d plugin=%s err=%v", channel.ID, channel.Plugin, err)
return nil, nil, fmt.Errorf("支付通道配置错误: %w", err)
}
// 调用支付接口
providerNotifyURL := strings.TrimRight(platformBaseURL, "/") + "/api/pay/notify/" + channel.Plugin
payReq := &payment.CreateOrderRequest{
TradeNo: tradeNo,
Amount: amountDecimal,
Subject: "测试支付",
ClientIP: "127.0.0.1",
NotifyURL: providerNotifyURL,
ReturnURL: "",
PayMethod: payType,
Extra: nil,
}
ctx := context.Background()
payResp, err := adapter.CreateOrder(ctx, payReq)
if err != nil {
return nil, nil, err
}
// 返回订单和支付数据
payData := map[string]interface{}{
"pay_type": payResp.PayType,
"pay_url": payResp.PayURL,
"pay_params": payResp.PayParams,
}
return order, payData, nil
}
+244
View File
@@ -0,0 +1,244 @@
// internal/service/refund.go
package service
import (
"context"
"errors"
"time"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/payment"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type RefundService struct {
refundRepo *repository.RefundRepository
orderRepo *repository.OrderRepository
merchantRepo *repository.MerchantRepository
recordRepo *repository.BalanceRecordRepository
}
func NewRefundService() *RefundService {
return &RefundService{
refundRepo: repository.NewRefundRepository(),
orderRepo: repository.NewOrderRepository(),
merchantRepo: repository.NewMerchantRepository(),
recordRepo: repository.NewBalanceRecordRepository(),
}
}
// CreateRefundRequest 创建退款请求
type CreateRefundRequest struct {
TradeNo string `json:"trade_no" binding:"required"`
Amount string `json:"amount" binding:"required"`
Reason string `json:"reason"`
NotifyURL string `json:"notify_url"`
}
func (s *RefundService) createRefundForOrder(order *model.Order, req *CreateRefundRequest) (*model.Refund, error) {
if order.Status != model.OrderStatusPaid {
return nil, errors.New("订单未支付,无法退款")
}
refundAmount, err := decimal.NewFromString(req.Amount)
if err != nil {
return nil, errors.New("退款金额格式错误")
}
if refundAmount.GreaterThan(order.Amount) {
return nil, errors.New("退款金额不能大于订单金额")
}
existingRefunds, err := s.refundRepo.GetByTradeNo(req.TradeNo)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
totalRefunded := decimal.Zero
for _, r := range existingRefunds {
if r.Status == model.RefundStatusSuccess {
amt, _ := decimal.NewFromString(r.Amount)
totalRefunded = totalRefunded.Add(amt)
}
}
if totalRefunded.Add(refundAmount).GreaterThan(order.Amount) {
return nil, errors.New("退款总额超过订单金额")
}
refund := &model.Refund{
RefundNo: utils.GenerateRefundNo(),
TradeNo: order.TradeNo,
MerchantID: order.MerchantID,
OrderID: order.ID,
ChannelID: order.ChannelID,
Amount: req.Amount,
RefundFee: "0.00",
Reason: req.Reason,
Status: model.RefundStatusPending,
NotifyURL: req.NotifyURL,
}
if err := s.refundRepo.Create(refund); err != nil {
return nil, err
}
return refund, nil
}
// CreateRefund 创建退款单
func (s *RefundService) CreateRefund(merchantID int64, req *CreateRefundRequest) (*model.Refund, error) {
// 查询原订单
order, err := s.orderRepo.GetByTradeNo(req.TradeNo)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
return nil, err
}
// 验证商户权限
if order.MerchantID != merchantID {
return nil, errors.New("无权操作此订单")
}
return s.createRefundForOrder(order, req)
}
// CreateRefundByAdmin 管理员创建退款单
func (s *RefundService) CreateRefundByAdmin(req *CreateRefundRequest) (*model.Refund, error) {
order, err := s.orderRepo.GetByTradeNo(req.TradeNo)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
return nil, err
}
return s.createRefundForOrder(order, req)
}
// ProcessRefund 处理退款(管理员审核)
func (s *RefundService) ProcessRefund(refundNo string, success bool, failReason string) error {
refund, err := s.refundRepo.GetByRefundNo(refundNo)
if err != nil {
return err
}
if refund.Status != model.RefundStatusPending {
return errors.New("退款单已处理")
}
now := time.Now()
if success {
order, err := s.orderRepo.GetByTradeNo(refund.TradeNo)
if err != nil {
return err
}
if order.Status != model.OrderStatusPaid {
return errors.New("订单状态不允许退款")
}
if order.Channel == nil {
return errors.New("订单支付通道不存在")
}
amount, _ := decimal.NewFromString(refund.Amount)
merchant, err := s.merchantRepo.GetByID(refund.MerchantID)
if err != nil {
return err
}
if merchant.Balance.LessThan(amount) {
return errors.New("商户余额不足,无法执行退款")
}
adapter, err := payment.NewAdapter(order.Channel.Plugin, order.Channel.Config)
if err != nil {
return err
}
refundResp, err := adapter.Refund(context.Background(), &payment.RefundRequest{
TradeNo: order.TradeNo,
RefundNo: refund.RefundNo,
TotalAmount: order.Amount,
Amount: amount,
RefundDesc: refund.Reason,
})
if err != nil {
refund.Status = model.RefundStatusFailed
refund.FailReason = err.Error()
refund.ProcessedAt = &now
return s.refundRepo.Update(refund)
}
if refundResp.Status != "success" && refundResp.Status != "processing" {
refund.Status = model.RefundStatusFailed
refund.FailReason = refundResp.ErrorMessage
refund.ProcessedAt = &now
return s.refundRepo.Update(refund)
}
refund.Status = model.RefundStatusSuccess
refund.ApiRefundNo = refundResp.ApiRefundNo
refund.FailReason = ""
refund.ProcessedAt = &now
beforeBalance := merchant.Balance
afterBalance := beforeBalance.Sub(amount)
tx := database.Get().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := s.refundRepo.Update(refund); err != nil {
tx.Rollback()
return err
}
if err := s.orderRepo.UpdateStatus(refund.TradeNo, model.OrderStatusRefund); err != nil {
tx.Rollback()
return err
}
if err := s.merchantRepo.UpdateBalance(tx, merchant.ID, amount.Neg().InexactFloat64()); err != nil {
tx.Rollback()
return err
}
record := &model.BalanceRecord{
MerchantID: refund.MerchantID,
Action: model.RecordActionExpense,
Amount: amount,
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Type: "refund",
TradeNo: refund.RefundNo,
}
if err := s.recordRepo.CreateWithTx(tx, record); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
} else {
refund.Status = model.RefundStatusFailed
refund.FailReason = failReason
refund.ProcessedAt = &now
}
return s.refundRepo.Update(refund)
}
// GetRefundByNo 根据退款单号查询
func (s *RefundService) GetRefundByNo(refundNo string) (*model.Refund, error) {
return s.refundRepo.GetByRefundNo(refundNo)
}
// List 退款列表
func (s *RefundService) List(page, pageSize int, merchantID *int64, status *int8) ([]*model.Refund, int64, error) {
return s.refundRepo.List(page, pageSize, merchantID, status)
}
+231
View File
@@ -0,0 +1,231 @@
// internal/service/settlement.go
package service
import (
"errors"
"github.com/example/epay-go/internal/database"
"github.com/example/epay-go/internal/model"
"github.com/example/epay-go/internal/repository"
"github.com/example/epay-go/pkg/utils"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type SettlementService struct {
settleRepo *repository.SettlementRepository
merchantRepo *repository.MerchantRepository
}
func NewSettlementService() *SettlementService {
return &SettlementService{
settleRepo: repository.NewSettlementRepository(),
merchantRepo: repository.NewMerchantRepository(),
}
}
// ApplyRequest 申请结算请求
type ApplyRequest struct {
MerchantID int64 `json:"-"`
Amount decimal.Decimal `json:"amount" binding:"required"`
AccountType string `json:"account_type" binding:"required,oneof=alipay bank"`
AccountNo string `json:"account_no" binding:"required"`
AccountName string `json:"account_name" binding:"required"`
}
// Apply 申请结算
func (s *SettlementService) Apply(req *ApplyRequest) (*model.Settlement, error) {
// 检查是否有待处理的结算
hasPending, err := s.settleRepo.HasPendingSettlement(req.MerchantID)
if err != nil {
return nil, err
}
if hasPending {
return nil, errors.New("您有待处理的结算申请,请等待处理完成")
}
// 获取商户信息
merchant, err := s.merchantRepo.GetByID(req.MerchantID)
if err != nil {
return nil, err
}
// 检查余额是否充足
if merchant.Balance.LessThan(req.Amount) {
return nil, errors.New("余额不足")
}
// 最小结算金额检查(假设最小10元)
minAmount := decimal.NewFromInt(10)
if req.Amount.LessThan(minAmount) {
return nil, errors.New("最小结算金额为10元")
}
// 计算手续费(假设2%)
feeRate := decimal.NewFromFloat(0.02)
fee := req.Amount.Mul(feeRate).Round(2)
actualAmount := req.Amount.Sub(fee)
// 创建结算记录
settlement := &model.Settlement{
SettleNo: utils.GenerateSettleNo(),
MerchantID: req.MerchantID,
Amount: req.Amount,
Fee: fee,
ActualAmount: actualAmount,
AccountType: req.AccountType,
AccountNo: req.AccountNo,
AccountName: req.AccountName,
Status: model.SettleStatusPending,
}
// 开启事务
tx := database.Get().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 冻结余额
newBalance := merchant.Balance.Sub(req.Amount)
newFrozen := merchant.FrozenBalance.Add(req.Amount)
if err := tx.Model(&model.Merchant{}).Where("id = ?", req.MerchantID).Updates(map[string]interface{}{
"balance": newBalance,
"frozen_balance": newFrozen,
}).Error; err != nil {
tx.Rollback()
return nil, err
}
// 添加资金记录
if err := repository.AddBalanceRecord(tx, req.MerchantID, model.RecordActionExpense, req.Amount, merchant.Balance, newBalance, "settle_freeze", settlement.SettleNo); err != nil {
tx.Rollback()
return nil, err
}
// 创建结算记录
if err := tx.Create(settlement).Error; err != nil {
tx.Rollback()
return nil, err
}
tx.Commit()
return settlement, nil
}
// Approve 审核通过
func (s *SettlementService) Approve(id int64) error {
settlement, err := s.settleRepo.GetByID(id)
if err != nil {
return err
}
if settlement.Status != model.SettleStatusPending {
return errors.New("结算状态不正确")
}
// 更新状态为处理中
return s.settleRepo.UpdateStatus(id, model.SettleStatusProcessing, "审核通过,处理中")
}
// Complete 完成结算
func (s *SettlementService) Complete(id int64) error {
settlement, err := s.settleRepo.GetByID(id)
if err != nil {
return err
}
if settlement.Status != model.SettleStatusProcessing {
return errors.New("结算状态不正确")
}
// 开启事务
tx := database.Get().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 扣除冻结余额
if err := tx.Model(&model.Merchant{}).Where("id = ?", settlement.MerchantID).
Update("frozen_balance", gorm.Expr("frozen_balance - ?", settlement.Amount)).Error; err != nil {
tx.Rollback()
return err
}
// 更新结算状态
if err := s.settleRepo.UpdateStatus(id, model.SettleStatusCompleted, "结算完成"); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return nil
}
// Reject 驳回结算
func (s *SettlementService) Reject(id int64, remark string) error {
settlement, err := s.settleRepo.GetByID(id)
if err != nil {
return err
}
if settlement.Status != model.SettleStatusPending {
return errors.New("结算状态不正确")
}
// 开启事务
tx := database.Get().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 获取商户
merchant, err := s.merchantRepo.GetByID(settlement.MerchantID)
if err != nil {
tx.Rollback()
return err
}
// 解冻余额
newBalance := merchant.Balance.Add(settlement.Amount)
newFrozen := merchant.FrozenBalance.Sub(settlement.Amount)
if err := tx.Model(&model.Merchant{}).Where("id = ?", settlement.MerchantID).Updates(map[string]interface{}{
"balance": newBalance,
"frozen_balance": newFrozen,
}).Error; err != nil {
tx.Rollback()
return err
}
// 添加资金记录
if err := repository.AddBalanceRecord(tx, settlement.MerchantID, model.RecordActionIncome, settlement.Amount, merchant.Balance, newBalance, "settle_unfreeze", settlement.SettleNo); err != nil {
tx.Rollback()
return err
}
// 更新结算状态
if err := s.settleRepo.UpdateStatus(id, model.SettleStatusRejected, remark); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return nil
}
// List 分页查询结算列表
func (s *SettlementService) List(page, pageSize int, merchantID *int64, status *int8) ([]model.Settlement, int64, error) {
return s.settleRepo.List(page, pageSize, merchantID, status)
}
// GetByID 根据ID获取结算记录
func (s *SettlementService) GetByID(id int64) (*model.Settlement, error) {
return s.settleRepo.GetByID(id)
}
+63
View File
@@ -0,0 +1,63 @@
// pkg/jwt/jwt.go
package jwt
import (
"errors"
"time"
"github.com/example/epay-go/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type TokenType string
const (
TokenTypeAdmin TokenType = "admin"
TokenTypeMerchant TokenType = "merchant"
)
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
TokenType TokenType `json:"token_type"`
jwt.RegisteredClaims
}
// GenerateToken 生成 JWT Token
func GenerateToken(userID int64, username string, tokenType TokenType) (string, error) {
cfg := config.Get().JWT
expireTime := time.Now().Add(time.Duration(cfg.ExpireHour) * time.Hour)
claims := Claims{
UserID: userID,
Username: username,
TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expireTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "epay-go",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.Secret))
}
// ParseToken 解析 JWT Token
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.Get().JWT
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.Secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
+111
View File
@@ -0,0 +1,111 @@
// pkg/response/response.go
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
// 错误码定义
const (
CodeSuccess = 0
CodeParamError = 1001
CodeUnauthorized = 1002
CodeForbidden = 1003
CodeNotFound = 1004
CodeServerError = 5000
CodeDatabaseError = 5001
)
// Response 统一响应结构
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
// PageData 分页数据结构
type PageData struct {
List interface{} `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// Success 成功响应
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Msg: "success",
Data: data,
})
}
// SuccessWithMessage 带消息的成功响应
func SuccessWithMessage(c *gin.Context, msg string, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Msg: msg,
Data: data,
})
}
// SuccessPage 分页成功响应
func SuccessPage(c *gin.Context, list interface{}, total int64, page, pageSize int) {
c.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Msg: "success",
Data: PageData{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
},
})
}
// Error 错误响应
func Error(c *gin.Context, code int, msg string) {
c.JSON(http.StatusOK, Response{
Code: code,
Msg: msg,
})
}
// ParamError 参数错误
func ParamError(c *gin.Context, msg string) {
Error(c, CodeParamError, msg)
}
// Unauthorized 未授权
func Unauthorized(c *gin.Context, msg string) {
c.JSON(http.StatusUnauthorized, Response{
Code: CodeUnauthorized,
Msg: msg,
})
}
// Forbidden 禁止访问
func Forbidden(c *gin.Context, msg string) {
c.JSON(http.StatusForbidden, Response{
Code: CodeForbidden,
Msg: msg,
})
}
// NotFound 未找到
func NotFound(c *gin.Context, msg string) {
c.JSON(http.StatusNotFound, Response{
Code: CodeNotFound,
Msg: msg,
})
}
// ServerError 服务器错误
func ServerError(c *gin.Context, msg string) {
c.JSON(http.StatusInternalServerError, Response{
Code: CodeServerError,
Msg: msg,
})
}
+65
View File
@@ -0,0 +1,65 @@
// pkg/sign/sign.go
package sign
import (
"crypto/md5"
"encoding/hex"
"net/url"
"sort"
"strings"
)
// VerifyMD5Sign 验证MD5签名(与原epay兼容)
func VerifyMD5Sign(params url.Values, key, sign string) bool {
// 按key排序
var keys []string
for k := range params {
if k != "sign" && k != "sign_type" && params.Get(k) != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
// 拼接字符串
var buf strings.Builder
for i, k := range keys {
if i > 0 {
buf.WriteString("&")
}
buf.WriteString(k)
buf.WriteString("=")
buf.WriteString(params.Get(k))
}
buf.WriteString(key)
// MD5
hash := md5.Sum([]byte(buf.String()))
expected := hex.EncodeToString(hash[:])
return strings.EqualFold(expected, sign)
}
// GenerateMD5Sign 生成MD5签名
func GenerateMD5Sign(params url.Values, key string) string {
var keys []string
for k := range params {
if k != "sign" && k != "sign_type" && params.Get(k) != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var buf strings.Builder
for i, k := range keys {
if i > 0 {
buf.WriteString("&")
}
buf.WriteString(k)
buf.WriteString("=")
buf.WriteString(params.Get(k))
}
buf.WriteString(key)
hash := md5.Sum([]byte(buf.String()))
return hex.EncodeToString(hash[:])
}
+18
View File
@@ -0,0 +1,18 @@
// pkg/utils/password.go
package utils
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用bcrypt加密密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+39
View File
@@ -0,0 +1,39 @@
// pkg/utils/trade.go
package utils
import (
"crypto/rand"
"fmt"
"time"
)
// GenerateTradeNo 生成订单号 (时间戳 + 随机数,共24位)
func GenerateTradeNo() string {
timestamp := time.Now().Format("20060102150405")
random := make([]byte, 5)
rand.Read(random)
return fmt.Sprintf("%s%x", timestamp, random)
}
// GenerateSettleNo 生成结算单号
func GenerateSettleNo() string {
timestamp := time.Now().Format("20060102150405")
random := make([]byte, 4)
rand.Read(random)
return fmt.Sprintf("S%s%x", timestamp, random)
}
// GenerateAPIKey 生成商户API密钥 (32位)
func GenerateAPIKey() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// GenerateRefundNo 生成退款单号
func GenerateRefundNo() string {
timestamp := time.Now().Format("20060102150405")
random := make([]byte, 4)
rand.Read(random)
return fmt.Sprintf("R%s%x", timestamp, random)
}
+53
View File
@@ -0,0 +1,53 @@
// pkg/utils/utils.go
package utils
import (
"crypto/md5"
"encoding/hex"
"net"
"strings"
"github.com/gin-gonic/gin"
)
// GetClientIP 获取客户端真实IP
func GetClientIP(c *gin.Context) string {
// 优先从 X-Forwarded-For 获取
xff := c.GetHeader("X-Forwarded-For")
if xff != "" {
ips := strings.Split(xff, ",")
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
if net.ParseIP(ip) != nil {
return ip
}
}
}
// 其次从 X-Real-IP 获取
xri := c.GetHeader("X-Real-IP")
if xri != "" && net.ParseIP(xri) != nil {
return xri
}
// 最后使用 RemoteAddr
ip, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
return ip
}
// MD5 计算MD5哈希
func MD5(str string) string {
h := md5.New()
h.Write([]byte(str))
return hex.EncodeToString(h.Sum(nil))
}
// ContainsString 检查字符串切片是否包含指定字符串
func ContainsString(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# epay-go/scripts/deploy.sh
set -e
echo "=== EPay Go 部署脚本 ==="
# 检查环境变量文件
if [ ! -f .env ]; then
echo "创建 .env 文件..."
cp .env.example .env
echo "请编辑 .env 文件配置必要的环境变量"
exit 1
fi
# 构建镜像
echo "构建 Docker 镜像..."
docker-compose build
# 启动服务
echo "启动服务..."
docker-compose up -d
# 等待数据库就绪
echo "等待数据库就绪..."
sleep 10
# 运行数据库迁移
echo "运行数据库迁移..."
docker-compose exec backend ./epay-server migrate
echo "=== 部署完成 ==="
echo "前端访问: http://localhost"
echo "后端 API: http://localhost:8080"
+6
View File
@@ -0,0 +1,6 @@
# epay-go/web/.dockerignore
node_modules
dist
.git
*.log
.env.local
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+26
View File
@@ -0,0 +1,26 @@
# epay-go/web/Dockerfile
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
# 复制 package.json
COPY package*.json ./
RUN npm ci
# 复制源代码并构建
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
# epay-go/web/nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# 静态资源缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 代理
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /submit.php {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /mapi.php {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api.php {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
# 区分 API 请求和前端路由
# API 请求代理到后端
if ($request_method != GET) {
proxy_pass http://backend:8080;
}
# GET 请求如果是 API 也代理
set $is_api 0;
if ($uri ~ ^/admin/api/) {
set $is_api 1;
}
if ($is_api = 1) {
proxy_pass http://backend:8080;
}
# 其他 GET 请求返回前端页面
try_files $uri $uri/ /index.html;
}
location /merchant/ {
# 区分 API 请求和前端路由
# API 请求代理到后端
if ($request_method != GET) {
proxy_pass http://backend:8080;
}
# GET 请求如果是 API 也代理
set $is_api 0;
if ($uri ~ ^/merchant/api/) {
set $is_api 1;
}
if ($is_api = 1) {
proxy_pass http://backend:8080;
}
# 其他 GET 请求返回前端页面
try_files $uri $uri/ /index.html;
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
}
+2275
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more