From 9f5bc33085cc82651fa381e8ef76ddf23654036d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 28 Mar 2026 14:55:39 +0800 Subject: [PATCH] init --- .claude/settings.json | 5 + .claude/settings.local.json | 10 + .dockerignore | 10 + .env.example | 31 + .gitignore | 49 + CLAUDE.md | 187 ++ DEPLOYMENT.md | 99 + Dockerfile | 42 + README.md | 143 + cmd/server/main.go | 116 + config.example.yaml | 22 + deploy/caddy/Caddyfile | 22 + deploy/nginx/gateway.prod.conf | 67 + deploy/nginx/host.prod.conf.example | 54 + docker-compose.prod.caddy.yml | 128 + docker-compose.yml | 94 + ...-02-07-complete-frontend-implementation.md | 1273 ++++++++ .../2026-02-07-epay-go-phase1-foundation.md | 1461 +++++++++ .../2026-02-07-epay-go-phase2-backend-core.md | 2698 +++++++++++++++++ .../2026-02-07-epay-go-phase3-api-handlers.md | 1661 ++++++++++ .../2026-02-07-epay-go-phase4-vue-frontend.md | 2375 +++++++++++++++ .../plans/2026-02-07-epay-go-phase5-docker.md | 582 ++++ docs/plans/2026-02-07-epay-go-redesign.md | 607 ++++ ...-02-08-complete-payment-system-features.md | 864 ++++++ ...26-02-08-optimize-channel-configuration.md | 753 +++++ go.mod | 71 + go.sum | 163 + internal/cache/redis.go | 42 + internal/config/config.go | 76 + internal/database/database.go | 66 + internal/database/migrate.go | 31 + internal/handler/admin/auth.go | 80 + internal/handler/admin/channel.go | 134 + internal/handler/admin/dashboard.go | 32 + internal/handler/admin/merchant.go | 113 + internal/handler/admin/order.go | 79 + internal/handler/admin/refund.go | 81 + internal/handler/admin/settlement.go | 84 + internal/handler/admin/test_payment.go | 60 + internal/handler/merchant/auth.go | 101 + internal/handler/merchant/dashboard.go | 39 + internal/handler/merchant/order.go | 55 + internal/handler/merchant/profile.go | 105 + internal/handler/merchant/record.go | 27 + internal/handler/merchant/refund.go | 54 + internal/handler/merchant/settlement.go | 76 + internal/handler/merchant/test_payment.go | 89 + internal/handler/payment/create.go | 122 + internal/handler/payment/legacy.go | 699 +++++ internal/handler/payment/notify.go | 91 + internal/handler/payment/query.go | 100 + internal/handler/payment/test_notify.go | 13 + internal/handler/payment/type_normalizer.go | 117 + internal/middleware/auth.go | 73 + internal/middleware/cors.go | 24 + internal/middleware/logger.go | 32 + internal/middleware/recovery.go | 27 + internal/model/admin.go | 17 + internal/model/base.go | 16 + internal/model/channel.go | 26 + internal/model/config.go | 15 + internal/model/merchant.go | 25 + internal/model/order.go | 55 + internal/model/record.go | 31 + internal/model/refund.go | 43 + internal/model/settlement.go | 36 + internal/payment/adapter.go | 91 + internal/payment/alipay.go | 246 ++ internal/payment/factory.go | 36 + internal/payment/plugin_config.go | 178 ++ internal/payment/wechat.go | 281 ++ internal/repository/admin.go | 67 + internal/repository/channel.go | 105 + internal/repository/merchant.go | 103 + internal/repository/order.go | 159 + internal/repository/record.go | 62 + internal/repository/refund.go | 64 + internal/repository/settlement.go | 91 + internal/router/router.go | 123 + internal/service/admin.go | 109 + internal/service/channel.go | 133 + internal/service/merchant.go | 150 + internal/service/notify.go | 193 ++ internal/service/order.go | 297 ++ internal/service/refund.go | 244 ++ internal/service/settlement.go | 231 ++ pkg/jwt/jwt.go | 63 + pkg/response/response.go | 111 + pkg/sign/sign.go | 65 + pkg/utils/password.go | 18 + pkg/utils/trade.go | 39 + pkg/utils/utils.go | 53 + scripts/deploy.sh | 34 + web/.dockerignore | 6 + web/.gitignore | 24 + web/Dockerfile | 26 + web/README.md | 5 + web/index.html | 13 + web/nginx.conf | 104 + web/package-lock.json | 2275 ++++++++++++++ web/package.json | 27 + web/public/vite.svg | 1 + web/src/App.vue | 19 + web/src/api/admin.ts | 108 + web/src/api/merchant.ts | 86 + web/src/api/request.ts | 69 + web/src/api/types.ts | 141 + web/src/assets/vue.svg | 1 + web/src/env.d.ts | 15 + web/src/layouts/AdminLayout.vue | 175 ++ web/src/layouts/MerchantLayout.vue | 133 + web/src/main.ts | 18 + web/src/router/index.ts | 174 ++ web/src/style.css | 79 + web/src/utils/paymentOptions.ts | 36 + web/src/views/Cashier.vue | 251 ++ web/src/views/NotFound.vue | 26 + web/src/views/admin/Channels.vue | 346 +++ web/src/views/admin/Dashboard.vue | 48 + web/src/views/admin/Login.vue | 75 + web/src/views/admin/Merchants.vue | 78 + web/src/views/admin/Orders.vue | 158 + web/src/views/admin/Refunds.vue | 206 ++ web/src/views/admin/Settlements.vue | 148 + web/src/views/admin/TestPayment.vue | 307 ++ web/src/views/merchant/Dashboard.vue | 74 + web/src/views/merchant/Login.vue | 86 + web/src/views/merchant/Orders.vue | 73 + web/src/views/merchant/Profile.vue | 160 + web/src/views/merchant/Records.vue | 90 + web/src/views/merchant/Refunds.vue | 224 ++ web/src/views/merchant/Register.vue | 92 + web/src/views/merchant/Settlements.vue | 153 + web/src/views/merchant/TestPayment.vue | 182 ++ web/tsconfig.app.json | 21 + web/tsconfig.json | 7 + web/tsconfig.node.json | 26 + web/vite.config.ts | 22 + 138 files changed, 26307 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 config.example.yaml create mode 100644 deploy/caddy/Caddyfile create mode 100644 deploy/nginx/gateway.prod.conf create mode 100644 deploy/nginx/host.prod.conf.example create mode 100644 docker-compose.prod.caddy.yml create mode 100644 docker-compose.yml create mode 100644 docs/plans/2026-02-07-complete-frontend-implementation.md create mode 100644 docs/plans/2026-02-07-epay-go-phase1-foundation.md create mode 100644 docs/plans/2026-02-07-epay-go-phase2-backend-core.md create mode 100644 docs/plans/2026-02-07-epay-go-phase3-api-handlers.md create mode 100644 docs/plans/2026-02-07-epay-go-phase4-vue-frontend.md create mode 100644 docs/plans/2026-02-07-epay-go-phase5-docker.md create mode 100644 docs/plans/2026-02-07-epay-go-redesign.md create mode 100644 docs/plans/2026-02-08-complete-payment-system-features.md create mode 100644 docs/plans/2026-02-08-optimize-channel-configuration.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cache/redis.go create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/database/migrate.go create mode 100644 internal/handler/admin/auth.go create mode 100644 internal/handler/admin/channel.go create mode 100644 internal/handler/admin/dashboard.go create mode 100644 internal/handler/admin/merchant.go create mode 100644 internal/handler/admin/order.go create mode 100644 internal/handler/admin/refund.go create mode 100644 internal/handler/admin/settlement.go create mode 100644 internal/handler/admin/test_payment.go create mode 100644 internal/handler/merchant/auth.go create mode 100644 internal/handler/merchant/dashboard.go create mode 100644 internal/handler/merchant/order.go create mode 100644 internal/handler/merchant/profile.go create mode 100644 internal/handler/merchant/record.go create mode 100644 internal/handler/merchant/refund.go create mode 100644 internal/handler/merchant/settlement.go create mode 100644 internal/handler/merchant/test_payment.go create mode 100644 internal/handler/payment/create.go create mode 100644 internal/handler/payment/legacy.go create mode 100644 internal/handler/payment/notify.go create mode 100644 internal/handler/payment/query.go create mode 100644 internal/handler/payment/test_notify.go create mode 100644 internal/handler/payment/type_normalizer.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/recovery.go create mode 100644 internal/model/admin.go create mode 100644 internal/model/base.go create mode 100644 internal/model/channel.go create mode 100644 internal/model/config.go create mode 100644 internal/model/merchant.go create mode 100644 internal/model/order.go create mode 100644 internal/model/record.go create mode 100644 internal/model/refund.go create mode 100644 internal/model/settlement.go create mode 100644 internal/payment/adapter.go create mode 100644 internal/payment/alipay.go create mode 100644 internal/payment/factory.go create mode 100644 internal/payment/plugin_config.go create mode 100644 internal/payment/wechat.go create mode 100644 internal/repository/admin.go create mode 100644 internal/repository/channel.go create mode 100644 internal/repository/merchant.go create mode 100644 internal/repository/order.go create mode 100644 internal/repository/record.go create mode 100644 internal/repository/refund.go create mode 100644 internal/repository/settlement.go create mode 100644 internal/router/router.go create mode 100644 internal/service/admin.go create mode 100644 internal/service/channel.go create mode 100644 internal/service/merchant.go create mode 100644 internal/service/notify.go create mode 100644 internal/service/order.go create mode 100644 internal/service/refund.go create mode 100644 internal/service/settlement.go create mode 100644 pkg/jwt/jwt.go create mode 100644 pkg/response/response.go create mode 100644 pkg/sign/sign.go create mode 100644 pkg/utils/password.go create mode 100644 pkg/utils/trade.go create mode 100644 pkg/utils/utils.go create mode 100644 scripts/deploy.sh create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/nginx.conf create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/vite.svg create mode 100644 web/src/App.vue create mode 100644 web/src/api/admin.ts create mode 100644 web/src/api/merchant.ts create mode 100644 web/src/api/request.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/assets/vue.svg create mode 100644 web/src/env.d.ts create mode 100644 web/src/layouts/AdminLayout.vue create mode 100644 web/src/layouts/MerchantLayout.vue create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/style.css create mode 100644 web/src/utils/paymentOptions.ts create mode 100644 web/src/views/Cashier.vue create mode 100644 web/src/views/NotFound.vue create mode 100644 web/src/views/admin/Channels.vue create mode 100644 web/src/views/admin/Dashboard.vue create mode 100644 web/src/views/admin/Login.vue create mode 100644 web/src/views/admin/Merchants.vue create mode 100644 web/src/views/admin/Orders.vue create mode 100644 web/src/views/admin/Refunds.vue create mode 100644 web/src/views/admin/Settlements.vue create mode 100644 web/src/views/admin/TestPayment.vue create mode 100644 web/src/views/merchant/Dashboard.vue create mode 100644 web/src/views/merchant/Login.vue create mode 100644 web/src/views/merchant/Orders.vue create mode 100644 web/src/views/merchant/Profile.vue create mode 100644 web/src/views/merchant/Records.vue create mode 100644 web/src/views/merchant/Refunds.vue create mode 100644 web/src/views/merchant/Register.vue create mode 100644 web/src/views/merchant/Settlements.vue create mode 100644 web/src/views/merchant/TestPayment.vue create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts 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, + }, + }, + }, +})