init
This commit is contained in:
commit
9f5bc33085
138 files changed
+26307
No files matched your search
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"playwright-skill@playwright-skill": true
|
||||
}
|
||||
}
|
||||
@@ -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>\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# epay-go/.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
docs
|
||||
web/node_modules
|
||||
web/dist
|
||||
*.log
|
||||
*.test
|
||||
.env.local
|
||||
@@ -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
@@ -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/
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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 配置
|
||||
```
|
||||
@@ -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 天完成全部功能
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Vendored
+42
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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, "支付完成")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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密钥、商户证书序列号、商户私钥内容。",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
# epay-go/web/.dockerignore
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env.local
|
||||
@@ -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?
|
||||
@@ -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;"]
|
||||
@@ -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).
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2275
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
Reference in new issue
Block a user