commit 9f5bc33085cc82651fa381e8ef76ddf23654036d Author: Ubuntu Date: Sat Mar 28 14:55:39 2026 +0800 init diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d12c67a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "playwright-skill@playwright-skill": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..670a5f0 --- /dev/null +++ b/.claude/settings.local.json @@ -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 \")" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecd48a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# epay-go/.dockerignore +.git +.gitignore +README.md +docs +web/node_modules +web/dist +*.log +*.test +.env.local diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..335c160 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5c082a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b56a8e0 --- /dev/null +++ b/CLAUDE.md @@ -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" +``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..5d72ef2 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,99 @@ +# EPay Go 部署指南 + +## 环境要求 + +- Docker 20.10+ +- Docker Compose 2.0+ +- 2GB+ 内存 +- 10GB+ 磁盘空间 + +## 快速开始 + +### 1. 克隆项目 + +```bash +git clone +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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1593f1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a8ecd2 --- /dev/null +++ b/README.md @@ -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 +``` + + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..49bce4d --- /dev/null +++ b/cmd/server/main.go @@ -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") +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..105df6f --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..2dbe981 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -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 +} diff --git a/deploy/nginx/gateway.prod.conf b/deploy/nginx/gateway.prod.conf new file mode 100644 index 0000000..8cd3701 --- /dev/null +++ b/deploy/nginx/gateway.prod.conf @@ -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; + } +} diff --git a/deploy/nginx/host.prod.conf.example b/deploy/nginx/host.prod.conf.example new file mode 100644 index 0000000..6813121 --- /dev/null +++ b/deploy/nginx/host.prod.conf.example @@ -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; + } +} diff --git a/docker-compose.prod.caddy.yml b/docker-compose.prod.caddy.yml new file mode 100644 index 0000000..7d78cb0 --- /dev/null +++ b/docker-compose.prod.caddy.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40f3680 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/plans/2026-02-07-complete-frontend-implementation.md b/docs/plans/2026-02-07-complete-frontend-implementation.md new file mode 100644 index 0000000..f50a04b --- /dev/null +++ b/docs/plans/2026-02-07-complete-frontend-implementation.md @@ -0,0 +1,1273 @@ +# 完成前端实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**目标:** 修复 App.vue 并补全缺失的管理后台页面,使整个前端系统可以正常运行。 + +**架构:** Vue 3 + TypeScript + Arco Design + Vue Router。需要修复路由渲染机制,并为管理后台创建 CRUD 页面(商户管理、订单管理、通道管理、结算管理)。 + +**技术栈:** Vue 3, TypeScript, Arco Design Vue, Vue Router, Axios + +--- + +## Task 1: 修复 App.vue 启用路由系统 + +**文件:** +- Modify: `web/src/App.vue` + +**步骤 1: 备份并查看当前 App.vue** + +```bash +cat web/src/App.vue +``` + +预期:看到 HelloWorld 组件和 Vite 默认模板 + +**步骤 2: 替换 App.vue 为路由视图** + +```vue + + + + + +``` + +**步骤 3: 删除无用的 HelloWorld 组件** + +```bash +rm web/src/components/HelloWorld.vue +``` + +**步骤 4: 测试路由是否生效** + +```bash +# 在 web 目录启动开发服务器 +cd web && npm run dev +``` + +预期:访问 http://localhost:5173 应该重定向到 /merchant/login,显示商户登录页面(而不是计时器) + +**步骤 5: 提交修复** + +```bash +git add web/src/App.vue web/src/components/HelloWorld.vue +git commit -m "fix: enable router-view in App.vue + +- Replace default Vite template with router-view +- Remove unused HelloWorld component +- Fix routing system not rendering pages + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 2: 创建商户管理页面 + +**文件:** +- Create: `web/src/views/admin/Merchants.vue` + +**步骤 1: 创建商户管理页面** + +```vue + + + + + + +``` + +**步骤 2: 在 admin API 中添加商户管理方法** + +修改 `web/src/api/admin.ts`,添加以下方法(如果不存在): + +```typescript +// 商户管理 +export const getMerchants = (params: { page: number; page_size: number }) => + request.get<{ list: Merchant[]; total: number }>('/admin/merchants', { params }) + +export const createMerchant = (data: any) => + request.post('/admin/merchants', data) + +export const updateMerchant = (id: number, data: any) => + request.put(`/admin/merchants/${id}`, data) + +export const toggleMerchantStatus = (id: number) => + request.put(`/admin/merchants/${id}/toggle-status`) +``` + +**步骤 3: 在 types.ts 中添加 Merchant 类型** + +修改 `web/src/api/types.ts`,添加: + +```typescript +export interface Merchant { + id: number + name: string + contact: string + email: string + phone: string + status: 'active' | 'inactive' + created_at: string +} +``` + +**步骤 4: 测试商户管理页面** + +访问 http://localhost/admin/merchants(需要先登录管理后台) + +预期:看到商户列表表格、新建按钮、编辑/禁用操作按钮 + +**步骤 5: 提交** + +```bash +git add web/src/views/admin/Merchants.vue web/src/api/admin.ts web/src/api/types.ts +git commit -m "feat: add merchant management page + +- Create Merchants.vue with CRUD operations +- Add merchant API methods (list/create/update/toggle) +- Add Merchant type definition + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 3: 创建订单管理页面 + +**文件:** +- Create: `web/src/views/admin/Orders.vue` + +**步骤 1: 创建订单管理页面** + +```vue + + + + + + +``` + +**步骤 2: 在 admin API 中添加订单查询方法** + +修改 `web/src/api/admin.ts`: + +```typescript +// 订单管理 +export const getOrders = (params: any) => + request.get<{ list: Order[]; total: number }>('/admin/orders', { params }) +``` + +**步骤 3: 在 types.ts 中添加 Order 类型** + +修改 `web/src/api/types.ts`: + +```typescript +export interface Order { + id: number + trade_no: string + out_trade_no: string + merchant_id: number + amount: string + channel_code: string + status: 'pending' | 'paid' | 'closed' | 'refunding' | 'refunded' + notify_url?: string + return_url?: string + created_at: string + paid_at?: string +} +``` + +**步骤 4: 测试订单管理页面** + +访问 http://localhost/admin/orders + +预期:看到订单列表、搜索表单、状态筛选、详情按钮 + +**步骤 5: 提交** + +```bash +git add web/src/views/admin/Orders.vue web/src/api/admin.ts web/src/api/types.ts +git commit -m "feat: add order management page + +- Create Orders.vue with search and detail view +- Add order query API method +- Add Order type definition +- Support filtering by trade_no, merchant_id, status + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 4: 创建通道管理页面 + +**文件:** +- Create: `web/src/views/admin/Channels.vue` + +**步骤 1: 创建通道管理页面** + +```vue + + + + + + +``` + +**步骤 2: 在 admin API 中添加通道管理方法** + +修改 `web/src/api/admin.ts`: + +```typescript +// 通道管理 +export const getChannels = (params: { page: number; page_size: number }) => + request.get<{ list: Channel[]; total: number }>('/admin/channels', { params }) + +export const createChannel = (data: any) => + request.post('/admin/channels', data) + +export const updateChannel = (id: number, data: any) => + request.put(`/admin/channels/${id}`, data) + +export const toggleChannelStatus = (id: number) => + request.put(`/admin/channels/${id}/toggle-status`) +``` + +**步骤 3: 在 types.ts 中添加 Channel 类型** + +修改 `web/src/api/types.ts`: + +```typescript +export interface Channel { + id: number + code: string + name: string + type: 'alipay' | 'wechat' + fee_rate: number + config: Record + status: 'active' | 'inactive' +} +``` + +**步骤 4: 测试通道管理页面** + +访问 http://localhost/admin/channels + +预期:看到通道列表、新建按钮、编辑/禁用操作、JSON 配置表单 + +**步骤 5: 提交** + +```bash +git add web/src/views/admin/Channels.vue web/src/api/admin.ts web/src/api/types.ts +git commit -m "feat: add channel management page + +- Create Channels.vue with CRUD operations +- Add channel API methods (list/create/update/toggle) +- Add Channel type definition +- Support JSON config editing with validation + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 5: 创建结算管理页面 + +**文件:** +- Create: `web/src/views/admin/Settlements.vue` + +**步骤 1: 创建结算管理页面** + +```vue + + + + + + +``` + +**步骤 2: 在 admin API 中添加结算管理方法** + +修改 `web/src/api/admin.ts`: + +```typescript +// 结算管理 +export const getSettlements = (params: any) => + request.get<{ list: Settlement[]; total: number }>('/admin/settlements', { params }) + +export const approveSettlement = (id: number) => + request.put(`/admin/settlements/${id}/approve`) + +export const rejectSettlement = (id: number, data: { reason: string }) => + request.put(`/admin/settlements/${id}/reject`, data) +``` + +**步骤 3: 在 types.ts 中添加 Settlement 类型** + +修改 `web/src/api/types.ts`: + +```typescript +export interface Settlement { + id: number + merchant_id: number + amount: string + fee: string + actual_amount: string + status: 'pending' | 'processing' | 'completed' | 'rejected' + bank_card?: string + bank_name?: string + remark?: string + created_at: string + processed_at?: string +} +``` + +**步骤 4: 测试结算管理页面** + +访问 http://localhost/admin/settlements + +预期:看到结算申请列表、审核通过/拒绝按钮、详情查看 + +**步骤 5: 提交** + +```bash +git add web/src/views/admin/Settlements.vue web/src/api/admin.ts web/src/api/types.ts +git commit -m "feat: add settlement management page + +- Create Settlements.vue with approval workflow +- Add settlement API methods (list/approve/reject) +- Add Settlement type definition +- Support filtering by merchant_id and status + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 6: 最终测试和验证 + +**步骤 1: 构建前端检查编译错误** + +```bash +cd web && npm run build +``` + +预期:构建成功,无 TypeScript 错误 + +**步骤 2: 启动开发服务器** + +```bash +cd web && npm run dev +``` + +预期:服务器启动在 http://localhost:5173 + +**步骤 3: 测试所有页面路由** + +访问以下路径并验证: +- `/` → 重定向到 `/merchant/login` ✓ +- `/admin/login` → 管理员登录页面 ✓ +- `/admin/dashboard` → 管理后台仪表盘 ✓ +- `/admin/merchants` → 商户管理页面 ✓ +- `/admin/orders` → 订单管理页面 ✓ +- `/admin/channels` → 通道管理页面 ✓ +- `/admin/settlements` → 结算管理页面 ✓ +- `/merchant/login` → 商户登录页面 ✓ + +**步骤 4: 测试路由守卫** + +尝试直接访问 `/admin/dashboard` 而不登录,应该重定向到 `/admin/login` + +**步骤 5: 最终提交** + +```bash +git add . +git commit -m "chore: complete frontend implementation + +- Fix App.vue to enable router rendering +- Add all admin management pages (merchants/orders/channels/settlements) +- Complete API integration and type definitions +- All routes and navigation working correctly + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## 总结 + +实现完成后,前端系统将包含: + +1. ✅ **路由系统正常工作**(不再显示计时器) +2. ✅ **商户管理页面** - 创建/编辑/启用/禁用商户 +3. ✅ **订单管理页面** - 查询订单、查看详情 +4. ✅ **通道管理页面** - 管理支付通道和配置 +5. ✅ **结算管理页面** - 审核结算申请 + +所有页面都使用 Arco Design 组件库,与现有商户中心页面保持一致的设计风格。 diff --git a/docs/plans/2026-02-07-epay-go-phase1-foundation.md b/docs/plans/2026-02-07-epay-go-phase1-foundation.md new file mode 100644 index 0000000..09acd1c --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-phase1-foundation.md @@ -0,0 +1,1461 @@ +# EPay Go 重构 - 阶段一:基础框架 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 搭建 Go + Vue3 项目基础框架,包括项目结构、数据库连接、Redis 连接、JWT 认证中间件和统一响应格式。 + +**Architecture:** 采用 Gin 框架构建 RESTful API,使用 GORM 作为 ORM,Viper 管理配置,JWT 进行身份认证。前端使用 Vue3 + Vite + Arco Design。 + +**Tech Stack:** Go 1.22+, Gin, GORM, PostgreSQL, Redis, Viper, JWT, Vue3, Vite, Arco Design, Docker + +--- + +## Task 1: 初始化 Go 项目 + +**Files:** +- Create: `epay-go/go.mod` +- Create: `epay-go/cmd/server/main.go` +- Create: `epay-go/.gitignore` + +**Step 1: 创建项目目录结构** + +```bash +mkdir -p d:/project/payment/epay-go +cd d:/project/payment/epay-go +``` + +**Step 2: 初始化 Go 模块** + +Run: `go mod init github.com/example/epay-go` + +**Step 3: 创建 .gitignore** + +```gitignore +# 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/ +``` + +**Step 4: 创建入口文件骨架** + +```go +// cmd/server/main.go +package main + +import ( + "fmt" +) + +func main() { + fmt.Println("EPay Go Server Starting...") +} +``` + +**Step 5: 验证项目可运行** + +Run: `go run cmd/server/main.go` +Expected: `EPay Go Server Starting...` + +**Step 6: 提交** + +```bash +git init +git add . +git commit -m "chore: initialize go project structure" +``` + +--- + +## Task 2: 添加核心依赖 + +**Files:** +- Modify: `epay-go/go.mod` + +**Step 1: 安装核心依赖** + +```bash +cd d:/project/payment/epay-go +go get github.com/gin-gonic/gin@v1.9.1 +go get gorm.io/gorm@v1.25.7 +go get gorm.io/driver/postgres@v1.5.7 +go get github.com/redis/go-redis/v9@v9.5.1 +go get github.com/spf13/viper@v1.18.2 +go get github.com/golang-jwt/jwt/v5@v5.2.1 +go get github.com/shopspring/decimal@v1.3.1 +go get github.com/go-pay/gopay@v1.5.102 +go get golang.org/x/crypto@v0.21.0 +go get github.com/google/uuid@v1.6.0 +``` + +**Step 2: 整理依赖** + +Run: `go mod tidy` + +**Step 3: 验证依赖安装成功** + +Run: `go mod verify` +Expected: `all modules verified` + +**Step 4: 提交** + +```bash +git add go.mod go.sum +git commit -m "chore: add core dependencies" +``` + +--- + +## Task 3: 创建配置管理 + +**Files:** +- Create: `epay-go/internal/config/config.go` +- Create: `epay-go/config.yaml` +- Create: `epay-go/config.example.yaml` + +**Step 1: 创建配置结构体** + +```go +// 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() + + 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 +} +``` + +**Step 2: 创建示例配置文件** + +```yaml +# 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 +``` + +**Step 3: 创建本地配置文件** + +```yaml +# config.yaml +server: + port: 8080 + mode: debug + +database: + host: localhost + port: 5432 + user: epay + password: epay123 + dbname: epay + sslmode: disable + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: dev_jwt_secret_key_12345678 + expire_hour: 168 +``` + +**Step 4: 更新 .gitignore 排除 config.yaml** + +在 .gitignore 中添加: +``` +config.yaml +``` + +**Step 5: 验证配置加载** + +临时修改 main.go 测试: +```go +// cmd/server/main.go +package main + +import ( + "fmt" + "log" + + "github.com/example/epay-go/internal/config" +) + +func main() { + if err := config.Load("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + fmt.Printf("Server will run on port: %d\n", config.Get().Server.Port) +} +``` + +Run: `go run cmd/server/main.go` +Expected: `Server will run on port: 8080` + +**Step 6: 提交** + +```bash +git add . +git commit -m "feat: add configuration management with viper" +``` + +--- + +## Task 4: 创建数据库连接 + +**Files:** +- Create: `epay-go/internal/database/database.go` + +**Step 1: 创建数据库连接模块** + +```go +// 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() +} +``` + +**Step 2: 提交** + +```bash +git add internal/database/database.go +git commit -m "feat: add postgresql database connection with gorm" +``` + +--- + +## Task 5: 创建 Redis 连接 + +**Files:** +- Create: `epay-go/internal/cache/redis.go` + +**Step 1: 创建 Redis 连接模块** + +```go +// 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() +} +``` + +**Step 2: 提交** + +```bash +git add internal/cache/redis.go +git commit -m "feat: add redis connection" +``` + +--- + +## Task 6: 创建统一响应格式 + +**Files:** +- Create: `epay-go/pkg/response/response.go` + +**Step 1: 创建响应工具** + +```go +// 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, + }) +} +``` + +**Step 2: 提交** + +```bash +git add pkg/response/response.go +git commit -m "feat: add unified response format" +``` + +--- + +## Task 7: 创建 JWT 认证中间件 + +**Files:** +- Create: `epay-go/internal/middleware/auth.go` +- Create: `epay-go/pkg/jwt/jwt.go` + +**Step 1: 创建 JWT 工具包** + +```go +// 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") +} +``` + +**Step 2: 创建认证中间件** + +```go +// 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 "" +} +``` + +**Step 3: 提交** + +```bash +git add pkg/jwt/jwt.go internal/middleware/auth.go +git commit -m "feat: add jwt token generation and auth middleware" +``` + +--- + +## Task 8: 创建其他常用中间件 + +**Files:** +- Create: `epay-go/internal/middleware/cors.go` +- Create: `epay-go/internal/middleware/logger.go` +- Create: `epay-go/internal/middleware/recovery.go` + +**Step 1: 创建 CORS 中间件** + +```go +// 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() + } +} +``` + +**Step 2: 创建日志中间件** + +```go +// 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) + } +} +``` + +**Step 3: 创建 Recovery 中间件** + +```go +// 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() + } +} +``` + +**Step 4: 提交** + +```bash +git add internal/middleware/ +git commit -m "feat: add cors, logger and recovery middleware" +``` + +--- + +## Task 9: 创建数据模型 + +**Files:** +- Create: `epay-go/internal/model/base.go` +- Create: `epay-go/internal/model/admin.go` +- Create: `epay-go/internal/model/merchant.go` +- Create: `epay-go/internal/model/channel.go` +- Create: `epay-go/internal/model/order.go` +- Create: `epay-go/internal/model/settlement.go` +- Create: `epay-go/internal/model/record.go` +- Create: `epay-go/internal/model/config.go` + +**Step 1: 创建基础模型** + +```go +// 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:"-"` +} +``` + +**Step 2: 创建管理员模型** + +```go +// 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" +} +``` + +**Step 3: 创建商户模型** + +```go +// 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" +} +``` + +**Step 4: 创建支付通道模型** + +```go +// 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"` // 支持的支付方式,逗号分隔 + 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" +} +``` + +**Step 5: 创建订单模型** + +```go +// 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 +) +``` + +**Step 6: 创建结算模型** + +```go +// 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 +) +``` + +**Step 7: 创建资金记录模型** + +```go +// 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 +) +``` + +**Step 8: 创建系统配置模型** + +```go +// 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" +} +``` + +**Step 9: 提交** + +```bash +git add internal/model/ +git commit -m "feat: add all data models" +``` + +--- + +## Task 10: 创建数据库迁移和主程序入口 + +**Files:** +- Modify: `epay-go/cmd/server/main.go` +- Create: `epay-go/internal/database/migrate.go` + +**Step 1: 创建迁移函数** + +```go +// 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{}, + ) + + if err != nil { + return err + } + + log.Println("Database migrations completed") + return nil +} +``` + +**Step 2: 更新主程序入口** + +```go +// cmd/server/main.go +package main + +import ( + "fmt" + "log" + + "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/gin-gonic/gin" +) + +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() + + // 设置 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"}) + }) + + // TODO: 注册路由 + + // 启动服务 + addr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("Server starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} +``` + +**Step 3: 验证程序可编译** + +Run: `go build -o epay-server ./cmd/server` +Expected: 生成 `epay-server.exe` 文件 + +**Step 4: 提交** + +```bash +git add . +git commit -m "feat: add database migration and complete main entry" +``` + +--- + +## Task 11: 创建 Docker 配置 + +**Files:** +- Create: `epay-go/Dockerfile` +- Create: `epay-go/docker-compose.yml` +- Create: `epay-go/.dockerignore` + +**Step 1: 创建 Dockerfile** + +```dockerfile +# Dockerfile + +# 阶段1: 构建 Go 后端 +FROM golang:1.22-alpine AS backend +WORKDIR /app +RUN apk add --no-cache git +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 + +# 阶段2: 最终镜像 +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=backend /app/epay-server . +COPY --from=backend /app/config.example.yaml ./config.yaml +EXPOSE 8080 +CMD ["./epay-server"] +``` + +**Step 2: 创建 docker-compose.yml** + +```yaml +# docker-compose.yml +version: '3.8' + +services: + app: + build: . + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + - GIN_MODE=release + volumes: + - ./config.yaml:/app/config.yaml:ro + restart: unless-stopped + + postgres: + image: postgres:16-alpine + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: epay + POSTGRES_USER: epay + POSTGRES_PASSWORD: ${DB_PASSWORD:-epay123} + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U epay -d epay"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redisdata:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: + redisdata: +``` + +**Step 3: 创建 .dockerignore** + +``` +# .dockerignore +.git +.gitignore +*.md +.idea +.vscode +*.log +logs/ +.env +.env.* +web/node_modules +web/dist +*.exe +epay-server +``` + +**Step 4: 提交** + +```bash +git add Dockerfile docker-compose.yml .dockerignore +git commit -m "feat: add docker and docker-compose configuration" +``` + +--- + +## Task 12: 初始化 Vue3 前端项目 + +**Files:** +- Create: `epay-go/web/` (Vue3 项目) + +**Step 1: 创建 Vue3 项目** + +```bash +cd d:/project/payment/epay-go +npm create vite@latest web -- --template vue-ts +``` + +**Step 2: 安装依赖** + +```bash +cd web +npm install +npm install @arco-design/web-vue +npm install vue-router@4 pinia axios +npm install -D @types/node +``` + +**Step 3: 配置 Arco Design** + +修改 `web/src/main.ts`: + +```typescript +// web/src/main.ts +import { createApp } from 'vue' +import ArcoVue from '@arco-design/web-vue' +import '@arco-design/web-vue/dist/arco.css' +import App from './App.vue' + +const app = createApp(App) +app.use(ArcoVue) +app.mount('#app') +``` + +**Step 4: 验证前端可运行** + +Run: `npm run dev` +Expected: Vite 开发服务器启动,显示 Arco Design 样式 + +**Step 5: 提交** + +```bash +cd d:/project/payment/epay-go +git add web/ +git commit -m "feat: initialize vue3 frontend with arco design" +``` + +--- + +## 阶段一完成检查清单 + +- [ ] Go 项目初始化完成 +- [ ] 核心依赖安装完成 (Gin, GORM, Redis, Viper, JWT, gopay) +- [ ] 配置管理模块完成 +- [ ] 数据库连接模块完成 +- [ ] Redis 连接模块完成 +- [ ] 统一响应格式完成 +- [ ] JWT 认证中间件完成 +- [ ] CORS/Logger/Recovery 中间件完成 +- [ ] 所有数据模型定义完成 +- [ ] 数据库自动迁移完成 +- [ ] 主程序入口完成 +- [ ] Docker 配置完成 +- [ ] Vue3 前端项目初始化完成 + +--- + +**下一阶段:** 阶段二将实现后端核心业务功能,包括商户模块、支付通道管理、订单模块、支付适配器层等。 diff --git a/docs/plans/2026-02-07-epay-go-phase2-backend-core.md b/docs/plans/2026-02-07-epay-go-phase2-backend-core.md new file mode 100644 index 0000000..058a5ad --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-phase2-backend-core.md @@ -0,0 +1,2698 @@ +# EPay Go 重构 - 阶段二:后端核心业务功能 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现商户模块、支付通道管理、订单模块、支付适配器层(支付宝、微信)、异步回调处理和结算模块。 + +**Architecture:** 采用 handler → service → repository 三层架构,支付渠道使用适配器模式实现统一接口,使用 gopay SDK 对接支付宝和微信支付。 + +**Tech Stack:** Go, Gin, GORM, gopay, bcrypt, UUID + +**前置条件:** 阶段一已完成,项目基础框架已搭建。 + +--- + +## Task 1: 创建通用工具函数 + +**Files:** +- Create: `epay-go/pkg/utils/utils.go` +- Create: `epay-go/pkg/utils/password.go` +- Create: `epay-go/pkg/utils/trade.go` + +**Step 1: 创建通用工具函数** + +```go +// 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 +} +``` + +**Step 2: 创建密码工具** + +```go +// 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 +} +``` + +**Step 3: 创建订单号生成工具** + +```go +// 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) +} +``` + +**Step 4: 提交** + +```bash +cd d:/project/payment/epay-go +git add pkg/utils/ +git commit -m "feat: add utility functions for password, trade no generation" +``` + +--- + +## Task 2: 创建 Repository 层 - 商户 + +**Files:** +- Create: `epay-go/internal/repository/merchant.go` + +**Step 1: 创建商户 Repository** + +```go +// 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 +} +``` + +**Step 2: 提交** + +```bash +git add internal/repository/merchant.go +git commit -m "feat: add merchant repository" +``` + +--- + +## Task 3: 创建 Repository 层 - 管理员、通道、订单 + +**Files:** +- Create: `epay-go/internal/repository/admin.go` +- Create: `epay-go/internal/repository/channel.go` +- Create: `epay-go/internal/repository/order.go` + +**Step 1: 创建管理员 Repository** + +```go +// 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 +} + +// 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 +} +``` + +**Step 2: 创建通道 Repository** + +```go +// internal/repository/channel.go +package repository + +import ( + "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 + err := r.db.Where("plugin = ? AND status = 1 AND pay_types LIKE ?", plugin, "%"+payType+"%"). + 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 + err := r.db.Where("status = 1 AND pay_types LIKE ?", "%"+payType+"%"). + Order("sort ASC").First(&channel).Error + if err != nil { + return nil, err + } + return &channel, nil +} +``` + +**Step 3: 创建订单 Repository** + +```go +// 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 + var amount decimal.Decimal + + 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 +} +``` + +**Step 4: 提交** + +```bash +git add internal/repository/ +git commit -m "feat: add admin, channel, order repositories" +``` + +--- + +## Task 4: 创建 Repository 层 - 结算、资金记录 + +**Files:** +- Create: `epay-go/internal/repository/settlement.go` +- Create: `epay-go/internal/repository/record.go` + +**Step 1: 创建结算 Repository** + +```go +// 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 +} +``` + +**Step 2: 创建资金记录 Repository** + +```go +// 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 +} +``` + +**Step 3: 提交** + +```bash +git add internal/repository/ +git commit -m "feat: add settlement and balance record repositories" +``` + +--- + +## Task 5: 创建 Service 层 - 商户服务 + +**Files:** +- Create: `epay-go/internal/service/merchant.go` + +**Step 1: 创建商户服务** + +```go +// 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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/service/merchant.go +git commit -m "feat: add merchant service with register, login, profile management" +``` + +--- + +## Task 6: 创建 Service 层 - 管理员服务 + +**Files:** +- Create: `epay-go/internal/service/admin.go` + +**Step 1: 创建管理员服务** + +```go +// internal/service/admin.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" + "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 // 已有管理员,跳过 + } + + // 创建默认管理员 + hashedPassword, err := utils.HashPassword("admin123") + if err != nil { + return err + } + + admin := &model.Admin{ + Username: "admin", + 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.Create(admin) // 使用 Create 更新会触发 Save +} +``` + +**Step 2: 提交** + +```bash +git add internal/service/admin.go +git commit -m "feat: add admin service with login and password management" +``` + +--- + +## Task 7: 创建 Service 层 - 通道服务 + +**Files:** +- Create: `epay-go/internal/service/channel.go` + +**Step 1: 创建通道服务** + +```go +// internal/service/channel.go +package service + +import ( + "encoding/json" + + "github.com/example/epay-go/internal/model" + "github.com/example/epay-go/internal/repository" +) + +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"` + 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) { + configJSON, err := json.Marshal(req.Config) + if err != nil { + return nil, err + } + + channel := &model.Channel{ + Name: req.Name, + Plugin: req.Plugin, + PayTypes: req.PayTypes, + 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"` + 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.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) +} +``` + +需要在文件顶部添加 decimal 导入: + +```go +import ( + "encoding/json" + + "github.com/example/epay-go/internal/model" + "github.com/example/epay-go/internal/repository" + "github.com/shopspring/decimal" +) +``` + +**Step 2: 提交** + +```bash +git add internal/service/channel.go +git commit -m "feat: add channel service" +``` + +--- + +## Task 8: 创建支付适配器接口 + +**Files:** +- Create: `epay-go/internal/payment/adapter.go` +- Create: `epay-go/internal/payment/factory.go` + +**Step 1: 创建支付适配器接口** + +```go +// 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"` + 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) +} +``` + +**Step 2: 创建适配器工厂** + +```go +// 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 +} +``` + +**Step 3: 提交** + +```bash +git add internal/payment/ +git commit -m "feat: add payment adapter interface and factory" +``` + +--- + +## Task 9: 创建支付宝适配器 + +**Files:** +- Create: `epay-go/internal/payment/alipay.go` + +**Step 1: 创建支付宝适配器** + +```go +// 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 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 + } + + // 设置支付宝公钥 + if err := client.SetCertSnByContent(nil, nil, []byte(cfg.PublicKey)); err != nil { + // 如果证书方式失败,尝试公钥方式 + client.SetAliPayPublicKey(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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/payment/alipay.go +git commit -m "feat: add alipay payment adapter with gopay" +``` + +--- + +## Task 10: 创建微信支付适配器 + +**Files:** +- Create: `epay-go/internal/payment/wechat.go` + +**Step 1: 创建微信支付适配器** + +```go +// 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 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 扫码支付 + 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) { + // 金额转为分 + 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", amountFen) // 简化处理,实际应查询原订单金额 + 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.DecryptCipherText(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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/payment/wechat.go +git commit -m "feat: add wechat payment adapter with gopay v3" +``` + +--- + +## Task 11: 创建订单服务 + +**Files:** +- Create: `epay-go/internal/service/order.go` + +**Step 1: 创建订单服务** + +```go +// internal/service/order.go +package service + +import ( + "context" + "errors" + + "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:"required,url"` + 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 { + return nil, errors.New("支付通道配置错误") + } + + // 生成订单号 + 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.NotifyURL, + 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" // 默认扫码 + } + + payReq := &payment.CreateOrderRequest{ + TradeNo: tradeNo, + Amount: realAmount, + Subject: req.Name, + ClientIP: req.ClientIP, + NotifyURL: req.NotifyURL, + 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) +} + +// 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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/service/order.go +git commit -m "feat: add order service with create, query, notify processing" +``` + +--- + +## Task 12: 创建结算服务 + +**Files:** +- Create: `epay-go/internal/service/settlement.go` + +**Step 1: 创建结算服务** + +```go +// 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" +) + +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) +} +``` + +需要在顶部添加 gorm 导入: + +```go +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" +) +``` + +**Step 2: 提交** + +```bash +git add internal/service/settlement.go +git commit -m "feat: add settlement service with apply, approve, reject, complete" +``` + +--- + +## Task 13: 创建异步通知服务 + +**Files:** +- Create: `epay-go/internal/service/notify.go` + +**Step 1: 创建通知服务** + +```go +// 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 { + // 获取商户信息 + merchant, err := s.merchantRepo.GetByID(order.MerchantID) + if err != nil { + return err + } + + // 构建通知参数 + params := s.buildNotifyParams(order, merchant) + + // 发送请求 + success := s.doNotify(order.NotifyURL, params) + + // 更新通知状态 + if success { + 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]) + 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))) + + // 检查响应是否为 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) + } + } +} +``` + +**Step 2: 提交** + +```bash +git add internal/service/notify.go +git commit -m "feat: add async notify service with retry mechanism" +``` + +--- + +## Task 14: 更新主程序入口,集成所有服务 + +**Files:** +- Modify: `epay-go/cmd/server/main.go` + +**Step 1: 更新主程序** + +```go +// cmd/server/main.go +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "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/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"}) + }) + + // TODO: 在阶段三注册具体路由 + + // 启动异步通知工作协程 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + notifyService := service.NewNotifyService() + go notifyService.StartNotifyWorker(ctx) + + // 优雅关闭 + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + cancel() + }() + + // 启动服务 + addr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("Server starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} +``` + +**Step 2: 验证编译** + +Run: `cd d:/project/payment/epay-go && go build -o epay-server ./cmd/server` +Expected: 编译成功 + +**Step 3: 提交** + +```bash +git add cmd/server/main.go +git commit -m "feat: integrate all services and notify worker into main" +``` + +--- + +## 阶段二完成检查清单 + +- [ ] 通用工具函数(密码、订单号生成) +- [ ] Repository 层(商户、管理员、通道、订单、结算、资金记录) +- [ ] 商户服务(注册、登录、信息管理) +- [ ] 管理员服务(登录、密码管理) +- [ ] 通道服务(CRUD) +- [ ] 支付适配器接口和工厂 +- [ ] 支付宝适配器(gopay) +- [ ] 微信支付适配器(gopay v3) +- [ ] 订单服务(创建、查询、回调处理) +- [ ] 结算服务(申请、审核、驳回、完成) +- [ ] 异步通知服务(重试机制) +- [ ] 主程序集成 + +--- + +**下一阶段:** 阶段三将实现 API Handler 层,包括管理后台 API、商户端 API 和对外支付 API。 diff --git a/docs/plans/2026-02-07-epay-go-phase3-api-handlers.md b/docs/plans/2026-02-07-epay-go-phase3-api-handlers.md new file mode 100644 index 0000000..6623f0b --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-phase3-api-handlers.md @@ -0,0 +1,1661 @@ +# EPay Go 重构 - 阶段三:API Handler 层 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现管理后台 API、商户端 API 和对外支付 API,完成后端所有接口开发。 + +**Architecture:** Handler 层负责参数校验、调用 Service、返回响应。使用 Gin 路由分组,JWT 中间件保护需要认证的接口。 + +**Tech Stack:** Go, Gin, JWT, Validator + +**前置条件:** 阶段一、阶段二已完成。 + +--- + +## Task 1: 创建路由注册入口 + +**Files:** +- Create: `epay-go/internal/router/router.go` + +**Step 1: 创建路由注册文件** + +```go +// 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) { + // 对外支付 API(无需登录) + payAPI := r.Group("/api/pay") + { + payAPI.POST("/create", payment.CreateOrder) + payAPI.GET("/query", payment.QueryOrder) + payAPI.POST("/notify/:channel", payment.HandleNotify) + payAPI.GET("/return/:channel", payment.HandleReturn) + } + + // 管理后台 API + adminAPI := r.Group("/admin") + { + // 无需认证 + adminAPI.POST("/auth/login", admin.Login) + + // 需要认证 + adminAuth := adminAPI.Group("") + adminAuth.Use(middleware.JWTAuth(jwt.TokenTypeAdmin)) + { + adminAuth.POST("/auth/logout", admin.Logout) + 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("/settlements", admin.ListSettlements) + adminAuth.PATCH("/settlements/:id/approve", admin.ApproveSettlement) + adminAuth.PATCH("/settlements/:id/reject", admin.RejectSettlement) + } + } + + // 商户端 API + merchantAPI := r.Group("/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) + } + } +} +``` + +**Step 2: 提交** + +```bash +cd d:/project/payment/epay-go +git add internal/router/router.go +git commit -m "feat: add router setup with all api routes" +``` + +--- + +## Task 2: 创建管理员认证 Handler + +**Files:** +- Create: `epay-go/internal/handler/admin/auth.go` + +**Step 1: 创建管理员认证 Handler** + +```go +// internal/handler/admin/auth.go +package admin + +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" +) + +var adminService = service.NewAdminService() + +// 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 + } + + 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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/handler/admin/auth.go +git commit -m "feat: add admin auth handler (login/logout)" +``` + +--- + +## Task 3: 创建管理后台仪表盘和商户管理 Handler + +**Files:** +- Create: `epay-go/internal/handler/admin/dashboard.go` +- Create: `epay-go/internal/handler/admin/merchant.go` + +**Step 1: 创建仪表盘 Handler** + +```go +// 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" +) + +var orderService = service.NewOrderService() +var merchantService = service.NewMerchantService() + +// Dashboard 仪表盘数据 +func Dashboard(c *gin.Context) { + // 今日统计 + 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, + }) +} +``` + +**Step 2: 创建商户管理 Handler** + +```go +// internal/handler/admin/merchant.go +package admin + +import ( + "strconv" + + "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 + } + + 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 + } + + 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 + } + + 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 + } + + if err := merchantService.UpdateStatus(id, req.Status); err != nil { + response.ServerError(c, err.Error()) + return + } + + response.Success(c, nil) +} +``` + +**Step 3: 提交** + +```bash +git add internal/handler/admin/ +git commit -m "feat: add admin dashboard and merchant management handlers" +``` + +--- + +## Task 4: 创建管理后台订单和通道管理 Handler + +**Files:** +- Create: `epay-go/internal/handler/admin/order.go` +- Create: `epay-go/internal/handler/admin/channel.go` + +**Step 1: 创建订单管理 Handler** + +```go +// 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" +) + +var notifyService = service.NewNotifyService() + +// 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 + } + + 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") + + 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") + + 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) +} +``` + +**Step 2: 创建通道管理 Handler** + +```go +// internal/handler/admin/channel.go +package admin + +import ( + "strconv" + + "github.com/example/epay-go/internal/service" + "github.com/example/epay-go/pkg/response" + "github.com/gin-gonic/gin" +) + +var channelService = service.NewChannelService() + +// ListChannels 通道列表 +func ListChannels(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + 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 + } + + 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 + } + + 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 + } + + 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 + } + + if err := channelService.Delete(id); err != nil { + response.ServerError(c, "删除通道失败: "+err.Error()) + return + } + + response.Success(c, nil) +} +``` + +**Step 3: 提交** + +```bash +git add internal/handler/admin/ +git commit -m "feat: add admin order and channel management handlers" +``` + +--- + +## Task 5: 创建管理后台结算管理 Handler + +**Files:** +- Create: `epay-go/internal/handler/admin/settlement.go` + +**Step 1: 创建结算管理 Handler** + +```go +// 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" +) + +var settlementService = service.NewSettlementService() + +// 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 + } + + 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 + } + + 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 + } + + if err := settlementService.Reject(id, req.Remark); err != nil { + response.ServerError(c, err.Error()) + return + } + + response.Success(c, nil) +} +``` + +**Step 2: 提交** + +```bash +git add internal/handler/admin/settlement.go +git commit -m "feat: add admin settlement management handler" +``` + +--- + +## Task 6: 创建商户端认证 Handler + +**Files:** +- Create: `epay-go/internal/handler/merchant/auth.go` + +**Step 1: 创建商户认证 Handler** + +```go +// 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" +) + +var merchantService = service.NewMerchantService() + +// 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 + } + + 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 + } + + 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) +} +``` + +**Step 2: 提交** + +```bash +git add internal/handler/merchant/auth.go +git commit -m "feat: add merchant auth handler (register/login/logout)" +``` + +--- + +## Task 7: 创建商户端个人信息和仪表盘 Handler + +**Files:** +- Create: `epay-go/internal/handler/merchant/profile.go` +- Create: `epay-go/internal/handler/merchant/dashboard.go` + +**Step 1: 创建个人信息 Handler** + +```go +// internal/handler/merchant/profile.go +package merchant + +import ( + "github.com/example/epay-go/internal/middleware" + "github.com/example/epay-go/pkg/response" + "github.com/gin-gonic/gin" +) + +// GetProfile 获取个人信息 +func GetProfile(c *gin.Context) { + merchantID := middleware.GetUserID(c) + + 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 + } + + 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 + } + + 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) + + newKey, err := merchantService.ResetAPIKey(merchantID) + if err != nil { + response.ServerError(c, "重置密钥失败") + return + } + + response.Success(c, gin.H{ + "api_key": newKey, + }) +} +``` + +**Step 2: 创建仪表盘 Handler** + +```go +// 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" +) + +var orderService = service.NewOrderService() + +// Dashboard 商户仪表盘 +func Dashboard(c *gin.Context) { + merchantID := middleware.GetUserID(c) + + // 获取商户信息 + 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, + }) +} +``` + +**Step 3: 提交** + +```bash +git add internal/handler/merchant/ +git commit -m "feat: add merchant profile and dashboard handlers" +``` + +--- + +## Task 8: 创建商户端订单、结算、资金记录 Handler + +**Files:** +- Create: `epay-go/internal/handler/merchant/order.go` +- Create: `epay-go/internal/handler/merchant/settlement.go` +- Create: `epay-go/internal/handler/merchant/record.go` + +**Step 1: 创建订单 Handler** + +```go +// internal/handler/merchant/order.go +package merchant + +import ( + "strconv" + + "github.com/example/epay-go/internal/middleware" + "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 + } + + 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") + + order, err := orderService.GetByTradeNo(tradeNo) + if err != nil { + response.NotFound(c, "订单不存在") + return + } + + // 验证订单归属 + if order.MerchantID != merchantID { + response.Forbidden(c, "无权查看此订单") + return + } + + response.Success(c, order) +} +``` + +**Step 2: 创建结算 Handler** + +```go +// 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" +) + +var settlementService = service.NewSettlementService() + +// 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 + } + + 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 + } + + 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) +} +``` + +**Step 3: 创建资金记录 Handler** + +```go +// 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" +) + +var recordRepo = repository.NewBalanceRecordRepository() + +// 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")) + + records, total, err := recordRepo.List(page, pageSize, merchantID) + if err != nil { + response.ServerError(c, "获取资金记录失败") + return + } + + response.SuccessPage(c, records, total, page, pageSize) +} +``` + +**Step 4: 提交** + +```bash +git add internal/handler/merchant/ +git commit -m "feat: add merchant order, settlement, record handlers" +``` + +--- + +## Task 9: 创建对外支付 API Handler + +**Files:** +- Create: `epay-go/internal/handler/payment/create.go` +- Create: `epay-go/internal/handler/payment/query.go` +- Create: `epay-go/internal/handler/payment/notify.go` + +**Step 1: 创建签名验证工具** + +```go +// 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[:]) +} +``` + +**Step 2: 创建订单创建 Handler** + +```go +// internal/handler/payment/create.go +package payment + +import ( + "context" + "net/url" + + "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" +) + +var merchantService = service.NewMerchantService() +var orderService = service.NewOrderService() + +// 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 + } + + // 获取商户信息 + 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 + } + + // 创建订单 + orderReq := &service.CreateOrderRequest{ + MerchantID: merchant.ID, + OutTradeNo: req.OutTradeNo, + Amount: amount, + Name: req.Name, + PayType: req.Type, + NotifyURL: req.NotifyURL, + ReturnURL: req.ReturnURL, + ClientIP: utils.GetClientIP(c), + PayMethod: c.DefaultQuery("pay_method", "scan"), + } + + orderResp, err := orderService.Create(context.Background(), orderReq) + if err != nil { + response.Error(c, response.CodeServerError, err.Error()) + return + } + + response.Success(c, orderResp) +} +``` + +**Step 3: 创建订单查询 Handler** + +```go +// internal/handler/payment/query.go +package payment + +import ( + "net/url" + + "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 + } + + // 获取商户 + merchant, err := merchantService.GetByAPIKey(req.Pid) + if err != nil { + response.Error(c, response.CodeParamError, "商户不存在") + return + } + + // 验证签名 + 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 { + // TODO: 按商户订单号查询 + order, err = orderService.GetByTradeNo(req.OutTradeNo) + } + + if err != nil { + response.NotFound(c, "订单不存在") + return + } + + response.Success(c, order) +} +``` + +**Step 4: 创建回调处理 Handler** + +```go +// 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" +) + +var channelRepo = repository.NewChannelRepository() +var notifyService = service.NewNotifyService() + +// HandleNotify 处理支付回调 +func HandleNotify(c *gin.Context) { + channelPlugin := c.Param("channel") + + // 获取通道配置 + 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)) + } + + 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, "支付完成") +} +``` + +**Step 5: 提交** + +```bash +git add pkg/sign/sign.go internal/handler/payment/ +git commit -m "feat: add payment api handlers (create/query/notify)" +``` + +--- + +## Task 10: 更新主程序入口,注册路由 + +**Files:** +- Modify: `epay-go/cmd/server/main.go` + +**Step 1: 更新主程序** + +```go +// cmd/server/main.go +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "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) + + // 优雅关闭 + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + cancel() + }() + + // 启动服务 + addr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("Server starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} +``` + +**Step 2: 验证编译** + +Run: `cd d:/project/payment/epay-go && go build -o epay-server ./cmd/server` +Expected: 编译成功 + +**Step 3: 提交** + +```bash +git add cmd/server/main.go +git commit -m "feat: register all routes in main entry" +``` + +--- + +## 阶段三完成检查清单 + +- [ ] 路由注册入口 (`internal/router/router.go`) +- [ ] 管理员认证 Handler (login/logout) +- [ ] 管理后台仪表盘和商户管理 Handler +- [ ] 管理后台订单和通道管理 Handler +- [ ] 管理后台结算管理 Handler +- [ ] 商户端认证 Handler (register/login/logout) +- [ ] 商户端个人信息和仪表盘 Handler +- [ ] 商户端订单、结算、资金记录 Handler +- [ ] 对外支付 API Handler (create/query/notify) +- [ ] 签名验证工具 (`pkg/sign/sign.go`) +- [ ] 主程序集成路由 + +--- + +## API 接口汇总 + +### 对外支付 API +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/pay/create` | 创建支付订单 | +| GET | `/api/pay/query` | 查询订单状态 | +| POST | `/api/pay/notify/:channel` | 支付回调 | +| GET | `/api/pay/return/:channel` | 同步跳转 | + +### 管理后台 API +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/admin/auth/login` | 管理员登录 | +| GET | `/admin/dashboard` | 仪表盘 | +| GET | `/admin/merchants` | 商户列表 | +| GET/PUT | `/admin/merchants/:id` | 商户详情/更新 | +| GET | `/admin/orders` | 订单列表 | +| GET/POST | `/admin/channels` | 通道列表/创建 | +| GET | `/admin/settlements` | 结算列表 | +| PATCH | `/admin/settlements/:id/approve` | 审核通过 | + +### 商户端 API +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/merchant/auth/register` | 商户注册 | +| POST | `/merchant/auth/login` | 商户登录 | +| GET | `/merchant/dashboard` | 仪表盘 | +| GET/PUT | `/merchant/profile` | 个人信息 | +| GET | `/merchant/orders` | 订单列表 | +| GET/POST | `/merchant/settlements` | 结算列表/申请 | +| GET | `/merchant/records` | 资金记录 | + +--- + +**下一阶段:** 阶段四将实现 Vue3 前端(管理后台、商户中心、收银台)。 diff --git a/docs/plans/2026-02-07-epay-go-phase4-vue-frontend.md b/docs/plans/2026-02-07-epay-go-phase4-vue-frontend.md new file mode 100644 index 0000000..2827206 --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-phase4-vue-frontend.md @@ -0,0 +1,2375 @@ +# EPay Go 重构 - 阶段四:Vue3 前端 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现管理后台、商户中心、收银台三个前端模块,完成完整的用户界面。 + +**Architecture:** 使用 Vue3 + TypeScript + Arco Design,Pinia 状态管理,Vue Router 路由,Axios 请求封装。采用模块化目录结构,按功能划分页面。 + +**Tech Stack:** Vue3, TypeScript, Vite, Arco Design, Pinia, Vue Router, Axios + +**前置条件:** 阶段一至三已完成,后端 API 已就绪。 + +--- + +## Task 1: 配置项目基础结构 + +**Files:** +- Modify: `epay-go/web/vite.config.ts` +- Modify: `epay-go/web/src/main.ts` +- Create: `epay-go/web/src/env.d.ts` + +**Step 1: 更新 Vite 配置** + +```typescript +// web/vite.config.ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/admin': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/merchant': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +}) +``` + +**Step 2: 更新入口文件** + +```typescript +// web/src/main.ts +import { createApp } from 'vue' +import ArcoVue from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import App from './App.vue' +import router from './router' +import { createPinia } from 'pinia' + +const app = createApp(App) + +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(createPinia()) +app.use(router) + +app.mount('#app') +``` + +**Step 3: 创建类型声明文件** + +```typescript +// web/src/env.d.ts +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} +``` + +**Step 4: 安装额外依赖** + +```bash +cd d:/project/payment/epay-go/web +npm install pinia vue-router@4 axios +npm install -D @types/node +``` + +**Step 5: 提交** + +```bash +cd d:/project/payment/epay-go +git add web/ +git commit -m "feat(web): configure vite, install dependencies" +``` + +--- + +## Task 2: 创建 API 请求封装 + +**Files:** +- Create: `epay-go/web/src/api/request.ts` +- Create: `epay-go/web/src/api/types.ts` + +**Step 1: 创建请求封装** + +```typescript +// web/src/api/request.ts +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { Message } from '@arco-design/web-vue' + +const request: AxiosInstance = axios.create({ + baseURL: '', + timeout: 30000, +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + // 从 localStorage 获取 token + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response: AxiosResponse) => { + const { data } = response + if (data.code === 0) { + return data + } + // 业务错误 + Message.error(data.msg || '请求失败') + return Promise.reject(new Error(data.msg)) + }, + (error) => { + if (error.response) { + const { status, data } = error.response + if (status === 401) { + Message.error('登录已过期,请重新登录') + localStorage.removeItem('token') + window.location.href = '/login' + } else if (status === 403) { + Message.error('无权访问') + } else { + Message.error(data?.msg || '服务器错误') + } + } else { + Message.error('网络错误') + } + return Promise.reject(error) + } +) + +export default request +``` + +**Step 2: 创建类型定义** + +```typescript +// web/src/api/types.ts +// 通用响应 +export interface ApiResponse { + code: number + msg: string + data: T +} + +// 分页响应 +export interface PageData { + list: T[] + total: number + page: number + page_size: number +} + +// 商户 +export interface Merchant { + id: number + username: string + email: string + phone: string + api_key: string + balance: string + frozen_balance: string + status: number + created_at: string +} + +// 订单 +export interface Order { + id: number + trade_no: string + out_trade_no: string + merchant_id: number + channel_id: number + pay_type: string + amount: string + real_amount: string + fee: string + name: string + status: number + notify_status: number + paid_at: string | null + created_at: string +} + +// 通道 +export interface Channel { + id: number + name: string + plugin: string + pay_types: string + config: any + rate: string + daily_limit: string + status: number + sort: number +} + +// 结算 +export interface Settlement { + id: number + settle_no: string + merchant_id: number + amount: string + fee: string + actual_amount: string + account_type: string + account_no: string + account_name: string + status: number + remark: string + created_at: string +} + +// 资金记录 +export interface BalanceRecord { + id: number + merchant_id: number + action: number + amount: string + before_balance: string + after_balance: string + type: string + trade_no: string + created_at: string +} +``` + +**Step 3: 提交** + +```bash +git add web/src/api/ +git commit -m "feat(web): add axios request wrapper and types" +``` + +--- + +## Task 3: 创建管理后台 API + +**Files:** +- Create: `epay-go/web/src/api/admin.ts` + +**Step 1: 创建管理后台 API** + +```typescript +// web/src/api/admin.ts +import request from './request' +import type { ApiResponse, PageData, Merchant, Order, Channel, Settlement } from './types' + +// 登录 +export function adminLogin(data: { username: string; password: string }) { + return request.post('/admin/auth/login', data) +} + +// 仪表盘 +export function getDashboard() { + return request.get('/admin/dashboard') +} + +// 商户列表 +export function getMerchants(params: { page: number; page_size: number; status?: number }) { + return request.get>>('/admin/merchants', { params }) +} + +// 商户详情 +export function getMerchant(id: number) { + return request.get>(`/admin/merchants/${id}`) +} + +// 更新商户状态 +export function updateMerchantStatus(id: number, status: number) { + return request.patch(`/admin/merchants/${id}/status`, { status }) +} + +// 订单列表 +export function getOrders(params: { page: number; page_size: number; merchant_id?: number; status?: number }) { + return request.get>>('/admin/orders', { params }) +} + +// 订单详情 +export function getOrder(tradeNo: string) { + return request.get>(`/admin/orders/${tradeNo}`) +} + +// 重发通知 +export function renotifyOrder(tradeNo: string) { + return request.post(`/admin/orders/${tradeNo}/renotify`) +} + +// 通道列表 +export function getChannels(params: { page: number; page_size: number }) { + return request.get>>('/admin/channels', { params }) +} + +// 创建通道 +export function createChannel(data: Partial) { + return request.post('/admin/channels', data) +} + +// 更新通道 +export function updateChannel(id: number, data: Partial) { + return request.put(`/admin/channels/${id}`, data) +} + +// 删除通道 +export function deleteChannel(id: number) { + return request.delete(`/admin/channels/${id}`) +} + +// 结算列表 +export function getSettlements(params: { page: number; page_size: number; merchant_id?: number; status?: number }) { + return request.get>>('/admin/settlements', { params }) +} + +// 审核通过 +export function approveSettlement(id: number) { + return request.patch(`/admin/settlements/${id}/approve`) +} + +// 驳回 +export function rejectSettlement(id: number, remark: string) { + return request.patch(`/admin/settlements/${id}/reject`, { remark }) +} +``` + +**Step 2: 提交** + +```bash +git add web/src/api/admin.ts +git commit -m "feat(web): add admin api" +``` + +--- + +## Task 4: 创建商户端 API + +**Files:** +- Create: `epay-go/web/src/api/merchant.ts` + +**Step 1: 创建商户端 API** + +```typescript +// web/src/api/merchant.ts +import request from './request' +import type { ApiResponse, PageData, Merchant, Order, Settlement, BalanceRecord } from './types' + +// 注册 +export function register(data: { username: string; password: string; email?: string; phone?: string }) { + return request.post('/merchant/auth/register', data) +} + +// 登录 +export function login(data: { username: string; password: string }) { + return request.post('/merchant/auth/login', data) +} + +// 仪表盘 +export function getDashboard() { + return request.get('/merchant/dashboard') +} + +// 获取个人信息 +export function getProfile() { + return request.get>('/merchant/profile') +} + +// 更新个人信息 +export function updateProfile(data: { email?: string; phone?: string }) { + return request.put('/merchant/profile', data) +} + +// 修改密码 +export function updatePassword(data: { old_password: string; new_password: string }) { + return request.put('/merchant/profile/password', data) +} + +// 重置API密钥 +export function resetApiKey() { + return request.post>('/merchant/profile/reset-key') +} + +// 订单列表 +export function getOrders(params: { page: number; page_size: number; status?: number }) { + return request.get>>('/merchant/orders', { params }) +} + +// 订单详情 +export function getOrder(tradeNo: string) { + return request.get>(`/merchant/orders/${tradeNo}`) +} + +// 结算列表 +export function getSettlements(params: { page: number; page_size: number; status?: number }) { + return request.get>>('/merchant/settlements', { params }) +} + +// 申请结算 +export function applySettlement(data: { + amount: string + account_type: string + account_no: string + account_name: string +}) { + return request.post('/merchant/settlements', data) +} + +// 资金记录 +export function getRecords(params: { page: number; page_size: number }) { + return request.get>>('/merchant/records', { params }) +} +``` + +**Step 2: 提交** + +```bash +git add web/src/api/merchant.ts +git commit -m "feat(web): add merchant api" +``` + +--- + +## Task 5: 创建路由配置 + +**Files:** +- Create: `epay-go/web/src/router/index.ts` + +**Step 1: 创建路由配置** + +```typescript +// web/src/router/index.ts +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + // 首页重定向 + { + path: '/', + redirect: '/merchant/login', + }, + + // 管理后台 + { + path: '/admin/login', + name: 'AdminLogin', + component: () => import('@/views/admin/Login.vue'), + meta: { title: '管理员登录' }, + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + meta: { requiresAuth: true, authType: 'admin' }, + children: [ + { + path: '', + redirect: '/admin/dashboard', + }, + { + path: 'dashboard', + name: 'AdminDashboard', + component: () => import('@/views/admin/Dashboard.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'merchants', + name: 'AdminMerchants', + component: () => import('@/views/admin/Merchants.vue'), + meta: { title: '商户管理' }, + }, + { + path: 'orders', + name: 'AdminOrders', + component: () => import('@/views/admin/Orders.vue'), + meta: { title: '订单管理' }, + }, + { + path: 'channels', + name: 'AdminChannels', + component: () => import('@/views/admin/Channels.vue'), + meta: { title: '通道管理' }, + }, + { + path: 'settlements', + name: 'AdminSettlements', + component: () => import('@/views/admin/Settlements.vue'), + meta: { title: '结算管理' }, + }, + ], + }, + + // 商户中心 + { + path: '/merchant/login', + name: 'MerchantLogin', + component: () => import('@/views/merchant/Login.vue'), + meta: { title: '商户登录' }, + }, + { + path: '/merchant/register', + name: 'MerchantRegister', + component: () => import('@/views/merchant/Register.vue'), + meta: { title: '商户注册' }, + }, + { + path: '/merchant', + component: () => import('@/layouts/MerchantLayout.vue'), + meta: { requiresAuth: true, authType: 'merchant' }, + children: [ + { + path: '', + redirect: '/merchant/dashboard', + }, + { + path: 'dashboard', + name: 'MerchantDashboard', + component: () => import('@/views/merchant/Dashboard.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'orders', + name: 'MerchantOrders', + component: () => import('@/views/merchant/Orders.vue'), + meta: { title: '订单管理' }, + }, + { + path: 'settlements', + name: 'MerchantSettlements', + component: () => import('@/views/merchant/Settlements.vue'), + meta: { title: '结算管理' }, + }, + { + path: 'records', + name: 'MerchantRecords', + component: () => import('@/views/merchant/Records.vue'), + meta: { title: '资金记录' }, + }, + { + path: 'profile', + name: 'MerchantProfile', + component: () => import('@/views/merchant/Profile.vue'), + meta: { title: '个人信息' }, + }, + ], + }, + + // 收银台 + { + path: '/cashier/:tradeNo', + name: 'Cashier', + component: () => import('@/views/cashier/Index.vue'), + meta: { title: '收银台' }, + }, + + // 404 + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue'), + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + // 设置页面标题 + document.title = (to.meta.title as string) || 'EPay' + + if (to.meta.requiresAuth) { + const authType = to.meta.authType as string + const tokenKey = authType === 'admin' ? 'admin_token' : 'merchant_token' + const token = localStorage.getItem(tokenKey) + + if (!token) { + const loginPath = authType === 'admin' ? '/admin/login' : '/merchant/login' + next({ path: loginPath, query: { redirect: to.fullPath } }) + return + } + } + + next() +}) + +export default router +``` + +**Step 2: 提交** + +```bash +git add web/src/router/ +git commit -m "feat(web): add vue router with admin and merchant routes" +``` + +--- + +## Task 6: 创建布局组件 + +**Files:** +- Create: `epay-go/web/src/layouts/AdminLayout.vue` +- Create: `epay-go/web/src/layouts/MerchantLayout.vue` + +**Step 1: 创建管理后台布局** + +```vue + + + + + + +``` + +**Step 2: 创建商户中心布局** + +```vue + + + + + + +``` + +**Step 3: 提交** + +```bash +git add web/src/layouts/ +git commit -m "feat(web): add admin and merchant layout components" +``` + +--- + +## Task 7: 创建登录页面 + +**Files:** +- Create: `epay-go/web/src/views/admin/Login.vue` +- Create: `epay-go/web/src/views/merchant/Login.vue` +- Create: `epay-go/web/src/views/merchant/Register.vue` + +**Step 1: 创建管理员登录页** + +```vue + + + + + + +``` + +**Step 2: 创建商户登录页** + +```vue + + + + + + +``` + +**Step 3: 创建商户注册页** + +```vue + + + + + + +``` + +**Step 4: 提交** + +```bash +git add web/src/views/ +git commit -m "feat(web): add login and register pages" +``` + +--- + +## Task 8: 创建管理后台页面 + +**Files:** +- Create: `epay-go/web/src/views/admin/Dashboard.vue` +- Create: `epay-go/web/src/views/admin/Merchants.vue` +- Create: `epay-go/web/src/views/admin/Orders.vue` +- Create: `epay-go/web/src/views/admin/Channels.vue` +- Create: `epay-go/web/src/views/admin/Settlements.vue` + +**Step 1: 创建仪表盘页面** + +```vue + + + + + + +``` + +**Step 2: 创建商户管理页面** + +```vue + + + + +``` + +**Step 3: 创建订单管理页面** + +```vue + + + + +``` + +**Step 4: 创建通道管理页面** + +```vue + + + + +``` + +**Step 5: 创建结算管理页面** + +```vue + + + + +``` + +**Step 6: 提交** + +```bash +git add web/src/views/admin/ +git commit -m "feat(web): add admin dashboard, merchants, orders, channels, settlements pages" +``` + +--- + +## Task 9: 创建商户端页面 + +**Files:** +- Create: `epay-go/web/src/views/merchant/Dashboard.vue` +- Create: `epay-go/web/src/views/merchant/Orders.vue` +- Create: `epay-go/web/src/views/merchant/Settlements.vue` +- Create: `epay-go/web/src/views/merchant/Records.vue` +- Create: `epay-go/web/src/views/merchant/Profile.vue` + +**Step 1: 创建商户仪表盘** + +```vue + + + + +``` + +**Step 2: 创建订单页面** + +```vue + + + + +``` + +**Step 3: 创建结算页面** + +```vue + + + + +``` + +**Step 4: 创建资金记录页面** + +```vue + + + + +``` + +**Step 5: 创建个人信息页面** + +```vue + + + + +``` + +**Step 6: 提交** + +```bash +git add web/src/views/merchant/ +git commit -m "feat(web): add merchant dashboard, orders, settlements, records, profile pages" +``` + +--- + +## Task 10: 创建收银台和404页面 + +**Files:** +- Create: `epay-go/web/src/views/cashier/Index.vue` +- Create: `epay-go/web/src/views/NotFound.vue` +- Modify: `epay-go/web/src/App.vue` + +**Step 1: 创建收银台页面** + +```vue + + + + + + +``` + +**Step 2: 创建404页面** + +```vue + + + + +``` + +**Step 3: 更新 App.vue** + +```vue + + + + +``` + +**Step 4: 提交** + +```bash +git add web/src/views/ web/src/App.vue +git commit -m "feat(web): add cashier page and 404 page" +``` + +--- + +## Task 11: 最终构建验证 + +**Step 1: 验证前端构建** + +```bash +cd d:/project/payment/epay-go/web +npm run build +``` + +Expected: 构建成功,生成 `dist/` 目录 + +**Step 2: 提交所有更改** + +```bash +cd d:/project/payment/epay-go +git add . +git commit -m "feat(web): complete vue3 frontend with admin, merchant, cashier" +``` + +--- + +## 阶段四完成检查清单 + +- [ ] Vite 配置和依赖安装 +- [ ] API 请求封装 (`src/api/request.ts`) +- [ ] 类型定义 (`src/api/types.ts`) +- [ ] 管理后台 API (`src/api/admin.ts`) +- [ ] 商户端 API (`src/api/merchant.ts`) +- [ ] 路由配置 (`src/router/index.ts`) +- [ ] 管理后台布局 (`src/layouts/AdminLayout.vue`) +- [ ] 商户端布局 (`src/layouts/MerchantLayout.vue`) +- [ ] 登录/注册页面 +- [ ] 管理后台5个页面(仪表盘、商户、订单、通道、结算) +- [ ] 商户端5个页面(仪表盘、订单、结算、记录、个人信息) +- [ ] 收银台页面 +- [ ] 404页面 +- [ ] 构建验证通过 + +--- + +## 页面汇总 + +| 模块 | 页面 | 路由 | +|------|------|------| +| **管理后台** | 登录 | `/admin/login` | +| | 仪表盘 | `/admin/dashboard` | +| | 商户管理 | `/admin/merchants` | +| | 订单管理 | `/admin/orders` | +| | 通道管理 | `/admin/channels` | +| | 结算管理 | `/admin/settlements` | +| **商户中心** | 登录 | `/merchant/login` | +| | 注册 | `/merchant/register` | +| | 仪表盘 | `/merchant/dashboard` | +| | 订单管理 | `/merchant/orders` | +| | 结算管理 | `/merchant/settlements` | +| | 资金记录 | `/merchant/records` | +| | 个人信息 | `/merchant/profile` | +| **收银台** | 支付页 | `/cashier/:tradeNo` | + +--- + +**下一阶段:** 阶段五将完成 Docker 部署优化、集成测试和文档编写。 diff --git a/docs/plans/2026-02-07-epay-go-phase5-docker.md b/docs/plans/2026-02-07-epay-go-phase5-docker.md new file mode 100644 index 0000000..b022947 --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-phase5-docker.md @@ -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 +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 配置 +``` diff --git a/docs/plans/2026-02-07-epay-go-redesign.md b/docs/plans/2026-02-07-epay-go-redesign.md new file mode 100644 index 0000000..225ad42 --- /dev/null +++ b/docs/plans/2026-02-07-epay-go-redesign.md @@ -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 天** diff --git a/docs/plans/2026-02-08-complete-payment-system-features.md b/docs/plans/2026-02-08-complete-payment-system-features.md new file mode 100644 index 0000000..6bd30e0 --- /dev/null +++ b/docs/plans/2026-02-08-complete-payment-system-features.md @@ -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 " +``` + +--- + +## 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 周可完成全部功能细化。 diff --git a/docs/plans/2026-02-08-optimize-channel-configuration.md b/docs/plans/2026-02-08-optimize-channel-configuration.md new file mode 100644 index 0000000..1cc3152 --- /dev/null +++ b/docs/plans/2026-02-08-optimize-channel-configuration.md @@ -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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 +} + +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(`/admin/plugins/${plugin}/config`) +``` + +### 步骤 3: 修改 Channels.vue 实现动态表单 + +```vue + + + +``` + +### 步骤 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 " +``` + +--- + +## 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 " +``` + +--- + +## 总结 + +本计划实现了以下核心功能: + +1. ✅ **插件配置模板** - 定义支付宝、微信等插件的配置字段 +2. ✅ **动态表单生成** - 前端根据选择的插件自动显示对应配置字段 +3. ✅ **多接口支持** - 单个通道支持多种支付方式(扫码、H5、APP 等) +4. ✅ **配置验证** - 后端验证配置完整性和格式 +5. ✅ **测试支付** - 管理员可在配置后立即测试支付功能 + +**参考 epay 项目的优秀设计:** +- 插件化架构 +- 配置驱动的动态表单 +- JSON 格式存储配置 +- 支持证书模式和多接口 + +**预计工作量:** 2-3 天完成全部功能 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5601232 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7067436 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..c50101e --- /dev/null +++ b/internal/cache/redis.go @@ -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() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3cea923 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..fb137cb --- /dev/null +++ b/internal/database/database.go @@ -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() +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..30c3c51 --- /dev/null +++ b/internal/database/migrate.go @@ -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 +} diff --git a/internal/handler/admin/auth.go b/internal/handler/admin/auth.go new file mode 100644 index 0000000..4e29ab5 --- /dev/null +++ b/internal/handler/admin/auth.go @@ -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) +} diff --git a/internal/handler/admin/channel.go b/internal/handler/admin/channel.go new file mode 100644 index 0000000..9a4009a --- /dev/null +++ b/internal/handler/admin/channel.go @@ -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) +} diff --git a/internal/handler/admin/dashboard.go b/internal/handler/admin/dashboard.go new file mode 100644 index 0000000..07479aa --- /dev/null +++ b/internal/handler/admin/dashboard.go @@ -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, + }) +} diff --git a/internal/handler/admin/merchant.go b/internal/handler/admin/merchant.go new file mode 100644 index 0000000..b70621a --- /dev/null +++ b/internal/handler/admin/merchant.go @@ -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) +} diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go new file mode 100644 index 0000000..17c9ac1 --- /dev/null +++ b/internal/handler/admin/order.go @@ -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) +} diff --git a/internal/handler/admin/refund.go b/internal/handler/admin/refund.go new file mode 100644 index 0000000..80adb2d --- /dev/null +++ b/internal/handler/admin/refund.go @@ -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) +} diff --git a/internal/handler/admin/settlement.go b/internal/handler/admin/settlement.go new file mode 100644 index 0000000..eddd9f5 --- /dev/null +++ b/internal/handler/admin/settlement.go @@ -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) +} diff --git a/internal/handler/admin/test_payment.go b/internal/handler/admin/test_payment.go new file mode 100644 index 0000000..1acadee --- /dev/null +++ b/internal/handler/admin/test_payment.go @@ -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 +} diff --git a/internal/handler/merchant/auth.go b/internal/handler/merchant/auth.go new file mode 100644 index 0000000..6a79560 --- /dev/null +++ b/internal/handler/merchant/auth.go @@ -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) +} diff --git a/internal/handler/merchant/dashboard.go b/internal/handler/merchant/dashboard.go new file mode 100644 index 0000000..353005a --- /dev/null +++ b/internal/handler/merchant/dashboard.go @@ -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, + }) +} diff --git a/internal/handler/merchant/order.go b/internal/handler/merchant/order.go new file mode 100644 index 0000000..dbbc2d7 --- /dev/null +++ b/internal/handler/merchant/order.go @@ -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) +} diff --git a/internal/handler/merchant/profile.go b/internal/handler/merchant/profile.go new file mode 100644 index 0000000..857156c --- /dev/null +++ b/internal/handler/merchant/profile.go @@ -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, + }) +} diff --git a/internal/handler/merchant/record.go b/internal/handler/merchant/record.go new file mode 100644 index 0000000..e76186a --- /dev/null +++ b/internal/handler/merchant/record.go @@ -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) +} diff --git a/internal/handler/merchant/refund.go b/internal/handler/merchant/refund.go new file mode 100644 index 0000000..d6b22af --- /dev/null +++ b/internal/handler/merchant/refund.go @@ -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) +} diff --git a/internal/handler/merchant/settlement.go b/internal/handler/merchant/settlement.go new file mode 100644 index 0000000..5cf5fd0 --- /dev/null +++ b/internal/handler/merchant/settlement.go @@ -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) +} diff --git a/internal/handler/merchant/test_payment.go b/internal/handler/merchant/test_payment.go new file mode 100644 index 0000000..8652f09 --- /dev/null +++ b/internal/handler/merchant/test_payment.go @@ -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 +} + diff --git a/internal/handler/payment/create.go b/internal/handler/payment/create.go new file mode 100644 index 0000000..c4aec82 --- /dev/null +++ b/internal/handler/payment/create.go @@ -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 +} diff --git a/internal/handler/payment/legacy.go b/internal/handler/payment/legacy.go new file mode 100644 index 0000000..03b0050 --- /dev/null +++ b/internal/handler/payment/legacy.go @@ -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, "

"+msg+"

") +} + +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(`返回商户页面`, escapedReturnURL) + } + + html := fmt.Sprintf(` + + + + + 请扫码完成支付 + + + +
+
+
+
安全支付 · 扫码付款
+

请使用微信扫码完成支付

+

订单已经创建成功,请使用微信扫一扫扫描右侧二维码完成付款。支付成功后,系统会自动通知商户并跳转回业务页面。

+
+
+
支付金额
+
¥%s
+
+
+
商品名称
+
%s
+
+
+
平台订单号
+
%s
+
+
+
商户订单号
+
%s
+
+
+
+
+
+
支付二维码
+

扫码支付

+

如果二维码未显示,请点击下方按钮重新打开支付链接,或复制链接到支持的环境中打开。

+
等待支付完成
+
+ + %s +
+ +
+
+ + +`, 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, + }) +} diff --git a/internal/handler/payment/notify.go b/internal/handler/payment/notify.go new file mode 100644 index 0000000..c11ac0d --- /dev/null +++ b/internal/handler/payment/notify.go @@ -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, "支付完成") +} diff --git a/internal/handler/payment/query.go b/internal/handler/payment/query.go new file mode 100644 index 0000000..dbb8f20 --- /dev/null +++ b/internal/handler/payment/query.go @@ -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, + }) +} diff --git a/internal/handler/payment/test_notify.go b/internal/handler/payment/test_notify.go new file mode 100644 index 0000000..7c07c21 --- /dev/null +++ b/internal/handler/payment/test_notify.go @@ -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") +} + diff --git a/internal/handler/payment/type_normalizer.go b/internal/handler/payment/type_normalizer.go new file mode 100644 index 0000000..2eb662b --- /dev/null +++ b/internal/handler/payment/type_normalizer.go @@ -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 + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..4d3ffa7 --- /dev/null +++ b/internal/middleware/auth.go @@ -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 "" +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..7110ba1 --- /dev/null +++ b/internal/middleware/cors.go @@ -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() + } +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..c501d66 --- /dev/null +++ b/internal/middleware/logger.go @@ -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) + } +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..5a54ec9 --- /dev/null +++ b/internal/middleware/recovery.go @@ -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() + } +} diff --git a/internal/model/admin.go b/internal/model/admin.go new file mode 100644 index 0000000..0a1f2c6 --- /dev/null +++ b/internal/model/admin.go @@ -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" +} diff --git a/internal/model/base.go b/internal/model/base.go new file mode 100644 index 0000000..46f47c2 --- /dev/null +++ b/internal/model/base.go @@ -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:"-"` +} diff --git a/internal/model/channel.go b/internal/model/channel.go new file mode 100644 index 0000000..d538815 --- /dev/null +++ b/internal/model/channel.go @@ -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" +} diff --git a/internal/model/config.go b/internal/model/config.go new file mode 100644 index 0000000..ab8ed79 --- /dev/null +++ b/internal/model/config.go @@ -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" +} diff --git a/internal/model/merchant.go b/internal/model/merchant.go new file mode 100644 index 0000000..62dc5d6 --- /dev/null +++ b/internal/model/merchant.go @@ -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" +} diff --git a/internal/model/order.go b/internal/model/order.go new file mode 100644 index 0000000..ee1f277 --- /dev/null +++ b/internal/model/order.go @@ -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 +) diff --git a/internal/model/record.go b/internal/model/record.go new file mode 100644 index 0000000..e415bd1 --- /dev/null +++ b/internal/model/record.go @@ -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 +) diff --git a/internal/model/refund.go b/internal/model/refund.go new file mode 100644 index 0000000..f75d139 --- /dev/null +++ b/internal/model/refund.go @@ -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" +} diff --git a/internal/model/settlement.go b/internal/model/settlement.go new file mode 100644 index 0000000..57d015c --- /dev/null +++ b/internal/model/settlement.go @@ -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 +) diff --git a/internal/payment/adapter.go b/internal/payment/adapter.go new file mode 100644 index 0000000..f89813b --- /dev/null +++ b/internal/payment/adapter.go @@ -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) +} diff --git a/internal/payment/alipay.go b/internal/payment/alipay.go new file mode 100644 index 0000000..a917f49 --- /dev/null +++ b/internal/payment/alipay.go @@ -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) +} diff --git a/internal/payment/factory.go b/internal/payment/factory.go new file mode 100644 index 0000000..8e52ae0 --- /dev/null +++ b/internal/payment/factory.go @@ -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 +} diff --git a/internal/payment/plugin_config.go b/internal/payment/plugin_config.go new file mode 100644 index 0000000..c0f85b4 --- /dev/null +++ b/internal/payment/plugin_config.go @@ -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密钥、商户证书序列号、商户私钥内容。", + } +} diff --git a/internal/payment/wechat.go b/internal/payment/wechat.go new file mode 100644 index 0000000..bf049ad --- /dev/null +++ b/internal/payment/wechat.go @@ -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) +} diff --git a/internal/repository/admin.go b/internal/repository/admin.go new file mode 100644 index 0000000..ab18838 --- /dev/null +++ b/internal/repository/admin.go @@ -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 +} diff --git a/internal/repository/channel.go b/internal/repository/channel.go new file mode 100644 index 0000000..e0aeedf --- /dev/null +++ b/internal/repository/channel.go @@ -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 +} diff --git a/internal/repository/merchant.go b/internal/repository/merchant.go new file mode 100644 index 0000000..8270822 --- /dev/null +++ b/internal/repository/merchant.go @@ -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 +} diff --git a/internal/repository/order.go b/internal/repository/order.go new file mode 100644 index 0000000..97eed8e --- /dev/null +++ b/internal/repository/order.go @@ -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 +} diff --git a/internal/repository/record.go b/internal/repository/record.go new file mode 100644 index 0000000..477cd44 --- /dev/null +++ b/internal/repository/record.go @@ -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 +} diff --git a/internal/repository/refund.go b/internal/repository/refund.go new file mode 100644 index 0000000..6b7afb6 --- /dev/null +++ b/internal/repository/refund.go @@ -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 +} diff --git a/internal/repository/settlement.go b/internal/repository/settlement.go new file mode 100644 index 0000000..c4c186a --- /dev/null +++ b/internal/repository/settlement.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..beeddd0 --- /dev/null +++ b/internal/router/router.go @@ -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) + } + } +} diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..6b71083 --- /dev/null +++ b/internal/service/admin.go @@ -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) +} diff --git a/internal/service/channel.go b/internal/service/channel.go new file mode 100644 index 0000000..6408926 --- /dev/null +++ b/internal/service/channel.go @@ -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) +} diff --git a/internal/service/merchant.go b/internal/service/merchant.go new file mode 100644 index 0000000..c2d3780 --- /dev/null +++ b/internal/service/merchant.go @@ -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) +} diff --git a/internal/service/notify.go b/internal/service/notify.go new file mode 100644 index 0000000..68ebc5d --- /dev/null +++ b/internal/service/notify.go @@ -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) + } + } +} diff --git a/internal/service/order.go b/internal/service/order.go new file mode 100644 index 0000000..f4692a5 --- /dev/null +++ b/internal/service/order.go @@ -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 +} diff --git a/internal/service/refund.go b/internal/service/refund.go new file mode 100644 index 0000000..039e623 --- /dev/null +++ b/internal/service/refund.go @@ -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) +} diff --git a/internal/service/settlement.go b/internal/service/settlement.go new file mode 100644 index 0000000..7db4813 --- /dev/null +++ b/internal/service/settlement.go @@ -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) +} diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..ab4697c --- /dev/null +++ b/pkg/jwt/jwt.go @@ -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") +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..212bdf5 --- /dev/null +++ b/pkg/response/response.go @@ -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, + }) +} diff --git a/pkg/sign/sign.go b/pkg/sign/sign.go new file mode 100644 index 0000000..8d99ac4 --- /dev/null +++ b/pkg/sign/sign.go @@ -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[:]) +} diff --git a/pkg/utils/password.go b/pkg/utils/password.go new file mode 100644 index 0000000..26c5fee --- /dev/null +++ b/pkg/utils/password.go @@ -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 +} diff --git a/pkg/utils/trade.go b/pkg/utils/trade.go new file mode 100644 index 0000000..6ada311 --- /dev/null +++ b/pkg/utils/trade.go @@ -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) +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..bfa567f --- /dev/null +++ b/pkg/utils/utils.go @@ -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 +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..77d4120 --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..9b1eeca --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,6 @@ +# epay-go/web/.dockerignore +node_modules +dist +.git +*.log +.env.local diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -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? diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..5fec844 --- /dev/null +++ b/web/Dockerfile @@ -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;"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -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 ` + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..4f29dde --- /dev/null +++ b/web/nginx.conf @@ -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; + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ef3aed2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2275 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@arco-design/web-vue": "^2.57.0", + "ant-design-vue": "^4.2.6", + "axios": "^1.13.4", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.11", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } + }, + "node_modules/@ant-design/colors": { + "version": "6.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "http://mirrors.tencentyun.com/npm/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/icons-vue": { + "version": "7.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz", + "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.2.1" + }, + "peerDependencies": { + "vue": ">=3.0.3" + } + }, + "node_modules/@arco-design/color": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", + "integrity": "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3" + } + }, + "node_modules/@arco-design/web-vue": { + "version": "2.57.0", + "resolved": "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.57.0.tgz", + "integrity": "sha512-R5YReC3C2sG3Jv0+YuR3B7kzkq2KdhhQNCGXD8T11xAoa0zMt6SWTP1xJQOdZcM9du+q3z6tk5mRvh4qkieRJw==", + "license": "MIT", + "dependencies": { + "@arco-design/color": "^0.4.0", + "b-tween": "^0.3.3", + "b-validate": "^1.5.3", + "compute-scroll-into-view": "^1.0.20", + "dayjs": "^1.11.13", + "number-precision": "^1.6.0", + "resize-observer-polyfill": "^1.5.1", + "scroll-into-view-if-needed": "^2.2.31", + "vue": "^3.1.0" + }, + "peerDependencies": { + "vue": "^3.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "http://mirrors.tencentyun.com/npm/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "http://mirrors.tencentyun.com/npm/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "http://mirrors.tencentyun.com/npm/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simonwep/pickr": { + "version": "1.8.2", + "resolved": "http://mirrors.tencentyun.com/npm/@simonwep/pickr/-/pickr-1.8.2.tgz", + "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==", + "license": "MIT", + "dependencies": { + "core-js": "^3.15.1", + "nanopop": "^2.1.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.11", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.11.tgz", + "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.4.tgz", + "integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ant-design-vue": { + "version": "4.2.6", + "resolved": "http://mirrors.tencentyun.com/npm/ant-design-vue/-/ant-design-vue-4.2.6.tgz", + "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-vue": "^7.0.0", + "@babel/runtime": "^7.10.5", + "@ctrl/tinycolor": "^3.5.0", + "@emotion/hash": "^0.9.0", + "@emotion/unitless": "^0.8.0", + "@simonwep/pickr": "~1.8.0", + "array-tree-filter": "^2.1.0", + "async-validator": "^4.0.0", + "csstype": "^3.1.1", + "dayjs": "^1.10.5", + "dom-align": "^1.12.1", + "dom-scroll-into-view": "^2.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.15", + "resize-observer-polyfill": "^1.5.1", + "scroll-into-view-if-needed": "^2.2.25", + "shallow-equal": "^1.0.0", + "stylis": "^4.1.3", + "throttle-debounce": "^5.0.0", + "vue-types": "^3.0.0", + "warning": "^4.0.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design-vue" + }, + "peerDependencies": { + "vue": ">=3.2.0" + } + }, + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "http://mirrors.tencentyun.com/npm/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==", + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "http://mirrors.tencentyun.com/npm/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b-tween": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/b-tween/-/b-tween-0.3.3.tgz", + "integrity": "sha512-oEHegcRpA7fAuc9KC4nktucuZn2aS8htymCPcP3qkEGPqiBH+GfqtqoG2l7LxHngg6O0HFM7hOeOYExl1Oz4ZA==", + "license": "MIT" + }, + "node_modules/b-validate": { + "version": "1.5.3", + "resolved": "https://registry.npmmirror.com/b-validate/-/b-validate-1.5.3.tgz", + "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==", + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "http://mirrors.tencentyun.com/npm/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "http://mirrors.tencentyun.com/npm/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, + "node_modules/dom-scroll-into-view": { + "version": "2.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz", + "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-plain-object": { + "version": "3.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/is-plain-object/-/is-plain-object-3.0.1.tgz", + "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "http://mirrors.tencentyun.com/npm/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "http://mirrors.tencentyun.com/npm/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "http://mirrors.tencentyun.com/npm/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanopop": { + "version": "2.4.2", + "resolved": "http://mirrors.tencentyun.com/npm/nanopop/-/nanopop-2.4.2.tgz", + "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==", + "license": "MIT" + }, + "node_modules/number-precision": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/number-precision/-/number-precision-1.6.0.tgz", + "integrity": "sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "http://mirrors.tencentyun.com/npm/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==", + "license": "MIT" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "http://mirrors.tencentyun.com/npm/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "http://mirrors.tencentyun.com/npm/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.4.tgz", + "integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue-types": { + "version": "3.0.2", + "resolved": "http://mirrors.tencentyun.com/npm/vue-types/-/vue-types-3.0.2.tgz", + "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==", + "license": "MIT", + "dependencies": { + "is-plain-object": "3.0.1" + }, + "engines": { + "node": ">=10.15.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "http://mirrors.tencentyun.com/npm/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b689dec --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@arco-design/web-vue": "^2.57.0", + "ant-design-vue": "^4.2.6", + "axios": "^1.13.4", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.11", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } +} diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..f28ff38 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts new file mode 100644 index 0000000..8e07c75 --- /dev/null +++ b/web/src/api/admin.ts @@ -0,0 +1,108 @@ +// web/src/api/admin.ts +import request from './request' +import type { ApiResponse, PageData, Merchant, Order, Channel, Settlement, Refund, PluginConfig } from './types' + + const adminApiBase = '/api/admin' + +// 登录 +export function adminLogin(data: { username: string; password: string }) { + return request.post(`${adminApiBase}/auth/login`, data) +} + +// 修改密码 +export function updateAdminPassword(data: { old_password: string; new_password: string }) { + return request.put(`${adminApiBase}/profile/password`, data) +} + +// 仪表盘 +export function getDashboard() { + return request.get(`${adminApiBase}/dashboard`) +} + +// 商户列表 +export function getMerchants(params: { page: number; page_size: number; status?: number }) { + return request.get>>(`${adminApiBase}/merchants`, { params }) +} + +// 商户详情 +export function getMerchant(id: number) { + return request.get>(`${adminApiBase}/merchants/${id}`) +} + +// 更新商户状态 +export function updateMerchantStatus(id: number, status: number) { + return request.patch(`${adminApiBase}/merchants/${id}/status`, { status }) +} + +// 订单列表 +export function getOrders(params: { page: number; page_size: number; merchant_id?: number; status?: number }) { + return request.get>>(`${adminApiBase}/orders`, { params }) +} + +// 订单详情 +export function getOrder(tradeNo: string) { + return request.get>(`${adminApiBase}/orders/${tradeNo}`) +} + +// 重发通知 +export function renotifyOrder(tradeNo: string) { + return request.post(`${adminApiBase}/orders/${tradeNo}/renotify`) +} + +// 通道列表 +export function getChannels(params: { page: number; page_size: number }) { + return request.get>>(`${adminApiBase}/channels`, { params }) +} + +// 创建通道 +export function createChannel(data: Partial) { + return request.post(`${adminApiBase}/channels`, data) +} + +// 更新通道 +export function updateChannel(id: number, data: Partial) { + return request.put(`${adminApiBase}/channels/${id}`, data) +} + +// 删除通道 +export function deleteChannel(id: number) { + return request.delete(`${adminApiBase}/channels/${id}`) +} + +// 获取所有插件 +export const getPlugins = () => + request.get>(`${adminApiBase}/plugins`) + +// 获取插件配置模板 +export const getPluginConfig = (plugin: string) => + request.get>(`${adminApiBase}/plugins/${plugin}/config`) + +// 结算列表 +export function getSettlements(params: { page: number; page_size: number; merchant_id?: number; status?: number }) { + return request.get>>(`${adminApiBase}/settlements`, { params }) +} + +// 审核通过 +export function approveSettlement(id: number) { + return request.patch(`${adminApiBase}/settlements/${id}/approve`) +} + +// 驳回 +export function rejectSettlement(id: number, remark: string) { + return request.patch(`${adminApiBase}/settlements/${id}/reject`, { remark }) +} + +// 退款管理 +export const getRefunds = (params: any) => + request.get>>(`${adminApiBase}/refunds`, { params }) + +export const createRefund = (data: { trade_no: string; amount: string; reason?: string; notify_url?: string }) => + request.post>(`${adminApiBase}/refunds`, data) + +export const processRefund = (refundNo: string, data: { success: boolean; fail_reason?: string }) => + request.post(`${adminApiBase}/refunds/${refundNo}/process`, data) + +// 测试支付 +export const testPayment = (data: { channel_id: number; amount: string; pay_type: string }) => { + return request.post(`${adminApiBase}/test-payment`, data) +} diff --git a/web/src/api/merchant.ts b/web/src/api/merchant.ts new file mode 100644 index 0000000..c632c45 --- /dev/null +++ b/web/src/api/merchant.ts @@ -0,0 +1,86 @@ +// web/src/api/merchant.ts +import request from './request' +import type { ApiResponse, PageData, Merchant, Order, Settlement, BalanceRecord, Refund } from './types' + + const merchantApiBase = '/api/merchant' + +// 注册 +export function register(data: { username: string; password: string; email?: string; phone?: string }) { + return request.post(`${merchantApiBase}/auth/register`, data) +} + +// 登录 +export function login(data: { username: string; password: string }) { + return request.post(`${merchantApiBase}/auth/login`, data) +} + +// 仪表盘 +export function getDashboard() { + return request.get(`${merchantApiBase}/dashboard`) +} + +// 获取个人信息 +export function getProfile() { + return request.get>(`${merchantApiBase}/profile`) +} + +// 更新个人信息 +export function updateProfile(data: { email?: string; phone?: string }) { + return request.put(`${merchantApiBase}/profile`, data) +} + +// 修改密码 +export function updatePassword(data: { old_password: string; new_password: string }) { + return request.put(`${merchantApiBase}/profile/password`, data) +} + +// 重置API密钥 +export function resetApiKey() { + return request.post>(`${merchantApiBase}/profile/reset-key`) +} + +// 订单列表 +export function getOrders(params: { page: number; page_size: number; status?: number }) { + return request.get>>(`${merchantApiBase}/orders`, { params }) +} + +// 订单详情 +export function getOrder(tradeNo: string) { + return request.get>(`${merchantApiBase}/orders/${tradeNo}`) +} + +// 结算列表 +export function getSettlements(params: { page: number; page_size: number; status?: number }) { + return request.get>>(`${merchantApiBase}/settlements`, { params }) +} + +// 申请结算 +export function applySettlement(data: { + amount: string + account_type: string + account_no: string + account_name: string +}) { + return request.post(`${merchantApiBase}/settlements`, data) +} + +// 资金记录 +export function getRecords(params: { page: number; page_size: number }) { + return request.get>>(`${merchantApiBase}/records`, { params }) +} + +// 退款管理 +export const createRefund = (data: { + trade_no: string + amount: string + reason?: string + notify_url?: string +}) => request.post(`${merchantApiBase}/refunds`, data) + +export const getRefunds = (params: any) => + request.get>>(`${merchantApiBase}/refunds`, { params }) + +// 商户测试支付 +export function testPayment(data: { amount: string; pay_type: string; pay_method?: string }) { + return request.post(`${merchantApiBase}/test-payment`, data) +} diff --git a/web/src/api/request.ts b/web/src/api/request.ts new file mode 100644 index 0000000..ae14490 --- /dev/null +++ b/web/src/api/request.ts @@ -0,0 +1,69 @@ +// web/src/api/request.ts +import axios, { type AxiosInstance, type AxiosResponse } from 'axios' +import { Message } from '@arco-design/web-vue' + +const request: AxiosInstance = axios.create({ + baseURL: '', + timeout: 30000, +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + // 根据当前 URL 判断是管理后台还是商户中心 + const path = window.location.pathname + let token: string | null = null + + if (path.startsWith('/admin')) { + token = localStorage.getItem('admin_token') + } else if (path.startsWith('/merchant')) { + token = localStorage.getItem('merchant_token') + } + + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response: AxiosResponse) => { + const { data } = response + if (data.code === 0) { + return data + } + // 业务错误 + Message.error(data.msg || '请求失败') + return Promise.reject(new Error(data.msg)) + }, + (error) => { + if (error.response) { + const { status, data } = error.response + if (status === 401) { + Message.error('登录已过期,请重新登录') + const path = window.location.pathname + if (path.startsWith('/admin')) { + localStorage.removeItem('admin_token') + window.location.href = '/admin/login' + } else { + localStorage.removeItem('merchant_token') + window.location.href = '/merchant/login' + } + } else if (status === 403) { + Message.error('无权访问') + } else { + Message.error(data?.msg || '服务器错误') + } + } else { + Message.error('网络错误') + } + return Promise.reject(error) + } +) + +export default request diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..1ef34c5 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,141 @@ +// web/src/api/types.ts +// 通用响应 +export interface ApiResponse { + code: number + msg: string + data: T +} + +// 分页响应 +export interface PageData { + list: T[] + total: number + page: number + page_size: number +} + +// 商户 +export interface Merchant { + id: number + username: string + email: string + phone: string + api_key: string + balance: string + frozen_balance: string + status: number + created_at: string +} + +// 订单 +export interface Order { + id: number + trade_no: string + out_trade_no: string + merchant_id: number + channel_id: number + pay_type: string + amount: string + real_amount: string + fee: string + name: string + status: number + notify_status: number + paid_at: string | null + created_at: string +} + +// 通道 +export interface Channel { + id: number + name: string + plugin: string + pay_types: string + app_type?: string + config: any + rate: string | number + daily_limit: string | number + status: number + sort: number +} + +// 结算 +export interface Settlement { + id: number + settle_no: string + merchant_id: number + amount: string + fee: string + actual_amount: string + account_type: string + account_no: string + account_name: string + status: number + remark: string + created_at: string +} + +// 资金记录 +export interface BalanceRecord { + id: number + merchant_id: number + action: number + amount: string + before_balance: string + after_balance: string + type: string + trade_no: string + created_at: string +} + +// 退款 +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 +} + +// 插件配置字段 +export interface PluginConfigField { + key: string + name: string + type: 'input' | 'textarea' | 'select' | 'checkbox' + required: boolean + placeholder: string + note: string + options?: Record +} + +// 支付接口选项 +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 +} diff --git a/web/src/assets/vue.svg b/web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..a8e63ac --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,15 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..aeac2c5 --- /dev/null +++ b/web/src/layouts/AdminLayout.vue @@ -0,0 +1,175 @@ + + + + + + diff --git a/web/src/layouts/MerchantLayout.vue b/web/src/layouts/MerchantLayout.vue new file mode 100644 index 0000000..cb97f2b --- /dev/null +++ b/web/src/layouts/MerchantLayout.vue @@ -0,0 +1,133 @@ + + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..dcb0906 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,18 @@ +// web/src/main.ts +import { createApp } from 'vue' +import ArcoVue from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import App from './App.vue' +import router from './router' +import { createPinia } from 'pinia' + +const app = createApp(App) + +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..9a53c71 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,174 @@ +// web/src/router/index.ts +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + // 首页重定向 + { + path: '/', + redirect: '/merchant/login', + }, + + // 管理后台 + { + path: '/admin/login', + name: 'AdminLogin', + component: () => import('@/views/admin/Login.vue'), + meta: { title: '管理员登录' }, + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + meta: { requiresAuth: true, authType: 'admin' }, + children: [ + { + path: '', + redirect: '/admin/dashboard', + }, + { + path: 'dashboard', + name: 'AdminDashboard', + component: () => import('@/views/admin/Dashboard.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'merchants', + name: 'AdminMerchants', + component: () => import('@/views/admin/Merchants.vue'), + meta: { title: '商户管理' }, + }, + { + path: 'orders', + name: 'AdminOrders', + component: () => import('@/views/admin/Orders.vue'), + meta: { title: '订单管理' }, + }, + { + path: 'channels', + name: 'AdminChannels', + component: () => import('@/views/admin/Channels.vue'), + meta: { title: '通道管理' }, + }, + { + path: 'settlements', + name: 'AdminSettlements', + component: () => import('@/views/admin/Settlements.vue'), + meta: { title: '结算管理' }, + }, + { + path: 'refunds', + name: 'AdminRefunds', + component: () => import('@/views/admin/Refunds.vue'), + meta: { title: '退款管理' }, + }, + { + path: 'test-payment', + name: 'AdminTestPayment', + component: () => import('@/views/admin/TestPayment.vue'), + meta: { title: '测试支付' }, + }, + ], + }, + + // 商户中心 + { + path: '/merchant/login', + name: 'MerchantLogin', + component: () => import('@/views/merchant/Login.vue'), + meta: { title: '商户登录' }, + }, + { + path: '/merchant/register', + name: 'MerchantRegister', + component: () => import('@/views/merchant/Register.vue'), + meta: { title: '商户注册' }, + }, + { + path: '/merchant', + component: () => import('@/layouts/MerchantLayout.vue'), + meta: { requiresAuth: true, authType: 'merchant' }, + children: [ + { + path: '', + redirect: '/merchant/dashboard', + }, + { + path: 'dashboard', + name: 'MerchantDashboard', + component: () => import('@/views/merchant/Dashboard.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'orders', + name: 'MerchantOrders', + component: () => import('@/views/merchant/Orders.vue'), + meta: { title: '订单管理' }, + }, + { + path: 'settlements', + name: 'MerchantSettlements', + component: () => import('@/views/merchant/Settlements.vue'), + meta: { title: '结算管理' }, + }, + { + path: 'records', + name: 'MerchantRecords', + component: () => import('@/views/merchant/Records.vue'), + meta: { title: '资金记录' }, + }, + { + path: 'profile', + name: 'MerchantProfile', + component: () => import('@/views/merchant/Profile.vue'), + meta: { title: '个人信息' }, + }, + { + path: 'test-payment', + name: 'MerchantTestPayment', + component: () => import('@/views/merchant/TestPayment.vue'), + meta: { title: '测试支付' }, + }, + ], + }, + + // 收银台 + { + path: '/cashier/:tradeNo', + name: 'Cashier', + component: () => import('@/views/Cashier.vue'), + meta: { title: '收银台' }, + }, + + // 404 + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue'), + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// 路由守卫 +router.beforeEach((to, _from, next) => { + // 设置页面标题 + document.title = (to.meta.title as string) || 'EPay' + + if (to.meta.requiresAuth) { + const authType = to.meta.authType as string + const tokenKey = authType === 'admin' ? 'admin_token' : 'merchant_token' + const token = localStorage.getItem(tokenKey) + + if (!token) { + const loginPath = authType === 'admin' ? '/admin/login' : '/merchant/login' + next({ path: loginPath, query: { redirect: to.fullPath } }) + return + } + } + + next() +}) + +export default router diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/src/utils/paymentOptions.ts b/web/src/utils/paymentOptions.ts new file mode 100644 index 0000000..a449263 --- /dev/null +++ b/web/src/utils/paymentOptions.ts @@ -0,0 +1,36 @@ +export interface PaymentOption { + label: string + value: string + payType: string + payMethod?: string + mode: 'qrcode' | 'redirect' | 'jsapi' + provider: 'wechat' | 'alipay' +} + +const ALL_OPTIONS: PaymentOption[] = [ + { label: 'WX_NATIVE', value: 'WX_NATIVE', payType: 'wxpay', payMethod: 'native', mode: 'qrcode', provider: 'wechat' }, + { label: 'WX_JSAPI', value: 'WX_JSAPI', payType: 'wxpay', payMethod: 'jsapi', mode: 'jsapi', provider: 'wechat' }, + { label: 'WX_H5', value: 'WX_H5', payType: 'wxpay', payMethod: 'h5', mode: 'redirect', provider: 'wechat' }, + { label: 'ALIPAY_SCAN', value: 'ALIPAY_SCAN', payType: 'alipay', payMethod: 'scan', mode: 'qrcode', provider: 'alipay' }, + { label: 'ALIPAY_H5', value: 'ALIPAY_H5', payType: 'alipay', payMethod: 'h5', mode: 'redirect', provider: 'alipay' }, + { label: 'ALIPAY_WEB', value: 'ALIPAY_WEB', payType: 'alipay', payMethod: 'web', mode: 'redirect', provider: 'alipay' }, +] + +export function getPaymentOptionsByPlugin(plugin: string): PaymentOption[] { + const normalized = plugin.toLowerCase() + if (normalized.includes('wechat') || normalized.includes('wxpay')) { + return ALL_OPTIONS.filter(option => option.provider === 'wechat') + } + if (normalized.includes('alipay') || normalized.includes('ali')) { + return ALL_OPTIONS.filter(option => option.provider === 'alipay') + } + return [] +} + +export function getPaymentOptionsByProvider(provider: 'wechat' | 'alipay') { + return ALL_OPTIONS.filter(option => option.provider === provider) +} + +export function findPaymentOption(value: string) { + return ALL_OPTIONS.find(option => option.value === value) +} diff --git a/web/src/views/Cashier.vue b/web/src/views/Cashier.vue new file mode 100644 index 0000000..f887412 --- /dev/null +++ b/web/src/views/Cashier.vue @@ -0,0 +1,251 @@ + + + + + + diff --git a/web/src/views/NotFound.vue b/web/src/views/NotFound.vue new file mode 100644 index 0000000..3a00b35 --- /dev/null +++ b/web/src/views/NotFound.vue @@ -0,0 +1,26 @@ + + + + + + diff --git a/web/src/views/admin/Channels.vue b/web/src/views/admin/Channels.vue new file mode 100644 index 0000000..137f1c9 --- /dev/null +++ b/web/src/views/admin/Channels.vue @@ -0,0 +1,346 @@ + + + + + + diff --git a/web/src/views/admin/Dashboard.vue b/web/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..e3944e8 --- /dev/null +++ b/web/src/views/admin/Dashboard.vue @@ -0,0 +1,48 @@ + + + + + + diff --git a/web/src/views/admin/Login.vue b/web/src/views/admin/Login.vue new file mode 100644 index 0000000..409112e --- /dev/null +++ b/web/src/views/admin/Login.vue @@ -0,0 +1,75 @@ + + + + + + diff --git a/web/src/views/admin/Merchants.vue b/web/src/views/admin/Merchants.vue new file mode 100644 index 0000000..be9f9f5 --- /dev/null +++ b/web/src/views/admin/Merchants.vue @@ -0,0 +1,78 @@ + + + + diff --git a/web/src/views/admin/Orders.vue b/web/src/views/admin/Orders.vue new file mode 100644 index 0000000..64a33c7 --- /dev/null +++ b/web/src/views/admin/Orders.vue @@ -0,0 +1,158 @@ + + + + diff --git a/web/src/views/admin/Refunds.vue b/web/src/views/admin/Refunds.vue new file mode 100644 index 0000000..2d2d43d --- /dev/null +++ b/web/src/views/admin/Refunds.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/web/src/views/admin/Settlements.vue b/web/src/views/admin/Settlements.vue new file mode 100644 index 0000000..9889a97 --- /dev/null +++ b/web/src/views/admin/Settlements.vue @@ -0,0 +1,148 @@ + + + + + + diff --git a/web/src/views/admin/TestPayment.vue b/web/src/views/admin/TestPayment.vue new file mode 100644 index 0000000..2de7663 --- /dev/null +++ b/web/src/views/admin/TestPayment.vue @@ -0,0 +1,307 @@ + + + + + + diff --git a/web/src/views/merchant/Dashboard.vue b/web/src/views/merchant/Dashboard.vue new file mode 100644 index 0000000..93d85d4 --- /dev/null +++ b/web/src/views/merchant/Dashboard.vue @@ -0,0 +1,74 @@ + + + + diff --git a/web/src/views/merchant/Login.vue b/web/src/views/merchant/Login.vue new file mode 100644 index 0000000..35a7617 --- /dev/null +++ b/web/src/views/merchant/Login.vue @@ -0,0 +1,86 @@ + + + + + + diff --git a/web/src/views/merchant/Orders.vue b/web/src/views/merchant/Orders.vue new file mode 100644 index 0000000..2f0f46d --- /dev/null +++ b/web/src/views/merchant/Orders.vue @@ -0,0 +1,73 @@ + + + + diff --git a/web/src/views/merchant/Profile.vue b/web/src/views/merchant/Profile.vue new file mode 100644 index 0000000..309f331 --- /dev/null +++ b/web/src/views/merchant/Profile.vue @@ -0,0 +1,160 @@ + + + + + + diff --git a/web/src/views/merchant/Records.vue b/web/src/views/merchant/Records.vue new file mode 100644 index 0000000..5674cde --- /dev/null +++ b/web/src/views/merchant/Records.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/web/src/views/merchant/Refunds.vue b/web/src/views/merchant/Refunds.vue new file mode 100644 index 0000000..0e279a1 --- /dev/null +++ b/web/src/views/merchant/Refunds.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/web/src/views/merchant/Register.vue b/web/src/views/merchant/Register.vue new file mode 100644 index 0000000..0261388 --- /dev/null +++ b/web/src/views/merchant/Register.vue @@ -0,0 +1,92 @@ + + + + + + diff --git a/web/src/views/merchant/Settlements.vue b/web/src/views/merchant/Settlements.vue new file mode 100644 index 0000000..e9c87d5 --- /dev/null +++ b/web/src/views/merchant/Settlements.vue @@ -0,0 +1,153 @@ + + + + + + diff --git a/web/src/views/merchant/TestPayment.vue b/web/src/views/merchant/TestPayment.vue new file mode 100644 index 0000000..42cab02 --- /dev/null +++ b/web/src/views/merchant/TestPayment.vue @@ -0,0 +1,182 @@ + + + + + + + diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..0ee316d --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..2ba58af --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,22 @@ +// web/vite.config.ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 8090, + proxy: { + '/api': { + target: 'http://localhost:8099', + changeOrigin: true, + }, + }, + }, +})