This commit is contained in:
Joe
2026-01-16 15:49:34 +08:00
commit 550d3e1f42
380 changed files with 62024 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
root = "."
tmp_dir = "storage/temp"
[build]
bin = "./storage/temp/main.exe"
cmd = "go build -o ./storage/temp/main.exe ."
delay = 1000
exclude_dir = ["storage", "database","html"]
exclude_file = []
exclude_regex = []
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false
+98
View File
@@ -0,0 +1,98 @@
APP_NAME=Goravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# APP_HOST=127.0.0.1
APP_HOST=0.0.0.0
APP_PORT=3000
GRPC_HOST=
GRPC_PORT=
# JWT 配置
JWT_SECRET=
JWT_TTL=600 # Token 有效期(分钟),默认 600 分钟
# 日志配置
LOG_CHANNEL=stack
# 开发环境(显示所有日志,包括 debug)
LOG_LEVEL=debug
# 生产环境(只显示 info 及以上级别)
# LOG_LEVEL=info
# 缓存配置
CACHE_STORE=redis
# 队列配置
QUEUE_CONNECTION=redis
QUEUE_CONCURRENT=3
QUEUE_LONG_RUNNING_CONCURRENT=1
QUEUE_TRIES=5
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=goravel
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=file
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6379
# MAIL_HOST=
# MAIL_PORT=
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_FROM_ADDRESS=
# MAIL_FROM_NAME=
# 开发者管理员id,多个
ADMIN_DEVELOPER_IDS=2
# 超管id
ADMIN_SUPER_ADMIN_ID=1
# 隐藏服务监控菜单(开发者管理员除外)
ADMIN_MONITOR_HIDDEN=1
# 是否显示无权限的操作按钮
# false: 不显示(默认)- 用户没有权限时,按钮完全隐藏
# true: 显示但禁用 - 用户没有权限时,按钮显示但处于禁用状态
# ADMIN_SHOW_BUTTONS_WITHOUT_PERMISSION=false
# CORS 配置
CORS_ALLOWED_ORIGINS=https://admin.com,http://localhost:3007
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,X-Timezone,Accept,Origin
CORS_EXPOSED_HEADERS=Authorization,X-Trace-Id
CORS_MAX_AGE=3600
CORS_SUPPORTS_CREDENTIALS=true
# 后台接口域名白名单允许
# Single domain: DOMAINS_ADMIN=admin.example.com
# Multiple domains: DOMAINS_ADMIN=admin.example.com,*.admin.example.com
# Multiple domains with spaces: DOMAINS_ADMIN=admin.example.com, *.admin.example.com
# Disable domain restriction: DOMAINS_ADMIN= (empty or not set)
DOMAINS_ADMIN=
# 启用 pprof(生产环境需要显式设置)
# PPROF_ENABLED=true
# IP 白名单
# 支持单个 IP 和 CIDR 格式,多个 IP 用逗号分隔
# PPROF_ALLOWED_IPS=127.0.0.1,192.168.1.100,10.0.0.0/8
# 访问 token(可选,但强烈推荐)
# 如果设置,需要在请求头或查询参数中提供
# PPROF_TOKEN=your-secret-token-here
# 是否启用开发工具菜单
APP_ENABLE_DEV_TOOL=false
+13
View File
@@ -0,0 +1,13 @@
.idea
.DS_Store
storage/framework
launch.json
.env
.env.local
.env.production
.env.staging
.env.*.local
.env.bak
goravel
main
*.exe
+84
View File
@@ -0,0 +1,84 @@
# 变更日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
## [1.0.0] - 2024-12-25
### 新增
#### 核心功能
- ✨ 完整的后台管理系统
- ✨ JWT 认证 + Token 滑动过期
- ✨ RBAC 权限控制(角色-权限-菜单)
- ✨ 多 Token 管理和在线用户监控
- ✨ WebSocket 实时通知中心
- ✨ 中英文双语支持
#### 管理模块
- 👥 管理员管理(CRUD、密码重置)
- 🎭 角色管理(权限分配)
- 📋 菜单管理(动态菜单、图标)
- 🔐 权限管理(API 权限)
- 🏢 部门管理(树形结构)
- 📖 字典管理(配置项)
- 🚫 黑名单管理(IP/CIDR/范围)
#### 日志监控
- 📝 操作日志(自动记录)
- 🔑 登录日志
- ⚠️ 系统日志(TraceID 追踪)
- 📊 服务监控(CPU/内存/磁盘)
#### 文件管理
- 📁 附件上传(普通/分片)
- 📤 数据导出管理
### 技术特性
#### 后端
- 🔧 Goravel 框架
- 🔧 统一响应封装(response.go
- 🔧 统一日志工具(Debug/Info/Warn/Error 分级)
- 🔧 TraceID 链路追踪
- 🔧 敏感字段过滤
#### 前端
- 🎨 Vue 3 + Composition API
- 🎨 Element Plus UI
- 🎨 VXE-Table 高性能表格
- 🎨 TypeScript 核心模块支持
- 🎨 Composables 高度复用
#### 测试
- ✅ 后端单元测试(tests/unit/
- ✅ 后端集成测试(tests/feature/
- ✅ 前端单元测试(Vitest
#### 文档
- 📚 API 接口文档
- 📚 系统架构文档
- 📚 开发指南
- 📚 测试指南
- 📚 贡献指南
---
## 版本规范
- **主版本号 (Major)**: 不兼容的 API 变更
- **次版本号 (Minor)**: 向后兼容的功能新增
- **修订号 (Patch)**: 向后兼容的问题修复
### 变更类型
| 类型 | 说明 |
|------|------|
| ✨ 新增 | 新功能 |
| 🐛 修复 | Bug 修复 |
| 🔧 优化 | 性能或代码优化 |
| 📚 文档 | 文档更新 |
| ⚠️ 变更 | 不兼容的变更 |
| 🗑️ 移除 | 功能移除 |
---
+307
View File
@@ -0,0 +1,307 @@
# 贡献指南
感谢您对 Goravel Admin 项目的关注!我们欢迎任何形式的贡献。
## 目录
- [行为准则](#行为准则)
- [如何贡献](#如何贡献)
- [开发环境](#开发环境)
- [代码规范](#代码规范)
- [提交规范](#提交规范)
- [Pull Request 流程](#pull-request-流程)
---
## 行为准则
请在参与项目时保持尊重和友善。我们致力于维护一个开放、包容的社区环境。
---
## 如何贡献
### 报告 Bug
1. 检查 [Issues](https://github.com/your-repo/issues) 确认问题未被报告
2. 使用 Bug 模板创建新 Issue
3. 提供详细的复现步骤和环境信息
### 提出新功能
1. 先在 Issues 中讨论您的想法
2. 获得维护者认可后再开始开发
3. 遵循项目的设计理念
### 提交代码
1. Fork 项目
2. 创建功能分支
3. 编写代码和测试
4. 提交 Pull Request
---
## 开发环境
### 前置要求
| 工具 | 版本 |
|------|------|
| Go | 1.21+ |
| Node.js | 20+ |
| MySQL | 8.0+ |
| Redis | 7.0+ |
### 环境搭建
```bash
# 1. 克隆项目
git clone https://github.com/your-repo/goravel-admin.git
cd goravel-admin
# 2. 安装后端依赖
go mod tidy
# 3. 复制环境配置
cp .env.example .env
# 4. 配置数据库连接
# 编辑 .env 文件
# 5. 运行数据库迁移
go run . artisan migrate
go run . artisan db:seed
# 6. 启动后端服务
go run . --no-ansi
# 或使用 air 热重载
air
# 7. 安装前端依赖
cd html
npm install
# 8. 启动前端开发服务器
npm run dev
```
### 开发工具推荐
- **IDE**: VSCode / GoLand
- **Go 扩展**: gopls
- **Vue 扩展**: Volar
- **调试工具**: Delve (Go)
---
## 代码规范
### Go 代码规范
```go
// 1. 使用 gofmt 格式化代码
gofmt -w .
// 2. 遵循 Effective Go 指南
// https://go.dev/doc/effective_go
// 3. 函数命名使用驼峰式
func GetAdminList() {}
// 4. 常量使用大写下划线
const MAX_PAGE_SIZE = 100
// 5. 错误处理
if err != nil {
return nil, err
}
// 6. 注释规范
// GetAdminList 获取管理员列表
// 参数 page 为页码
func GetAdminList(page int) {}
```
### Vue/TypeScript 代码规范
```typescript
// 1. 使用 Composition API
import { ref, computed, onMounted } from 'vue'
// 2. 组件命名使用 PascalCase
// components/UserList.vue
// 3. Composables 命名使用 use 前缀
// composables/useCrud.ts
// 4. 类型定义
interface User {
id: number
name: string
}
// 5. 使用 const 声明不变的引用
const tableRef = ref<VxeTableInstance>()
// 6. 导出函数使用具名导出
export function useCrud() {}
```
### CSS/SCSS 规范
```scss
// 1. 使用 BEM 命名规范
.admin-list {
&__header {}
&__content {}
&--active {}
}
// 2. 使用变量管理颜色
$primary-color: #409eff;
// 3. 避免过深的嵌套(最多 3 层)
```
---
## 提交规范
### Commit Message 格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Type 类型
| 类型 | 说明 |
|------|------|
| `feat` | 新功能 |
| `fix` | Bug 修复 |
| `docs` | 文档更新 |
| `style` | 代码格式(不影响功能) |
| `refactor` | 重构 |
| `perf` | 性能优化 |
| `test` | 测试相关 |
| `chore` | 构建/工具相关 |
### 示例
```bash
# 新功能
feat(admin): add batch delete feature
# Bug 修复
fix(auth): fix token refresh issue
# 文档
docs: update API documentation
# 重构
refactor(composables): convert useCrud to TypeScript
```
---
## Pull Request 流程
### 1. 创建分支
```bash
# 从 main 分支创建功能分支
git checkout main
git pull origin main
git checkout -b feature/your-feature-name
```
### 2. 开发和测试
```bash
# 后端测试
go test ./tests/...
# 前端测试
cd html && npm run test:run
# 类型检查
cd html && npm run type-check
```
### 3. 提交代码
```bash
git add .
git commit -m "feat(module): add new feature"
git push origin feature/your-feature-name
```
### 4. 创建 Pull Request
1. 在 GitHub 上创建 PR
2. 填写 PR 模板
3. 关联相关 Issue
4. 请求 Code Review
### 5. Code Review
- 回复评论并修改代码
- 确保所有 CI 检查通过
- 等待维护者合并
---
## PR 检查清单
在提交 PR 前,请确认:
- [ ] 代码符合项目规范
- [ ] 添加了必要的测试
- [ ] 所有测试通过
- [ ] 更新了相关文档
- [ ] Commit message 符合规范
- [ ] 无冲突可合并
---
## 目录结构
提交代码时,请遵循项目的目录结构:
```
.
├── app/ # 后端应用代码
│ ├── http/controllers/ # 控制器
│ ├── models/ # 数据模型
│ ├── services/ # 业务服务
│ └── utils/ # 工具函数
├── html/ # 前端应用代码
│ └── src/
│ ├── api/ # API 请求
│ ├── components/ # 通用组件
│ ├── composables/ # 可复用逻辑
│ ├── views/ # 页面组件
│ └── types/ # TypeScript 类型
├── tests/ # 测试代码
│ ├── unit/ # 单元测试
│ └── feature/ # 集成测试
└── docs/ # 文档
```
---
## 联系我们
- **Issues**: [GitHub Issues](https://github.com/your-repo/issues)
- **Discussions**: [GitHub Discussions](https://github.com/your-repo/discussions)
- **Discord**: [Goravel Discord](https://discord.gg/cFc5csczzS)
---
感谢您的贡献!🎉
+277
View File
@@ -0,0 +1,277 @@
# Docker 蓝绿部署快速指南
## 📋 概述
本项目支持 Docker Compose 蓝绿部署,实现零停机更新。适合本地没有 Docker 环境,但服务器有 Docker 的场景。
## 🚀 快速开始
### 1. 本地开发(无需 Docker
```bash
# 正常开发,提交代码
git add .
git commit -m "更新功能"
git push origin main
```
### 2. 服务器部署
#### 首次部署
```bash
# SSH 登录服务器
ssh user@your-server.com
# 创建部署目录
mkdir -p /www/goravel-admin
cd /www/goravel-admin
# 克隆仓库
git clone https://github.com/your-username/goravel-admin.git .
# 配置环境变量
cp .env.example .env # 如果存在
vim .env # 编辑配置
# 执行部署
chmod +x scripts/deploy/git-deploy.sh
./scripts/deploy/git-deploy.sh
```
#### 后续部署
```bash
# SSH 登录服务器
ssh user@your-server.com
# 进入部署目录
cd /www/goravel-admin
# 方式一:使用 Git 部署脚本(推荐)
./scripts/deploy/git-deploy.sh
# 方式二:手动拉取代码后部署
git pull origin main
./scripts/deploy/docker-blue-green.sh
```
## 📁 文件结构
```
项目根目录/
├── docker-compose.blue.yml # 蓝环境配置(端口 3000)
├── docker-compose.green.yml # 绿环境配置(端口 3001)
├── Dockerfile # Docker 镜像构建文件
└── scripts/
└── deploy/
├── docker-blue-green.sh # 蓝绿部署主脚本
├── git-deploy.sh # Git 拉取并部署脚本
├── rollback.sh # 快速回滚脚本
├── rollback-git.sh # Git 版本回滚脚本
└── README.md # 详细说明文档
```
## 🔧 配置说明
### 端口配置
**重要:** 所有端口配置统一在 `scripts/deploy/deploy-config.sh` 文件中管理。
只需修改一个文件即可更改所有端口:
```bash
# 编辑配置文件
vim scripts/deploy/deploy-config.sh
# 修改端口配置:
export BLUE_PORT="${BLUE_PORT:-3000}" # 蓝环境端口(默认 3000
export GREEN_PORT="${GREEN_PORT:-3001}" # 绿环境端口(默认 3001
export CONTAINER_PORT="${CONTAINER_PORT:-3000}" # 容器内部端口(默认 3000
```
或通过环境变量临时覆盖:
```bash
export BLUE_PORT=4000
export GREEN_PORT=4001
./scripts/deploy/docker-blue-green.sh
```
详细说明请参考:`scripts/deploy/README-PORT-CONFIG.md`
### 环境变量
在服务器上创建 `.env` 文件,配置数据库、Redis 等:
```env
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=3000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_password
# ... 其他配置
```
### Git 仓库配置
如果使用 `git-deploy.sh`,可以设置环境变量:
```bash
export GIT_REPO_URL="https://github.com/your-username/goravel-admin.git"
export GIT_BRANCH="main"
export DEPLOY_DIR="/www/goravel-admin"
```
或直接编辑 `scripts/deploy/git-deploy.sh` 文件中的配置。
## 📊 部署流程
1. **检测当前版本** - 自动检测运行的是 `blue` 还是 `green`
2. **构建新版本** - 在备用环境构建新 Docker 镜像(包含启动脚本)
3. **启动新版本** - 启动新版本容器(使用不同端口)
4. **自动执行迁移** - 容器启动脚本会在应用启动前自动执行数据库迁移
5. **数据填充(可选)** - 提示是否执行数据填充
6. **健康检查** - 等待新版本通过健康检查
7. **切换流量** - 更新 Nginx 配置(如果存在)
8. **停止旧版本** - 停止旧版本容器
## 🗄️ 数据库迁移和填充
### 自动迁移(容器启动时执行)
**重要:** 迁移在容器启动时自动执行,确保在应用启动前完成:
-**启动前执行** - 容器启动脚本会在应用启动前执行迁移,避免字段不存在错误
-**自动执行** - 无需手动操作,每次容器启动都会执行
-**幂等操作** - Goravel 的 `migrate` 命令是安全的,可以重复执行
-**失败退出** - 如果迁移失败,容器会退出,部署脚本会自动检测并回滚
- ⚙️ **可配置** - 通过环境变量 `SKIP_MIGRATE=true` 可以跳过迁移
### 数据填充
- ⚠️ **需要确认** - 部署时会提示是否执行数据填充
- ⚠️ **可能重复** - 填充可能会重复插入数据,请谨慎使用
- 💡 **建议** - 通常只在首次部署或需要更新基础数据时执行
### 通过环境变量控制
```bash
# 自动执行填充(不提示)
export RUN_SEED=true
./scripts/deploy/docker-blue-green.sh
# 跳过填充(不提示)
export RUN_SEED=false
./scripts/deploy/docker-blue-green.sh
```
### 单独执行
```bash
# 执行数据库迁移
./scripts/deploy/migrate.sh
# 执行数据填充
./scripts/deploy/seed.sh
```
## 🔍 查看状态
```bash
# 查看运行中的容器
docker ps | grep goravel-admin
# 查看容器日志
docker logs -f goravel-admin-blue
docker logs -f goravel-admin-green
# 查看健康状态
docker inspect --format='{{.State.Health.Status}}' goravel-admin-blue
```
## 🔄 回滚
### 快速回滚(推荐)
如果刚部署的版本有问题,快速回滚到上一个版本:
```bash
cd /www/goravel-admin
chmod +x scripts/deploy/rollback.sh
./scripts/deploy/rollback.sh
```
**特点:**
- ✅ 自动检测当前版本
- ✅ 零停机切换
- ✅ 自动健康检查
- ✅ 自动切换 Nginx 流量
### Git 版本回滚
回滚到特定的 Git 提交、标签或分支:
```bash
cd /www/goravel-admin
chmod +x scripts/deploy/rollback-git.sh
# 查看最近提交
./scripts/deploy/rollback-git.sh
# 回滚到上一个提交
./scripts/deploy/rollback-git.sh HEAD~1
# 回滚到指定提交
./scripts/deploy/rollback-git.sh abc1234
# 回滚到指定标签
./scripts/deploy/rollback-git.sh v1.0.0
```
### 手动回滚
如果需要手动控制:
```bash
cd /www/goravel-admin
# 方式一:手动切换容器
docker-compose -f docker-compose.blue.yml up -d # 启动 blue
docker-compose -f docker-compose.green.yml down # 停止 green
# 方式二:回滚代码后重新部署
git checkout <previous-commit>
./scripts/deploy/docker-blue-green.sh
```
## ⚠️ 故障处理
如果部署失败:
- 脚本会自动停止新版本容器
- 旧版本继续运行
- 检查日志:`docker logs goravel-admin-<color>`
## 📝 注意事项
1. **首次部署**:确保服务器已安装 Docker 和 Docker Compose
2. **环境变量**:确保 `.env` 文件配置正确
3. **健康检查**:应用需要提供 `/health` 端点(已配置)
4. **端口冲突**:确保 3000 和 3001 端口未被占用
5. **存储持久化**`storage` 目录会持久化到宿主机
## 🔗 相关文档
- 详细部署文档:`docs/BUILD.md`
- 脚本说明:`scripts/deploy/README.md`
## 💡 提示
- 本地开发无需 Docker,只需 Go 环境
- 部署在服务器上自动完成,无需手动操作
- 支持零停机部署,用户体验无影响
- 支持快速回滚,降低部署风险
+38
View File
@@ -0,0 +1,38 @@
FROM golang:alpine AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOARCH="amd64" \
GOOS=linux
WORKDIR /build
COPY . .
RUN go mod tidy
RUN go build --ldflags "-extldflags -static" -o main .
FROM alpine:latest
# 安装必要的工具(用于健康检查)
RUN apk add --no-cache ca-certificates tzdata curl wget
WORKDIR /www
COPY --from=builder /build/main /www/
COPY --from=builder /build/database/ /www/database/
COPY --from=builder /build/public/ /www/public/
COPY --from=builder /build/storage/ /www/storage/
COPY --from=builder /build/resources/ /www/resources/
COPY --from=builder /build/.env /www/.env
# 复制启动脚本
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# 健康检查(增加启动等待时间,因为需要执行迁移)
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
EXPOSE 3000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD []
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 goravel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+327
View File
@@ -0,0 +1,327 @@
<p align="center"><img src="https://www.goravel.dev/logo.png?v=1.14.x" width="300"></p>
English | [中文](./README_zh.md)
## About Goravel
Goravel is a web application framework with complete functions and good scalability. As a starting scaffolding to help Gopher quickly build their own applications.
The framework style is consistent with [Laravel](https://github.com/laravel/laravel), let Phper don't need to learn a new framework, but also happy to play around Golang! Tribute Laravel!
Welcome to star, PR and issues
## Admin System
This project includes a complete admin management system built with Goravel framework.
```bash
git clone https://github.com/1768177868/goravel-admin.git
```
> Demo https://admin.xuancheng888.top
username: demo
password: demo123
### Screenshots
<p align="center">
<img src="./images/login.png" alt="Login Page" width="800">
<p align="center">Login Page</p>
</p>
<p align="center">
<img src="./images/admin.png" alt="Admin Dashboard" width="800">
<p align="center">Admin Dashboard</p>
</p>
<p align="center">
<img src="./images/monitor.png" alt="System Monitoring" width="800">
<p align="center">System Monitoring</p>
</p>
<p align="center">
<img src="./images/pages.png" alt="cloudflare" width="800">
<p align="center">cloudflare</p>
</p>
### Features
#### Core Modules
- **Authentication & Authorization**
- JWT-based authentication
- Role-based access control (RBAC)
- Permission management
- Multi-token management
- Online admin monitoring and kick-out
- **Admin Management**
- Admin user management
- Department management
- Role management
- Permission assignment
- Password reset
- **System Configuration**
- Menu management (dynamic menu)
- Dictionary management
- System configuration
- Blacklist management
- **Logging & Monitoring**
- Operation logs (with automatic recording)
- Login logs
- System logs (with trace ID)
- Service monitoring
- **Additional Features**
- Dashboard with statistics
- Notification center (WebSocket real-time notifications)
- Data export management
- Multi-language support (Chinese/English)
- Responsive UI design
### Tech Stack
**Backend:**
- Goravel Framework (Go)
- RBAC Permission System
- WebSocket Support
- Database Migrations & Seeders
**Frontend:**
- Vue 3
- Element Plus
- vxe-table (Advanced table component)
- Vue Router
- Pinia (State management)
- Axios
- ECharts (Data visualization)
- vue-i18n (Internationalization)
### Quick Start
1. **Backend Setup:**
```bash
# Install dependencies
go mod tidy
# Configure database in .env
# Run migrations and seeders
go run . artisan migrate
go run . artisan db:seed
# Start server
go run . --no-ansi
# or use air for live reload
air
```
2. **Frontend Setup:**
```bash
cd html
# Install dependencies
npm install
# Configure API address in .env
# VITE_API_BASE_URL=http://127.0.0.1:3000
# VITE_API_PREFIX=/api/admin
# Start development server
npm run dev
```
3. **Default Login:**
- Username: `admin`
- Password: `admin123`
- (Please change the default password after first login)
### Build & Deployment
For detailed build and deployment instructions, including cross-platform compilation, Docker deployment, and systemd service setup, please refer to [BUILD.md](./docs/BUILD.md).
### API Documentation
The admin API endpoints are prefixed with `/api/admin`. All endpoints require JWT authentication except login and captcha.
For detailed API documentation, see [routes/admin.go](./routes/admin.go)
#### Swagger API Documentation
The project includes Swagger API documentation for interactive API exploration.
**Access Swagger Documentation:**
The Swagger JSON document is available at:
- Local development: `http://localhost:3000/swagger/index.html`
- Production: `https://your-domain.com/swagger/index.html`
**Regenerate Swagger Documentation:**
After modifying API routes or adding new endpoints, regenerate the Swagger documentation:
```bash
# Generate Swagger documentation
swag init
```
This will regenerate the `docs/docs.go`, `docs/swagger.json`, and `docs/swagger.yaml` files based on the Swagger annotations in your code (see `main.go` for example annotations).
### Project Structure
```
.
├── app/
│ ├── http/
│ │ ├── controllers/admin/ # Admin controllers
│ │ ├── middleware/ # Custom middleware (JWT, Permission, OperationLog)
│ │ └── helpers/ # Helper functions
│ ├── models/ # Database models
│ └── services/ # Business logic services
├── routes/
│ └── admin.go # Admin routes
├── database/
│ ├── migrations/ # Database migrations
│ └── seeders/ # Database seeders
├── html/ # Frontend Vue application
│ └── src/
│ ├── views/ # Page components
│ ├── components/ # Reusable components
│ ├── api/ # API client
│ └── store/ # Pinia stores
├── config/ # Configuration files
├── docs/ # Documentation
│ ├── API.md # API documentation
│ ├── ARCHITECTURE.md # Architecture documentation
│ ├── BUILD.md # Build and deployment
│ ├── SHARDING_MIGRATION.md # Database sharding guide
│ └── ... # Other documentation
├── CONTRIBUTING.md # Contribution guidelines
├── CHANGELOG.md # Version history
└── images/ # Screenshots
```
### Database Sharding
The project supports monthly sharding strategy and has implemented monthly sharding for order tables. For detailed documentation on how to create, use, and modify sharding tables, please refer to [docs/SHARDING_MIGRATION.md](./docs/SHARDING_MIGRATION.md).
### Security Features
- JWT token-based authentication
- Permission middleware for route protection
- Automatic operation logging
- Sensitive data filtering in logs
- Rate limiting on login endpoints
- Blacklist management for IP/Admin blocking
- Token revocation support
## Getting Started
### Start Service
`go run . --no-ansi` or `air`
[About air]: https://www.goravel.dev/getting-started/installation.html#live-reload
### Cloudflare Workers Deployment
Deploy the frontend application to Cloudflare Workers:
```bash
# Build the frontend application
cd html
# Note: Cloudflare Workers build environment automatically runs npm ci
# If you encounter Rollup optional dependency issues, use the following build command:
npm install --include=optional @rollup/rollup-linux-x64-gnu && npm run build
# Or use the project's CI build script:
npm run build:ci
# Deploy to Cloudflare Workers
npx wrangler deploy --assets ./dist --compatibility-date 2025-11-29 --name admin
```
**Configuration:**
- **Root directory:** `html`
- **Environment variables (Variables & Secrets):**
- `VITE_API_BASE_URL`: `https://api.xuancheng888.top`
- `VITE_API_PREFIX`: `/api/admin`
- **Custom domain:** `admin.xuancheng888.top`
**Note:** The `worker.js` file automatically handles SPA routing by returning `index.html` when a file doesn't exist.
### Performance Profiling
pprof is available at: http://localhost:3000/debug/pprof/
### Binary Compression
To reduce the binary size, you can use UPX (Ultimate Packer for eXecutables) to compress the compiled executable:
**Windows:**
1. Download UPX (Windows 64-bit version):
- Official download: https://github.com/upx/upx/releases/latest
- Select `upx-5.0.2-win64.zip` (or the latest version)
- Extract the zip to a path without Chinese characters or spaces (e.g., `F:\tools\upx`)
- Ensure `upx.exe` is accessible
2. Compress the binary (PowerShell):
```powershell
# Option 1: Temporarily add UPX to PATH (recommended)
$env:PATH += ";F:\tools\upx"
# Navigate to project directory
cd F:\www\go\admin\goravel-admin
# Maximum compression level (-9)
upx -9 main
```
**Linux/macOS:**
```bash
# Install UPX (if not already installed)
# Ubuntu/Debian: sudo apt-get install upx
# macOS: brew install upx
# Compress the binary
upx -9 main
```
## Documentation
### Project Documentation
| Document | Description |
|----------|-------------|
| [API.md](./docs/API.md) | Complete API reference with examples |
| [ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System architecture and design |
| [DEVELOPMENT_GUIDE.md](./docs/DEVELOPMENT_GUIDE.md) | Development guide: Complete CRUD module development example (using guestbook module as example, includes backend APIs and frontend pages) |
| [SHARDING_MIGRATION.md](./docs/SHARDING_MIGRATION.md) | Database sharding guide (creating, using, and modifying sharding tables) |
| [BUILD.md](./docs/BUILD.md) | Build and deployment |
| [TESTING.md](./docs/TESTING.md) | Testing guide (unit & integration) |
| [CONTRIBUTING.md](./CONTRIBUTING.md) | Contribution guidelines |
| [CHANGELOG.md](./CHANGELOG.md) | Version history |
| [Frontend Guide](./html/DEVELOPMENT.md) | Frontend development guide |
### Goravel Framework
Online documentation [https://www.goravel.dev](https://www.goravel.dev)
> To optimize the documentation, please submit a PR to the documentation
> repository [https://github.com/goravel/docs](https://github.com/goravel/docs)
## Group
Welcome more discussion in Discord.
[https://discord.gg/cFc5csczzS](https://discord.gg/cFc5csczzS)
## License
The Goravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+328
View File
@@ -0,0 +1,328 @@
<p align="center"><img src="https://www.goravel.dev/logo.png?v=1.14.x" width="300"></p>
[English](./README.md) | 中文
## 关于 Goravel
Goravel 是一个功能完整、可扩展性良好的 Web 应用框架。作为起始脚手架,帮助 Gopher 快速构建自己的应用程序。
框架风格与 [Laravel](https://github.com/laravel/laravel) 保持一致,让 Phper 无需学习新框架,也能愉快地使用 Golang!致敬 Laravel
欢迎 Star、PR 和 Issues
## 后台管理系统
本项目包含一个基于 Goravel 框架构建的完整后台管理系统。
```bash
git clone https://github.com/1768177868/goravel-admin.git
```
> 演示站 https://admin.xuancheng888.top
账号: demo
密码: demo123
### 截图展示
<p align="center">
<img src="./images/login.png" alt="登录页面" width="800">
<p align="center">登录页面</p>
</p>
<p align="center">
<img src="./images/admin.png" alt="后台管理界面" width="800">
<p align="center">后台管理界面</p>
</p>
<p align="center">
<img src="./images/monitor.png" alt="系统监控" width="800">
<p align="center">系统监控</p>
</p>
<p align="center">
<img src="./images/pages.png" alt="cloudflare" width="800">
<p align="center">cloudflare</p>
</p>
### 功能特性
#### 核心模块
- **认证与授权**
- 基于角色的访问控制(RBAC
- 权限管理
- 多令牌管理
- 在线用户监控与踢出
- **管理员管理**
- 管理员用户管理
- 部门管理
- 角色管理
- 权限分配
- 密码重置
- **系统配置**
- 菜单管理(动态菜单)
- 字典管理
- 系统配置
- 黑名单管理
- **日志与监控**
- 操作日志(自动记录)
- 登录日志
- 系统日志(带追踪 ID
- 服务监控
- **附加功能**
- 数据统计仪表盘
- 通知中心(WebSocket 实时通知)
- 数据导出管理
- 多语言支持(中文/英文)
- 响应式 UI 设计
### 技术栈
**后端:**
- Goravel 框架(Go
- JWT 认证
- RBAC 权限系统
- WebSocket 支持
- 数据库迁移与填充
**前端:**
- Vue 3
- Element Plus
- vxe-table(高级表格组件)
- Vue Router
- Pinia(状态管理)
- Axios
- ECharts(数据可视化)
- vue-i18n(国际化)
### 快速开始
1. **后端配置:**
```bash
# 安装依赖
go mod tidy
# 在 .env 中配置数据库
# 运行数据库迁移和填充
go run . artisan migrate
go run . artisan db:seed
# 启动服务
go run . --no-ansi
# 或使用 air 进行热重载
air
```
2. **前端配置:**
```bash
cd html
# 安装依赖
npm install
# 在 .env 中配置 API 地址
# VITE_API_BASE_URL=http://127.0.0.1:3000
# VITE_API_PREFIX=/api/admin
# 启动开发服务器
npm run dev
```
3. **默认登录:**
- 用户名:`admin`
- 密码:`admin123`
- (首次登录后请修改默认密码)
### 构建与部署
详细的编译打包和部署说明,包括跨平台编译、Docker 部署、systemd 服务配置等,请参考 [BUILD.md](./docs/BUILD.md)。
### API 文档
后台管理 API 接口前缀为 `/api/admin`。除登录和验证码接口外,所有接口都需要 JWT 认证。
详细的 API 文档请查看 [routes/admin.go](./routes/admin.go)
#### Swagger API 文档
项目包含 Swagger API 文档,支持交互式 API 探索。
**访问 Swagger 文档:**
Swagger JSON 文档访问地址:
- 本地开发:`http://localhost:3000/swagger/index.html`
- 生产环境:`https://your-domain.com/swagger/index.html`
**重新生成 Swagger 文档:**
修改 API 路由或添加新接口后,需要重新生成 Swagger 文档:
```bash
# 生成 Swagger 文档
swag init
```
这将根据代码中的 Swagger 注解(示例见 `main.go`)重新生成 `docs/docs.go`、`docs/swagger.json` 和 `docs/swagger.yaml` 文件。
### 项目结构
```
.
├── app/
│ ├── http/
│ │ ├── controllers/admin/ # 后台控制器
│ │ ├── middleware/ # 自定义中间件(JWT、权限、操作日志)
│ │ └── helpers/ # 辅助函数
│ ├── models/ # 数据库模型
│ └── services/ # 业务逻辑服务
├── routes/
│ └── admin.go # 后台路由
├── database/
│ ├── migrations/ # 数据库迁移
│ └── seeders/ # 数据库填充
├── html/ # 前端 Vue 应用
│ └── src/
│ ├── views/ # 页面组件
│ ├── components/ # 可复用组件
│ ├── api/ # API 客户端
│ └── store/ # Pinia 状态管理
├── config/ # 配置文件
├── docs/ # 文档目录
│ ├── API.md # API 接口文档
│ ├── ARCHITECTURE.md # 架构设计文档
│ ├── BUILD.md # 编译打包与部署
│ ├── SHARDING_MIGRATION.md # 数据库分表指南
│ └── ... # 其他文档
├── CONTRIBUTING.md # 贡献指南
├── CHANGELOG.md # 版本变更记录
└── images/ # 截图文件
```
### 数据库分表
项目支持按月分表策略,已实现订单表的按月分表功能。关于如何创建、使用和修改分表的详细文档,请参考 [docs/SHARDING_MIGRATION.md](./docs/SHARDING_MIGRATION.md)。
### 安全特性
- 基于 JWT 令牌的认证
- 权限中间件保护路由
- 自动操作日志记录
- 日志中敏感数据过滤
- 登录接口限流
- IP/管理员黑名单管理
- 令牌撤销支持
## 快速入门
### 启动服务
`go run . --no-ansi` 或 `air`
[关于 air]https://www.goravel.dev/getting-started/installation.html#live-reload
### Cloudflare Workers 部署
将前端应用部署到 Cloudflare Workers
```bash
# 构建前端应用
cd html
# 注意:Cloudflare Workers 构建环境会自动运行 npm ci
# 如果遇到 Rollup 可选依赖问题,请使用以下构建命令:
npm install --include=optional @rollup/rollup-linux-x64-gnu && npm run build
# 或者使用项目提供的 CI 构建脚本:
npm run build:ci
# 部署到 Cloudflare Workers
npx wrangler deploy --assets ./dist --compatibility-date 2025-11-29 --name admin
```
**配置说明:**
- **根目录:** `html`
- **环境变量(变量和机密):**
- `VITE_API_BASE_URL`: `https://api.xuancheng888.top`
- `VITE_API_PREFIX`: `/api/admin`
- **自定义域名:** `admin.xuancheng888.top`
**注意:** `worker.js` 文件会自动处理 SPA 路由,当文件不存在时返回 `index.html`。
### 性能分析
pprof 性能分析工具地址:http://localhost:3000/debug/pprof/
### 二进制压缩
为了减小二进制文件大小,可以使用 UPXUltimate Packer for eXecutables)压缩编译后的可执行文件:
**Windows**
1. 下载 UPXWindows 64 位版本):
- 官网下载:https://github.com/upx/upx/releases/latest
- 选择 `upx-5.0.2-win64.zip`(或最新版本)
- 解压到无中文或空格的路径(例如:`F:\tools\upx`
- 确保 `upx.exe` 可访问
2. 压缩二进制文件(PowerShell):
```powershell
# 方式1:临时添加 UPX 到环境变量(推荐)
$env:PATH += ";F:\tools\upx"
# 进入项目目录
cd F:\www\go\admin\goravel-admin
# 最高级别压缩(-9
upx -9 main
```
**Linux/macOS**
```bash
# 安装 UPX(如果尚未安装)
# Ubuntu/Debian: sudo apt-get install upx
# macOS: brew install upx
# 压缩二进制文件
upx -9 main
```
## 文档
### 项目文档
| 文档 | 说明 |
|------|------|
| [API.md](./docs/API.md) | 完整 API 接口文档 |
| [ARCHITECTURE.md](./docs/ARCHITECTURE.md) | 系统架构设计 |
| [DEVELOPMENT_GUIDE.md](./docs/DEVELOPMENT_GUIDE.md) | 开发指南:CRUD 模块完整开发示例(以留言板模块为例,包含后端接口和前端页面) |
| [SHARDING_MIGRATION.md](./docs/SHARDING_MIGRATION.md) | 数据库分表指南(创建、使用和修改分表) |
| [BUILD.md](./docs/BUILD.md) | 编译打包与部署 |
| [TESTING.md](./docs/TESTING.md) | 测试指南(单元测试 & 集成测试) |
| [CONTRIBUTING.md](./CONTRIBUTING.md) | 贡献指南 |
| [CHANGELOG.md](./CHANGELOG.md) | 版本变更记录 |
| [前端开发指南](./html/DEVELOPMENT.md) | 前端开发文档 |
### Goravel 框架文档
在线文档 [https://www.goravel.dev](https://www.goravel.dev)
> 要优化文档,请向文档仓库提交 PR
> [https://github.com/goravel/docs](https://github.com/goravel/docs)
## 社区
欢迎在 Discord 中讨论。
[https://discord.gg/cFc5csczzS](https://discord.gg/cFc5csczzS)
## 许可证
Goravel 框架是在 [MIT 许可证](https://opensource.org/licenses/MIT) 下发布的开源软件。
+162
View File
@@ -0,0 +1,162 @@
package commands
import (
"fmt"
"strings"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/utils"
)
type AnalyzeStats struct {
}
func (r *AnalyzeStats) Signature() string {
return "db:analyze-stats"
}
func (r *AnalyzeStats) Description() string {
// # 默认:分析当前月+上个月的 orders/order_details 分表,并分析 payments 表
// go run . artisan db:analyze-stats
//
// # 指定向前分析几个月(含当前月)
// go run . artisan db:analyze-stats --months=2
// go run . artisan db:analyze-stats --months=6
//
// # 指定某一个月(只分析该月的分表)
// go run . artisan db:analyze-stats --month=202601
//
// # 只分析订单分表,不分析支付表
// go run . artisan db:analyze-stats --payments=false
//
// # 只分析 payments 表
// go run . artisan db:analyze-stats --orders=false --order-details=false
//
// # 帮助
// go run . artisan db:analyze-stats --help
return "更新订单分表与支付表统计信息(ANALYZE)"
}
func (r *AnalyzeStats) Extend() command.Extend {
return command.Extend{
Category: "db",
Flags: []command.Flag{
&command.StringFlag{
Name: "month",
Aliases: []string{"m"},
Usage: "指定月份(格式: YYYYMM,如:202512),不指定则按 months 向前分析",
},
&command.IntFlag{
Name: "months",
Aliases: []string{"n"},
Value: 2,
Usage: "向前分析几个月(默认2:当前月+上个月)",
},
&command.BoolFlag{
Name: "orders",
Value: true,
Usage: "是否分析订单分表(orders_YYYYMM",
},
&command.BoolFlag{
Name: "order-details",
Value: true,
Usage: "是否分析订单详情分表(order_details_YYYYMM",
},
&command.BoolFlag{
Name: "payments",
Value: true,
Usage: "是否分析支付记录表(payments",
},
},
}
}
func (r *AnalyzeStats) Handle(ctx console.Context) error {
dbConnection := strings.ToLower(facades.Config().GetString("database.default", "sqlite"))
monthsFlag := ctx.OptionInt("months")
if monthsFlag <= 0 {
monthsFlag = 2
}
monthFlag := strings.TrimSpace(ctx.Option("month"))
analyzeOrders := ctx.OptionBool("orders")
analyzeOrderDetails := ctx.OptionBool("order-details")
analyzePayments := ctx.OptionBool("payments")
var months []time.Time
if monthFlag != "" {
parsedTime, err := time.ParseInLocation("200601", monthFlag, time.UTC)
if err != nil {
return fmt.Errorf("月份格式错误,应为 YYYYMM 格式(如:202512): %v", err)
}
months = []time.Time{parsedTime}
} else {
now := time.Now().UTC()
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < monthsFlag; i++ {
months = append(months, currentMonth.AddDate(0, -i, 0))
}
}
ctx.Info("开始执行 ANALYZE...")
execAnalyze := func(table string) error {
var sql string
switch dbConnection {
case "mysql":
sql = fmt.Sprintf("ANALYZE TABLE `%s`", table)
case "postgres":
sql = fmt.Sprintf("ANALYZE %s", table)
default:
return fmt.Errorf("unsupported database: %s", dbConnection)
}
_, err := facades.Orm().Query().Exec(sql)
if err != nil {
return err
}
ctx.Info("✓ " + sql)
return nil
}
if analyzeOrders {
for _, m := range months {
table := utils.GetShardingTableName("orders", m)
if facades.Schema().HasTable(table) {
if err := execAnalyze(table); err != nil {
return fmt.Errorf("analyze %s failed: %v", table, err)
}
}
}
}
if analyzeOrderDetails {
for _, m := range months {
table := utils.GetShardingTableName("order_details", m)
if facades.Schema().HasTable(table) {
if err := execAnalyze(table); err != nil {
return fmt.Errorf("analyze %s failed: %v", table, err)
}
}
}
}
if analyzePayments {
if facades.Schema().HasTable("payments") {
if err := execAnalyze("payments"); err != nil {
return fmt.Errorf("analyze payments failed: %v", err)
}
}
}
ctx.Info("完成")
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/path"
"goravel/app/utils"
)
type ClearChunks struct {
}
// Signature The name and signature of the console command.
func (r *ClearChunks) Signature() string {
return "app:clear-chunks"
}
// Description The console command description.
func (r *ClearChunks) Description() string {
return "清理3天前的分片文件"
}
// Extend The console command extend.
func (r *ClearChunks) Extend() command.Extend {
return command.Extend{Category: "app"}
}
// Handle Execute the console command.
func (r *ClearChunks) Handle(ctx console.Context) error {
// 从数据库读取文件存储配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = "local"
}
// 检查存储驱动是否为本地存储
if disk != "local" && disk != "public" {
ctx.Info(fmt.Sprintf("当前存储驱动为 %s,清理分片文件功能仅支持本地存储,跳过清理", disk))
return nil
}
storage := facades.Storage().Disk(disk)
// 计算3天前的时间
threeDaysAgo := time.Now().AddDate(0, 0, -3)
ctx.Info(fmt.Sprintf("开始清理3天前的分片文件(%s之前创建的文件)...", threeDaysAgo.Format(utils.DateTimeFormat)))
// 获取存储根目录
var storageRoot string
if disk == "public" {
storageRoot = path.Storage("app/public")
} else {
storageRoot = path.Storage("app")
}
chunksDir := filepath.Join(storageRoot, "chunks")
// 检查目录是否存在
if _, err := os.Stat(chunksDir); os.IsNotExist(err) {
ctx.Info("分片目录不存在,无需清理")
return nil
}
cleanedCount := 0
cleanedSize := int64(0)
errorCount := 0
// 遍历 chunks 目录下的所有文件
err := filepath.Walk(chunksDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// 如果无法访问某个文件/目录,记录错误但继续
ctx.Info(fmt.Sprintf("无法访问路径 %s: %v", path, err))
errorCount++
return nil
}
// 跳过根目录本身
if path == chunksDir {
return nil
}
// 只处理文件(分片文件)
if !info.IsDir() {
// 跳过 .gitignore 文件,避免误删
if info.Name() == ".gitignore" {
return nil
}
// 检查文件修改时间
if info.ModTime().Before(threeDaysAgo) {
// 使用 Storage 接口删除文件(保持一致性)
relativePath := strings.TrimPrefix(path, storageRoot+string(filepath.Separator))
relativePath = strings.ReplaceAll(relativePath, string(filepath.Separator), "/")
if err := storage.Delete(relativePath); err != nil {
ctx.Info(fmt.Sprintf("删除分片文件失败 %s: %v", relativePath, err))
errorCount++
} else {
cleanedCount++
cleanedSize += info.Size()
}
}
}
return nil
})
if err != nil {
ctx.Error(fmt.Sprintf("遍历分片目录失败: %v", err))
return err
}
// 清理空目录(避免留下大量空目录)
emptyDirCount := r.cleanupEmptyDirs(chunksDir, ctx)
ctx.Info(fmt.Sprintf("分片文件清理完成!已清理 %d 个文件,释放空间 %s,删除 %d 个空目录,错误数: %d",
cleanedCount,
r.formatFileSize(cleanedSize),
emptyDirCount,
errorCount))
return nil
}
// cleanupEmptyDirs 清理空目录
func (r *ClearChunks) cleanupEmptyDirs(rootDir string, _ console.Context) int {
var dirs []string
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() && path != rootDir {
dirs = append(dirs, path)
}
return nil
})
if err != nil {
return 0
}
// 反向遍历目录(从最深层的开始)
removedCount := 0
for i := len(dirs) - 1; i >= 0; i-- {
dir := dirs[i]
// 检查目录是否为空
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
if len(entries) == 0 {
// 目录为空,删除它
if err := os.Remove(dir); err == nil {
removedCount++
}
}
}
return removedCount
}
// formatFileSize 格式化文件大小
func (r *ClearChunks) formatFileSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
+71
View File
@@ -0,0 +1,71 @@
package commands
import (
"fmt"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/models"
)
type ClearLogs struct {
}
// Signature The name and signature of the console command.
func (r *ClearLogs) Signature() string {
return "app:clear-logs"
}
// Description The console command description.
func (r *ClearLogs) Description() string {
return "清理6个月前的日志记录(操作日志、登录日志、系统日志)"
}
// Extend The console command extend.
func (r *ClearLogs) Extend() command.Extend {
return command.Extend{Category: "app"}
}
// Handle Execute the console command.
func (r *ClearLogs) Handle(ctx console.Context) error {
// 计算6个月前的日期
monthsAgo := time.Now().AddDate(0, -6, 0)
ctx.Info("开始清理6个月前的日志...")
// 清理操作日志
operationLogResult, err := facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at < ?", monthsAgo).
Delete(&models.OperationLog{})
if err != nil {
ctx.Error("清理操作日志失败: " + err.Error())
return err
}
ctx.Info(fmt.Sprintf("已清理操作日志: %d 条", operationLogResult.RowsAffected))
// 清理登录日志
loginLogResult, err := facades.Orm().Query().Model(&models.LoginLog{}).
Where("created_at < ?", monthsAgo).
Delete(&models.LoginLog{})
if err != nil {
ctx.Error("清理登录日志失败: " + err.Error())
return err
}
ctx.Info(fmt.Sprintf("已清理登录日志: %d 条", loginLogResult.RowsAffected))
// 清理系统日志
systemLogResult, err := facades.Orm().Query().Model(&models.SystemLog{}).
Where("created_at < ?", monthsAgo).
Delete(&models.SystemLog{})
if err != nil {
ctx.Error("清理系统日志失败: " + err.Error())
return err
}
ctx.Info(fmt.Sprintf("已清理系统日志: %d 条", systemLogResult.RowsAffected))
ctx.Info("日志清理完成!")
return nil
}
@@ -0,0 +1,141 @@
package commands
import (
"context"
"fmt"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/services"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type CreateOrderShardingTables struct {
shardingService services.ShardingService
}
func NewCreateOrderShardingTables() *CreateOrderShardingTables {
return &CreateOrderShardingTables{
shardingService: services.NewShardingService(),
}
}
// Signature The name and signature of the console command.
func (r *CreateOrderShardingTables) Signature() string {
return "order:create-sharding-tables"
}
// Description The console command description.
func (r *CreateOrderShardingTables) Description() string {
// # 创建分表(默认创建上个月、当前月份及未来2个月,共4个月)
// go run . artisan order:create-sharding-tables
// # 创建指定月份的分表
// go run . artisan order:create-sharding-tables --month=202512
// # 创建上个月、当前月份及未来N个月的分表(--months 参数包括上个月和当前月)
// # 例如:--months=6 会创建上个月、当前月及未来4个月,共6个月
// go run . artisan order:create-sharding-tables --months=6
// # 帮助
// go run . artisan order:create-sharding-tables --help
return "创建订单分表(按月分表,可指定月份或创建上个月、当前月份及未来几个月)"
}
// Extend The console command extend.
func (r *CreateOrderShardingTables) Extend() command.Extend {
return command.Extend{
Category: "order",
Flags: []command.Flag{
&command.StringFlag{
Name: "month",
Aliases: []string{"m"},
Usage: "指定月份(格式: YYYYMM,如:202512),不指定则创建当前月份",
},
&command.IntFlag{
Name: "months",
Aliases: []string{"n"},
Value: 4,
Usage: "创建几个月(默认4个月,包括上个月、当前月份及未来2个月)",
},
},
}
}
// Handle Execute the console command.
func (r *CreateOrderShardingTables) Handle(ctx console.Context) error {
var months []time.Time
monthFlag := ctx.Option("month")
monthsFlag := ctx.OptionInt("months")
if monthFlag != "" {
// 指定月份(解析为 UTC 时区,与分表逻辑保持一致)
parsedTime, err := time.ParseInLocation("200601", monthFlag, time.UTC)
if err != nil {
return fmt.Errorf("月份格式错误,应为 YYYYMM 格式(如:202512): %v", err)
}
months = []time.Time{parsedTime}
} else {
// 创建上个月、当前月份及未来几个月(使用 UTC 时区,与分表逻辑保持一致)
// 默认:上个月(-1)、当前月(0)、未来2个月(1,2),共4个月
now := time.Now().UTC()
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
// 从 -1 开始(上个月),到 monthsFlag-2(未来几个月)
// 例如:monthsFlag=4 时,创建:上个月(-1)、当前月(0)、未来1个月(1)、未来2个月(2)
startOffset := -1 // 从上个月开始
for i := startOffset; i < monthsFlag-1; i++ {
month := currentMonth.AddDate(0, i, 0)
months = append(months, month)
}
}
createdCount := 0
skippedCount := 0
for _, month := range months {
tableName := utils.GetShardingTableName("orders", month)
detailTableName := utils.GetShardingTableName("order_details", month)
// 创建订单主表
if facades.Schema().HasTable(tableName) {
ctx.Info(fmt.Sprintf("分表 %s 已存在,跳过", tableName))
skippedCount++
} else {
if err := r.shardingService.CreateShardingTable(tableName, "orders"); err != nil {
errorlog.Record(context.Background(), "sharding", "创建分表失败", map[string]any{
"table_name": tableName,
"base_table_name": "orders",
"error": err.Error(),
}, "创建分表 %s 失败: %v", tableName, err)
return fmt.Errorf("创建分表 %s 失败: %v", tableName, err)
}
ctx.Info(fmt.Sprintf("✓ 创建分表: %s", tableName))
createdCount++
}
// 创建订单详情表
if facades.Schema().HasTable(detailTableName) {
ctx.Info(fmt.Sprintf("分表 %s 已存在,跳过", detailTableName))
} else {
if err := r.shardingService.CreateShardingTable(detailTableName, "order_details"); err != nil {
errorlog.Record(context.Background(), "sharding", "创建分表失败", map[string]any{
"table_name": detailTableName,
"base_table_name": "order_details",
"error": err.Error(),
}, "创建分表 %s 失败: %v", detailTableName, err)
return fmt.Errorf("创建分表 %s 失败: %v", detailTableName, err)
}
ctx.Info(fmt.Sprintf("✓ 创建分表: %s", detailTableName))
}
}
ctx.Info(fmt.Sprintf("\n完成!创建了 %d 个分表,跳过了 %d 个已存在的分表", createdCount, skippedCount))
return nil
}
@@ -0,0 +1,121 @@
package commands
import (
"context"
"fmt"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/utils"
"goravel/app/utils/errorlog"
"goravel/database/migrations"
)
type CreatePaymentShardingTables struct {
}
func NewCreatePaymentShardingTables() *CreatePaymentShardingTables {
return &CreatePaymentShardingTables{}
}
// Signature The name and signature of the console command.
func (r *CreatePaymentShardingTables) Signature() string {
return "payment:create-sharding-tables"
}
// Description The console command description.
func (r *CreatePaymentShardingTables) Description() string {
// # 创建分表(默认创建上个月、当前月份及未来2个月,共4个月)
// go run . artisan payment:create-sharding-tables
// # 创建指定月份的分表
// go run . artisan payment:create-sharding-tables --month=202512
// # 创建上个月、当前月份及未来N个月的分表(--months 参数包括上个月和当前月)
// # 例如:--months=6 会创建上个月、当前月及未来4个月,共6个月
// go run . artisan payment:create-sharding-tables --months=6
// # 帮助
// go run . artisan payment:create-sharding-tables --help
return "创建支付记录分表(按月分表,可指定月份或创建上个月、当前月份及未来几个月)"
}
// Extend The console command extend.
func (r *CreatePaymentShardingTables) Extend() command.Extend {
return command.Extend{
Category: "payment",
Flags: []command.Flag{
&command.StringFlag{
Name: "month",
Aliases: []string{"m"},
Usage: "指定月份(格式: YYYYMM,如:202512),不指定则创建当前月份",
},
&command.IntFlag{
Name: "months",
Aliases: []string{"n"},
Value: 4,
Usage: "创建几个月(默认4个月,包括上个月、当前月份及未来2个月)",
},
},
}
}
// Handle Execute the console command.
func (r *CreatePaymentShardingTables) Handle(ctx console.Context) error {
var months []time.Time
monthFlag := ctx.Option("month")
monthsFlag := ctx.OptionInt("months")
if monthFlag != "" {
// 指定月份(解析为 UTC 时区,与分表逻辑保持一致)
parsedTime, err := time.ParseInLocation("200601", monthFlag, time.UTC)
if err != nil {
return fmt.Errorf("月份格式错误,应为 YYYYMM 格式(如:202512): %v", err)
}
months = []time.Time{parsedTime}
} else {
// 创建上个月、当前月份及未来几个月(使用 UTC 时区,与分表逻辑保持一致)
// 默认:上个月(-1)、当前月(0)、未来2个月(1,2),共4个月
now := time.Now().UTC()
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
// 从 -1 开始(上个月),到 monthsFlag-2(未来几个月)
// 例如:monthsFlag=4 时,创建:上个月(-1)、当前月(0)、未来1个月(1)、未来2个月(2)
startOffset := -1 // 从上个月开始
for i := startOffset; i < monthsFlag-1; i++ {
month := currentMonth.AddDate(0, i, 0)
months = append(months, month)
}
}
createdCount := 0
skippedCount := 0
for _, month := range months {
tableName := utils.GetShardingTableName("payments", month)
// 创建支付记录分表
if facades.Schema().HasTable(tableName) {
ctx.Info(fmt.Sprintf("分表 %s 已存在,跳过", tableName))
skippedCount++
} else {
if err := migrations.CreatePaymentsShardingTable(tableName); err != nil {
errorlog.Record(context.Background(), "sharding", "创建支付记录分表失败", map[string]any{
"table_name": tableName,
"error": err.Error(),
}, "创建支付记录分表 %s 失败: %v", tableName, err)
return fmt.Errorf("创建支付记录分表 %s 失败: %v", tableName, err)
}
ctx.Info(fmt.Sprintf("✓ 创建分表: %s", tableName))
createdCount++
}
}
ctx.Info(fmt.Sprintf("\n完成!创建了 %d 个分表,跳过了 %d 个已存在的分表", createdCount, skippedCount))
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package commands
import (
"errors"
"strconv"
"strings"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
// # Admin用户 - 1小时后过期
// go run . artisan token:create 1 --expires=1h
// # 7天后过期
// go run . artisan token:create 1 --expires=7d
// # 使用简写
// go run . artisan token:create 1 -e=1h
// # 指定token名称
// go run . artisan token:create 1 --expires=7d --name=api-token
// # 创建永久token(不设置expires
// go run . artisan token:create 1
type CreateToken struct {
}
// Signature The name and signature of the console command.
func (receiver *CreateToken) Signature() string {
return "token:create"
}
// Description The console command description.
func (receiver *CreateToken) Description() string {
return "为指定管理员创建token(永久或指定过期时间)"
}
// Extend The console command extend.
func (receiver *CreateToken) Extend() command.Extend {
return command.Extend{
Category: "token",
Flags: []command.Flag{
&command.StringFlag{
Name: "expires",
Aliases: []string{"e"},
Usage: "过期时间,格式:1h(1小时)、24h(24小时)、7d(7天)等,不设置则创建永久token",
},
&command.StringFlag{
Name: "name",
Aliases: []string{"n"},
Value: "console-token",
Usage: "token名称",
},
},
}
}
// Handle Execute the console command.
func (receiver *CreateToken) Handle(ctx console.Context) error {
// 获取用户标识(ID或用户名)
userIdentifier := ctx.Argument(0)
if userIdentifier == "" {
return errors.New("请提供用户ID或用户名")
}
// 用户类型固定为 admin
userType := "admin"
// 获取过期时间选项
expiresStr := ctx.Option("expires")
var expiresAt *time.Time
if expiresStr != "" {
// 解析过期时间
duration, err := parseDuration(expiresStr)
if err != nil {
return errors.New("过期时间格式错误,请使用:1h(1小时)、24h(24小时)、7d(7天)等格式")
}
exp := time.Now().Add(duration)
expiresAt = &exp
}
// 如果 expiresStr 为空,expiresAt 为 nil,表示永久token
// 获取token名称
tokenName := ctx.Option("name")
if tokenName == "" {
tokenName = "console-token"
}
// 查询管理员
var admin models.Admin
var err error
// 尝试作为ID解析
if id, parseErr := strconv.ParseUint(userIdentifier, 10, 32); parseErr == nil {
// 作为ID查询
err = facades.Orm().Query().Where("id", uint(id)).First(&admin)
} else {
// 作为用户名查询
err = facades.Orm().Query().Where("username", userIdentifier).First(&admin)
}
if err != nil {
return errors.New("管理员不存在")
}
userID := admin.ID
username := admin.Username
// 创建token
tokenService := services.NewTokenServiceImpl()
// 命令行创建token时,浏览器、IP、操作系统信息为空
plainToken, accessToken, err := tokenService.CreateToken(userType, userID, tokenName, expiresAt, "", "", "", "")
if err != nil {
return errors.New("创建token失败: " + err.Error())
}
// 输出结果
ctx.Info("Token创建成功!")
ctx.Line("")
ctx.Info("用户信息:")
ctx.Line(" 类型: " + userType)
ctx.Line(" ID: " + strconv.FormatUint(uint64(userID), 10))
ctx.Line(" 用户名: " + username)
ctx.Line("")
ctx.Info("Token信息:")
ctx.Line(" 名称: " + accessToken.Name)
if accessToken.ExpiresAt != nil {
ctx.Line(" 过期时间: " + accessToken.ExpiresAt.Format(utils.DateTimeFormat))
} else {
ctx.Line(" 过期时间: 永久有效")
}
ctx.Line(" 创建时间: " + accessToken.CreatedAt.Format(utils.DateTimeFormat))
ctx.Line("")
ctx.Warning("请妥善保管以下token,它只会显示一次:")
ctx.Line("")
ctx.Line(plainToken)
ctx.Line("")
return nil
}
// parseDuration 解析时间字符串,支持 h(小时)、d(天)、m(分钟)等格式
func parseDuration(s string) (time.Duration, error) {
// 移除空格
s = strings.TrimSpace(s)
if len(s) == 0 {
return 0, errors.New("时间字符串为空")
}
// 获取最后一个字符作为单位
unit := s[len(s)-1:]
valueStr := s[:len(s)-1]
// 解析数值
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
return 0, err
}
// 根据单位转换为duration
switch unit {
case "m", "M":
// 分钟
return time.Duration(value) * time.Minute, nil
case "h", "H":
// 小时
return time.Duration(value) * time.Hour, nil
case "d", "D":
// 天
return time.Duration(value) * 24 * time.Hour, nil
case "w", "W":
// 周
return time.Duration(value) * 7 * 24 * time.Hour, nil
default:
// 尝试直接解析为duration(如 "1h30m"
return time.ParseDuration(s)
}
}
@@ -0,0 +1,510 @@
package commands
import (
"fmt"
"math/rand"
"strconv"
"sync"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/oklog/ulid/v2"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
// GenerateTestOrders 生成测试订单数据
type GenerateTestOrders struct {
shardingService services.ShardingService
}
// Signature The name and signature of the console command.
func (receiver *GenerateTestOrders) Signature() string {
return "order:generate-test-data"
}
// # 使用默认10个并发协程
// go run . artisan order:generate-test-data --count=1000000
// # 使用20个并发协程(更快)
// go run . artisan order:generate-test-data --count=1000000 --workers=20
// # 根据服务器性能调整并发数
// go run . artisan order:generate-test-data --count=1000000 --workers=50 --batch-size=2000
// Description The console command description.
func (receiver *GenerateTestOrders) Description() string {
return "生成订单测试数据(用于测试订单导出等功能)"
}
// Extend The console command extend.
func (receiver *GenerateTestOrders) Extend() command.Extend {
return command.Extend{
Category: "order",
Flags: []command.Flag{
&command.IntFlag{
Name: "count",
Aliases: []string{"c"},
Value: 1000000,
Usage: "要生成的订单数量(默认:1000000)",
},
&command.IntFlag{
Name: "batch-size",
Aliases: []string{"b"},
Value: 1000,
Usage: "批量插入的大小(默认:1000",
},
&command.StringFlag{
Name: "start-date",
Aliases: []string{"s"},
Usage: "开始日期(格式:YYYY-MM-DD,默认:当前月份的第一天)",
},
&command.StringFlag{
Name: "end-date",
Aliases: []string{"e"},
Usage: "结束日期(格式:YYYY-MM-DD,默认:当前日期)",
},
&command.IntFlag{
Name: "min-user-id",
Value: 1,
Usage: "最小用户ID(默认:1",
},
&command.IntFlag{
Name: "max-user-id",
Value: 1000,
Usage: "最大用户ID(默认:1000",
},
&command.IntFlag{
Name: "workers",
Aliases: []string{"w"},
Value: 10,
Usage: "并发工作协程数量(默认:10)",
},
},
}
}
// Handle Execute the console command.
func (receiver *GenerateTestOrders) Handle(ctx console.Context) error {
receiver.shardingService = services.NewShardingService()
// 获取参数
count := ctx.OptionInt("count")
batchSize := ctx.OptionInt("batch-size")
startDateStr := ctx.Option("start-date")
endDateStr := ctx.Option("end-date")
minUserID := ctx.OptionInt("min-user-id")
maxUserID := ctx.OptionInt("max-user-id")
workers := ctx.OptionInt("workers")
// 解析日期
var startDate, endDate time.Time
var err error
if startDateStr != "" {
startDate, err = utils.ParseDate(startDateStr)
if err != nil {
return fmt.Errorf("开始日期格式错误,请使用 YYYY-MM-DD 格式: %v", err)
}
} else {
// 默认:当前月份的第一天
now := time.Now().UTC()
startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
}
if endDateStr != "" {
endDate, err = utils.ParseDate(endDateStr)
if err != nil {
return fmt.Errorf("结束日期格式错误,请使用 YYYY-MM-DD 格式: %v", err)
}
// 设置为当天的最后一秒
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, time.UTC)
} else {
// 默认:当前时间
endDate = time.Now().UTC()
}
if startDate.After(endDate) {
return fmt.Errorf("开始日期不能晚于结束日期")
}
// 验证用户ID范围
if minUserID <= 0 || maxUserID <= 0 || minUserID > maxUserID {
return fmt.Errorf("用户ID范围无效")
}
ctx.Info("开始生成测试订单数据...")
ctx.Line("")
ctx.Info(fmt.Sprintf("订单数量: %s", formatNumber(count)))
ctx.Info(fmt.Sprintf("批量大小: %s", formatNumber(batchSize)))
ctx.Info(fmt.Sprintf("并发协程: %d", workers))
ctx.Info(fmt.Sprintf("开始日期: %s", utils.FormatDate(startDate)))
ctx.Info(fmt.Sprintf("结束日期: %s", utils.FormatDate(endDate)))
ctx.Info(fmt.Sprintf("用户ID范围: %d - %d", minUserID, maxUserID))
ctx.Line("")
// 计算时间范围(秒)
timeRange := endDate.Sub(startDate).Seconds()
if timeRange <= 0 {
return fmt.Errorf("时间范围无效")
}
// 生成订单状态列表
statuses := []string{"pending", "paid", "cancelled"}
// 生成商品列表(用于订单详情)
products := []struct {
ID uint
Name string
Price float64
}{
{1, "商品A", 99.99},
{2, "商品B", 199.99},
{3, "商品C", 299.99},
{4, "商品D", 49.99},
{5, "商品E", 149.99},
{6, "商品F", 79.99},
{7, "商品G", 249.99},
{8, "商品H", 89.99},
}
// 开始生成
startTime := time.Now()
totalInserted := 0
batches := (count + batchSize - 1) / batchSize // 向上取整
// 使用随机种子
rand.Seed(time.Now().UnixNano())
for range batches {
// 计算本批次要插入的数量
remaining := count - totalInserted
currentBatchSize := min(remaining, batchSize)
// 准备批量数据
orders := make([]models.Order, 0, currentBatchSize)
for i := range currentBatchSize {
// 随机生成订单时间(在指定时间范围内)
randomSeconds := rand.Float64() * timeRange
orderTime := startDate.Add(time.Duration(randomSeconds) * time.Second)
// 生成订单号
yearMonth := orderTime.Format("200601")
ulidStr := ulid.MustNew(ulid.Timestamp(orderTime), ulid.DefaultEntropy()).String()
orderNo := fmt.Sprintf("ORD%s%s", yearMonth, ulidStr)
// 随机生成用户ID
userID := uint(minUserID + rand.Intn(maxUserID-minUserID+1))
// 随机生成订单金额(10.00 - 9999.99
amount := 10.0 + rand.Float64()*(9999.99-10.0)
// 随机选择订单状态
status := statuses[rand.Intn(len(statuses))]
// 将time.Time转换为时间字符串
timeStr := utils.FormatDateTime(orderTime)
// 创建订单对象用于后续处理
order := models.Order{
OrderNo: orderNo,
UserID: userID,
Amount: amount,
Status: status,
Remark: fmt.Sprintf("测试订单-%d", totalInserted+i+1),
}
// 设置CreatedAt用于分表计算(使用carbon.NewDateTime
order.CreatedAt = carbon.NewDateTime(carbon.Parse(timeStr))
order.UpdatedAt = carbon.NewDateTime(carbon.Parse(timeStr))
// 存储订单对象
orders = append(orders, order)
}
// 按分表分组插入订单,同时准备订单数据map
ordersByTable := make(map[string][]models.Order)
orderDataByTable := make(map[string][]map[string]any)
for _, order := range orders {
// 从carbon.DateTime获取time.Time用于分表
timeStr := order.CreatedAt.ToDateTimeString()
orderTime, _ := utils.ParseDateTimeUTC(timeStr)
tableName := utils.GetShardingTableName("orders", orderTime)
ordersByTable[tableName] = append(ordersByTable[tableName], order)
// 准备订单数据map
orderData := map[string]any{
"order_no": order.OrderNo,
"user_id": order.UserID,
"amount": order.Amount,
"status": order.Status,
"remark": order.Remark,
"created_at": timeStr,
"updated_at": timeStr,
}
orderDataByTable[tableName] = append(orderDataByTable[tableName], orderData)
}
// 并发插入订单
var wg sync.WaitGroup
var mu sync.Mutex
workerChan := make(chan struct{}, workers) // 控制并发数量
errChan := make(chan error, len(ordersByTable))
orderIDMap := make(map[string]uint) // 订单号到ID的映射(线程安全)
for tableName, tableOrders := range ordersByTable {
wg.Add(1)
workerChan <- struct{}{} // 获取工作协程
go func(tn string, tos []models.Order, odl []map[string]any) {
defer wg.Done()
defer func() { <-workerChan }() // 释放工作协程
// 确保分表存在
if err := receiver.shardingService.EnsureShardingTable(tn, "orders"); err != nil {
errChan <- fmt.Errorf("确保分表 %s 存在失败: %v", tn, err)
return
}
// 分批插入订单
for i := 0; i < len(odl); i += 100 {
end := min(i+100, len(odl))
batchData := odl[i:end]
// 批量插入
for j := range batchData {
if err := facades.Orm().Query().Table(tn).Create(batchData[j]); err != nil {
errChan <- fmt.Errorf("插入订单失败: %v", err)
return
}
}
// 批量查询ID(优化:减少查询次数)
orderNos := make([]string, 0, len(batchData))
for j := range batchData {
if orderNo, ok := batchData[j]["order_no"].(string); ok {
orderNos = append(orderNos, orderNo)
}
}
// 批量查询订单ID
if len(orderNos) > 0 {
var insertedOrders []models.Order
if err := facades.Orm().Query().Table(tn).Where("order_no IN ?", orderNos).Find(&insertedOrders); err == nil {
// 更新全局订单ID映射
mu.Lock()
for k := range insertedOrders {
orderIDMap[insertedOrders[k].OrderNo] = insertedOrders[k].ID
}
mu.Unlock()
}
}
}
}(tableName, tableOrders, orderDataByTable[tableName])
}
// 等待所有goroutine完成
go func() {
wg.Wait()
close(errChan)
}()
// 检查错误
for err := range errChan {
if err != nil {
return err
}
}
// 更新所有订单的ID
mu.Lock()
for tableName := range ordersByTable {
for i := range ordersByTable[tableName] {
if id, exists := orderIDMap[ordersByTable[tableName][i].OrderNo]; exists {
ordersByTable[tableName][i].ID = id
}
}
}
mu.Unlock()
// 为每个订单生成订单详情
orderDetailsMap := make(map[string][]models.OrderDetail) // key: tableName, value: details
for _, tableOrders := range ordersByTable {
for i := range tableOrders {
order := &tableOrders[i]
// 从carbon.DateTime获取time.Time用于分表
timeStr := order.CreatedAt.ToDateTimeString()
orderTime, _ := utils.ParseDateTimeUTC(timeStr)
detailTableName := utils.GetShardingTableName("order_details", orderTime)
// 确保订单详情分表存在
if err := receiver.shardingService.EnsureShardingTable(detailTableName, "order_details"); err != nil {
return fmt.Errorf("确保分表 %s 存在失败: %v", detailTableName, err)
}
// 随机生成1-3个商品
productCount := 1 + rand.Intn(3)
details := make([]models.OrderDetail, 0, productCount)
for j := 0; j < productCount; j++ {
product := products[rand.Intn(len(products))]
quantity := 1 + rand.Intn(5) // 1-5个
subtotal := product.Price * float64(quantity)
detail := models.OrderDetail{
OrderID: order.ID,
ProductID: product.ID,
ProductName: product.Name,
Price: product.Price,
Quantity: quantity,
Subtotal: subtotal,
}
// 设置CreatedAt和UpdatedAt(使用订单的创建时间)
detail.CreatedAt = order.CreatedAt
detail.UpdatedAt = order.CreatedAt
details = append(details, detail)
}
// 将详情添加到对应分表的列表中
if _, exists := orderDetailsMap[detailTableName]; !exists {
orderDetailsMap[detailTableName] = make([]models.OrderDetail, 0)
}
orderDetailsMap[detailTableName] = append(orderDetailsMap[detailTableName], details...)
}
}
// 并发插入订单详情
detailWg := sync.WaitGroup{}
detailErrChan := make(chan error, len(orderDetailsMap))
for detailTableName, details := range orderDetailsMap {
if len(details) == 0 {
continue
}
detailWg.Add(1)
workerChan <- struct{}{} // 获取工作协程
go func(dtn string, dets []models.OrderDetail) {
defer detailWg.Done()
defer func() { <-workerChan }() // 释放工作协程
// 确保订单详情分表存在
if err := receiver.shardingService.EnsureShardingTable(dtn, "order_details"); err != nil {
detailErrChan <- fmt.Errorf("确保分表 %s 存在失败: %v", dtn, err)
return
}
// 分批插入订单详情
for i := 0; i < len(dets); i += 100 {
end := min(i+100, len(dets))
batchDetails := dets[i:end]
// 批量插入
for j := range batchDetails {
// 准备详情数据map
// 安全处理CreatedAt,如果为nil则使用当前时间
var createdAtStr string
if batchDetails[j].CreatedAt != nil && !batchDetails[j].CreatedAt.IsZero() {
createdAtStr = batchDetails[j].CreatedAt.ToDateTimeString()
} else {
// 如果CreatedAt为nil,使用当前时间
createdAtStr = utils.FormatDateTime(time.Now().UTC())
}
detailData := map[string]any{
"order_id": batchDetails[j].OrderID,
"product_id": batchDetails[j].ProductID,
"product_name": batchDetails[j].ProductName,
"price": batchDetails[j].Price,
"quantity": batchDetails[j].Quantity,
"subtotal": batchDetails[j].Subtotal,
"created_at": createdAtStr,
"updated_at": createdAtStr,
}
if err := facades.Orm().Query().Table(dtn).Create(detailData); err != nil {
detailErrChan <- fmt.Errorf("插入订单详情失败: %v", err)
return
}
}
}
}(detailTableName, details)
}
// 等待所有详情插入完成
go func() {
detailWg.Wait()
close(detailErrChan)
}()
// 检查错误
for err := range detailErrChan {
if err != nil {
return err
}
}
totalInserted += currentBatchSize
// 显示进度
progress := float64(totalInserted) / float64(count) * 100
elapsed := time.Since(startTime)
rate := float64(totalInserted) / elapsed.Seconds()
remainingCount := count - totalInserted
eta := time.Duration(float64(remainingCount)/rate) * time.Second
ctx.Info(fmt.Sprintf("进度: %s/%s (%.2f%%) | 已用时间: %s | 速度: %.0f 条/秒 | 预计剩余: %s",
formatNumber(totalInserted),
formatNumber(count),
progress,
formatDuration(elapsed),
rate,
formatDuration(eta),
))
}
// 完成
elapsed := time.Since(startTime)
ctx.Line("")
ctx.Info(fmt.Sprintf("✅ 成功生成 %s 条订单数据!", formatNumber(totalInserted)))
ctx.Info(fmt.Sprintf("总耗时: %s", formatDuration(elapsed)))
ctx.Info(fmt.Sprintf("平均速度: %.0f 条/秒", float64(totalInserted)/elapsed.Seconds()))
return nil
}
// formatNumber 格式化数字(添加千位分隔符)
func formatNumber(n int) string {
str := strconv.Itoa(n)
if len(str) <= 3 {
return str
}
result := ""
for i, c := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result += ","
}
result += string(c)
}
return result
}
// formatDuration 格式化时间间隔
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%.0f秒", d.Seconds())
} else if d < time.Hour {
return fmt.Sprintf("%.0f分钟", d.Minutes())
} else {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
return fmt.Sprintf("%d小时%d分钟", hours, minutes)
}
}
@@ -0,0 +1,366 @@
package commands
import (
"context"
"fmt"
"math/rand"
"strconv"
"sync"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"github.com/oklog/ulid/v2"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
"goravel/app/utils/errorlog"
"goravel/database/migrations"
)
// GenerateTestPayments 生成测试支付记录数据
type GenerateTestPayments struct {
paymentService services.PaymentService
}
// Signature The name and signature of the console command.
func (receiver *GenerateTestPayments) Signature() string {
return "payment:generate-test-data"
}
// # 使用默认10个并发协程
// go run . artisan payment:generate-test-data --count=1000000
// # 使用20个并发协程(更快)
// go run . artisan payment:generate-test-data --count=1000000 --workers=20
// # 根据服务器性能调整并发数
// go run . artisan payment:generate-test-data --count=1000000 --workers=50 --batch-size=2000
// Description The console command description.
func (receiver *GenerateTestPayments) Description() string {
return "生成支付记录测试数据(用于测试支付记录导出等功能)"
}
// Extend The console command extend.
func (receiver *GenerateTestPayments) Extend() command.Extend {
return command.Extend{
Category: "payment",
Flags: []command.Flag{
&command.IntFlag{
Name: "count",
Aliases: []string{"c"},
Value: 10000,
Usage: "生成的记录数量",
},
&command.IntFlag{
Name: "workers",
Aliases: []string{"w"},
Value: 10,
Usage: "并发协程数",
},
&command.IntFlag{
Name: "batch-size",
Aliases: []string{"b"},
Value: 1000,
Usage: "每个批次插入的记录数",
},
&command.StringFlag{
Name: "start-date",
Aliases: []string{"s"},
Value: "",
Usage: "开始日期(格式:2006-01-02),默认为3个月前",
},
&command.StringFlag{
Name: "end-date",
Aliases: []string{"e"},
Value: "",
Usage: "结束日期(格式:2006-01-02),默认为当前时间",
},
},
}
}
// Handle Execute the console command.
func (receiver *GenerateTestPayments) Handle(ctx console.Context) error {
count := ctx.OptionInt("count")
workers := ctx.OptionInt("workers")
batchSize := ctx.OptionInt("batch-size")
startDateStr := ctx.Option("start-date")
endDateStr := ctx.Option("end-date")
// 参数验证
if count <= 0 {
return fmt.Errorf("记录数量必须大于0")
}
if workers <= 0 {
return fmt.Errorf("并发协程数必须大于0")
}
if batchSize <= 0 {
return fmt.Errorf("批次大小必须大于0")
}
// 解析日期范围
var startDate, endDate time.Time
var err error
if startDateStr != "" {
startDate, err = time.ParseInLocation("2006-01-02", startDateStr, time.UTC)
if err != nil {
return fmt.Errorf("开始日期格式错误,应为 YYYY-MM-DD 格式: %v", err)
}
} else {
startDate = time.Now().UTC().AddDate(0, -3, 0) // 默认3个月前
}
if endDateStr != "" {
endDate, err = time.ParseInLocation("2006-01-02", endDateStr, time.UTC)
if err != nil {
return fmt.Errorf("结束日期格式错误,应为 YYYY-MM-DD 格式: %v", err)
}
} else {
endDate = time.Now().UTC() // 默认当前时间
}
if startDate.After(endDate) {
return fmt.Errorf("开始日期不能晚于结束日期")
}
ctx.Info(fmt.Sprintf("开始生成 %d 条支付记录测试数据", count))
ctx.Info(fmt.Sprintf("时间范围: %s 至 %s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")))
ctx.Info(fmt.Sprintf("并发协程数: %d, 批次大小: %d", workers, batchSize))
// 获取支付方式列表
paymentMethods, err := receiver.getPaymentMethods()
if err != nil {
return fmt.Errorf("获取支付方式失败: %v", err)
}
if len(paymentMethods) == 0 {
return fmt.Errorf("没有找到支付方式,请先创建支付方式")
}
// 计算每个协程的工作量
totalBatches := (count + batchSize - 1) / batchSize
batchesPerWorker := totalBatches / workers
remainder := totalBatches % workers
startTime := time.Now()
var wg sync.WaitGroup
var mu sync.Mutex
var successCount int64
var errorCount int64
// 启动工作协程
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
// 计算当前协程的批次范围
batchStart := i * batchesPerWorker
batchEnd := batchStart + batchesPerWorker
if i < remainder {
batchEnd++
}
for batchID := batchStart; batchID < batchEnd; batchID++ {
// 计算当前批次的记录数
remainingCount := count - batchID*batchSize
currentBatchSize := batchSize
if remainingCount < batchSize {
currentBatchSize = remainingCount
}
if currentBatchSize <= 0 {
break
}
// 生成批次数据
err := receiver.generateBatch(currentBatchSize, startDate, endDate, paymentMethods)
mu.Lock()
if err != nil {
errorCount++
ctx.Error(fmt.Sprintf("Worker %d 批次 %d 失败: %v", workerID, batchID, err))
} else {
successCount += int64(currentBatchSize)
ctx.Info(fmt.Sprintf("Worker %d 批次 %d 完成,生成 %d 条记录", workerID, batchID, currentBatchSize))
}
mu.Unlock()
}
}(i)
}
wg.Wait()
// 输出结果
duration := time.Since(startTime)
ctx.Info(fmt.Sprintf("\n✅ 完成!"))
ctx.Info(fmt.Sprintf("总耗时: %v", duration))
ctx.Info(fmt.Sprintf("成功生成: %d 条记录", successCount))
ctx.Info(fmt.Sprintf("失败批次: %d", errorCount))
if successCount > 0 {
ctx.Info(fmt.Sprintf("平均速度: %.0f 条/秒", float64(successCount)/duration.Seconds()))
}
return nil
}
// getPaymentMethods 获取支付方式列表
func (receiver *GenerateTestPayments) getPaymentMethods() ([]models.PaymentMethod, error) {
var paymentMethods []models.PaymentMethod
err := facades.Orm().Query().Model(&models.PaymentMethod{}).Where("is_active", true).Find(&paymentMethods)
return paymentMethods, err
}
// generateBatch 生成一批支付记录
func (receiver *GenerateTestPayments) generateBatch(count int, startDate, endDate time.Time, paymentMethods []models.PaymentMethod) error {
payments := make([]models.Payment, 0, count)
for i := 0; i < count; i++ {
// 随机生成时间
randomTime := randomTimeInRange(startDate, endDate)
// 随机选择支付方式
paymentMethod := paymentMethods[rand.Intn(len(paymentMethods))]
// 随机生成订单号(模拟已存在的订单)
orderNo := fmt.Sprintf("ORD%s%s", randomTime.Format("20060102"), strconv.Itoa(rand.Intn(999999)))
// 随机生成用户ID
userID := uint(rand.Intn(10000) + 1)
// 随机生成金额(0.01-10000.00
amount := float64(rand.Intn(1000000)+1) / 100
// 随机生成状态
statuses := []string{"pending", "paid", "failed", "cancelled"}
status := statuses[rand.Intn(len(statuses))]
// 生成支付单号
paymentNo := fmt.Sprintf("PAY%s%s", randomTime.Format("20060102"), ulid.Make().String())
payment := models.Payment{
PaymentNo: paymentNo,
OrderNo: orderNo,
PaymentMethodID: paymentMethod.ID,
UserID: userID,
Amount: amount,
Status: status,
}
// 根据状态设置其他字段
switch status {
case "paid":
payment.PayTime = &randomTime
payment.ThirdPartyNo = fmt.Sprintf("TXN%s%d", randomTime.Format("20060102150405"), rand.Intn(999999))
case "failed":
payment.FailReason = "支付超时"
}
payments = append(payments, payment)
}
// 确保分表存在(使用最早的时间)
tableName := utils.GetShardingTableName("payments", startDate)
if !facades.Schema().HasTable(tableName) {
if err := receiver.ensurePaymentShardingTableExists(startDate); err != nil {
return fmt.Errorf("创建分表失败: %v", err)
}
}
// 批量插入(按分表分组)
tableGroups := make(map[string][]models.Payment)
for _, payment := range payments {
// 从支付单号提取日期
if len(payment.PaymentNo) >= 11 {
dateStr := payment.PaymentNo[3:11] // 提取日期部分 YYYYMMDD
if parsedTime, err := time.Parse("20060102", dateStr); err == nil {
tableName := utils.GetShardingTableName("payments", parsedTime)
tableGroups[tableName] = append(tableGroups[tableName], payment)
}
}
}
// 分表插入
for tableName, tablePayments := range tableGroups {
// 确保分表存在
if !facades.Schema().HasTable(tableName) {
// 从表名解析月份
if len(tableName) > 8 {
monthStr := tableName[len(tableName)-6:] // 提取 YYYYMM
if parsedTime, err := time.Parse("200601", monthStr); err == nil {
if err := receiver.ensurePaymentShardingTableExists(parsedTime); err != nil {
return fmt.Errorf("创建分表 %s 失败: %v", tableName, err)
}
}
}
}
// 转换为 map 以便批量插入
paymentMaps := make([]map[string]any, len(tablePayments))
for i, payment := range tablePayments {
// 从支付单号解析创建时间
var createdAt time.Time
if len(payment.PaymentNo) >= 11 {
dateStr := payment.PaymentNo[3:11] // 提取日期部分 YYYYMMDD
if parsedTime, err := time.Parse("20060102", dateStr); err == nil {
// 添加随机时分秒
createdAt = parsedTime.Add(time.Duration(rand.Intn(86400)) * time.Second)
}
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
paymentMaps[i] = map[string]any{
"payment_no": payment.PaymentNo,
"order_no": payment.OrderNo,
"payment_method_id": payment.PaymentMethodID,
"user_id": payment.UserID,
"amount": payment.Amount,
"status": payment.Status,
"third_party_no": payment.ThirdPartyNo,
"pay_time": payment.PayTime,
"fail_reason": payment.FailReason,
"remark": payment.Remark,
"created_at": createdAt,
"updated_at": createdAt,
}
}
if err := facades.Orm().Query().Table(tableName).Create(paymentMaps); err != nil {
errorlog.Record(context.Background(), "payment", "批量插入支付记录失败", map[string]any{
"table_name": tableName,
"count": len(paymentMaps),
"error": err.Error(),
}, "批量插入支付记录失败: %v", err)
return fmt.Errorf("插入分表 %s 失败: %v", tableName, err)
}
}
return nil
}
// randomTimeInRange 生成指定时间范围内的随机时间
func randomTimeInRange(start, end time.Time) time.Time {
delta := end.Sub(start)
sec := rand.Int63n(int64(delta.Seconds()))
return start.Add(time.Duration(sec) * time.Second)
}
// ensurePaymentShardingTableExists 确保支付记录分表存在
func (receiver *GenerateTestPayments) ensurePaymentShardingTableExists(paymentTime time.Time) error {
tableName := utils.GetShardingTableName("payments", paymentTime)
if !facades.Schema().HasTable(tableName) {
// 使用迁移函数创建分表
if err := migrations.CreatePaymentsShardingTable(tableName); err != nil {
return fmt.Errorf("创建支付记录分表失败: %v", err)
}
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package commands
import (
"fmt"
"strings"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
)
type OptimizeTables struct {
}
func (r *OptimizeTables) Signature() string {
return "db:optimize-tables"
}
func (r *OptimizeTables) Description() string {
// # MySQL: OPTIMIZE TABLE(整理碎片,回收空间)
// # PostgreSQL: VACUUM (ANALYZE)(清理死元组并更新统计信息)
//
// # 传参方式:直接传表名(可多个)
// go run . artisan db:optimize-tables payments
// go run . artisan db:optimize-tables payments orders_202601 order_details_202601
//
// # 选项方式:--tables= 逗号分隔
// go run . artisan db:optimize-tables --tables=payments,orders_202601
//
// # PostgreSQL 重度回收空间(风险高:会锁表,且耗时长)
// go run . artisan db:optimize-tables payments --full=true
//
// # 帮助
// go run . artisan db:optimize-tables --help
return "优化表(MySQL: OPTIMIZE TABLE; PostgreSQL: VACUUM"
}
func (r *OptimizeTables) Extend() command.Extend {
return command.Extend{
Category: "db",
Flags: []command.Flag{
&command.StringFlag{
Name: "tables",
Aliases: []string{"t"},
Usage: "要优化的表名列表(逗号分隔),也可以直接用参数方式传入多个表名",
},
&command.BoolFlag{
Name: "full",
Value: false,
Usage: "PostgreSQL 是否使用 VACUUM FULL(更重,可能锁表,默认 false",
},
},
}
}
func (r *OptimizeTables) Handle(ctx console.Context) error {
dbConnection := strings.ToLower(facades.Config().GetString("database.default", "sqlite"))
full := ctx.OptionBool("full")
var tables []string
// 1) 从 --tables 读取(逗号分隔)
tablesFlag := strings.TrimSpace(ctx.Option("tables"))
if tablesFlag != "" {
parts := strings.Split(tablesFlag, ",")
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
tables = append(tables, t)
}
}
}
// 2) 从参数读取(db:optimize-tables table1 table2 ...
for i := 0; ; i++ {
arg := strings.TrimSpace(ctx.Argument(i))
if arg == "" {
break
}
tables = append(tables, arg)
}
if len(tables) == 0 {
return fmt.Errorf("请提供要优化的表名,例如:go run . artisan db:optimize-tables payments 或使用 --tables=payments,orders_202601")
}
execOptimize := func(table string) error {
var sql string
switch dbConnection {
case "mysql":
sql = fmt.Sprintf("OPTIMIZE TABLE `%s`", table)
case "postgres":
if full {
sql = fmt.Sprintf("VACUUM (FULL, ANALYZE) %s", table)
} else {
sql = fmt.Sprintf("VACUUM (ANALYZE) %s", table)
}
default:
return fmt.Errorf("unsupported database: %s", dbConnection)
}
if _, err := facades.Orm().Query().Exec(sql); err != nil {
return err
}
ctx.Info("✓ " + sql)
return nil
}
ctx.Info("开始执行优化...")
for _, table := range tables {
if facades.Schema().HasTable(table) {
if err := execOptimize(table); err != nil {
return fmt.Errorf("optimize %s failed: %v", table, err)
}
}
}
ctx.Info("完成")
return nil
}
+292
View File
@@ -0,0 +1,292 @@
package commands
import (
"context"
"fmt"
"os"
"strings"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type QueueClear struct {
}
// Signature The name and signature of the console command.
func (r *QueueClear) Signature() string {
return "queue:clear"
}
// Description The console command description.
func (r *QueueClear) Description() string {
return "清理队列中的任务(仅支持 Redis 驱动)"
}
// Extend The console command extend.
func (r *QueueClear) Extend() command.Extend {
return command.Extend{
Category: "queue",
Flags: []command.Flag{
&command.StringFlag{
Name: "queue",
Aliases: []string{"q"},
Usage: "队列名称(可选,默认清理默认队列)",
},
&command.StringFlag{
Name: "connection",
Aliases: []string{"c"},
Usage: "队列连接名称(可选,默认使用默认连接)",
},
&command.BoolFlag{
Name: "force",
Usage: "强制清理,不提示确认",
},
},
}
}
// Handle Execute the console command.
func (r *QueueClear) Handle(ctx console.Context) error {
queueName := ctx.Option("queue")
connectionName := ctx.Option("connection")
// 布尔标志:检查命令行参数中是否包含 --force
// 在 Goravel 框架中,BoolFlag 存在时 ctx.Option 返回空字符串,所以需要检查命令行参数
force := r.hasForceFlag()
if connectionName == "" {
connectionName = facades.Config().GetString("queue.default", "sync")
}
// 判断队列驱动类型
driver := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.driver", connectionName), "")
// 检查是否是 Redis 驱动
isRedis := r.isRedisDriver(connectionName)
if !isRedis {
if driver == "sync" {
ctx.Info("同步驱动:任务立即执行,无需清理")
return nil
}
if driver == "database" {
ctx.Warning("数据库驱动暂不支持清理命令,请直接操作数据库 jobs 表")
return nil
}
ctx.Warning(fmt.Sprintf("驱动 %s 暂不支持清理命令", driver))
return nil
}
// Redis 驱动:清理队列
defaultQueue := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.queue", connectionName), "default")
if queueName == "" {
queueName = defaultQueue
}
// 获取 Redis 连接名称
redisConnectionName := r.getRedisConnectionName(connectionName)
if redisConnectionName == "" {
ctx.Warning("无法确定 Redis 连接名称")
return nil
}
// 查询当前队列统计
stats, err := r.getRedisQueueStats(redisConnectionName, connectionName, queueName)
if err != nil {
ctx.Error(fmt.Sprintf("查询队列统计失败: %v", err))
return err
}
totalCount := stats.Pending + stats.Reserved + stats.Delayed
if totalCount == 0 {
ctx.Info(fmt.Sprintf("队列 '%s' 中没有任何任务", queueName))
return nil
}
// 显示当前统计
ctx.Info(fmt.Sprintf("队列 '%s' 当前状态:", queueName))
ctx.Info(fmt.Sprintf(" 待执行任务: %d", stats.Pending))
ctx.Info(fmt.Sprintf(" 正在执行任务: %d", stats.Reserved))
ctx.Info(fmt.Sprintf(" 延迟任务: %d", stats.Delayed))
ctx.Info(fmt.Sprintf(" 总计: %d", totalCount))
ctx.Info("")
// 确认清理
if !force {
ctx.Warning(fmt.Sprintf("警告:此操作将删除队列 '%s' 中的所有任务(共 %d 个)", queueName, totalCount))
ctx.Info("如果确定要继续,请使用 --force 参数")
ctx.Info(fmt.Sprintf(" go run . artisan queue:clear --queue=%s --connection=%s --force", queueName, connectionName))
return nil
}
// 执行清理
ctx.Info("开始清理队列...")
redisClient, err := utils.GetRedisClient(redisConnectionName)
if err != nil {
ctx.Error(fmt.Sprintf("获取 Redis 客户端失败: %v", err))
return err
}
// 注意:使用公共 Redis 客户端池,不需要手动关闭
ctxRedis := context.Background()
clearedCount := int64(0)
// 清理待执行队列
pendingKey := r.redisQueueKey(connectionName, queueName)
pendingLen, _ := redisClient.LLen(ctxRedis, pendingKey).Result()
if pendingLen > 0 {
if err := redisClient.Del(ctxRedis, pendingKey).Err(); err != nil {
ctx.Error(fmt.Sprintf("清理待执行队列失败: %v", err))
} else {
clearedCount += pendingLen
ctx.Info(fmt.Sprintf("已清理待执行队列: %d 个任务", pendingLen))
}
}
// 清理正在执行队列
reservedKey := r.redisReservedKey(connectionName, queueName)
reservedLen, _ := redisClient.ZCard(ctxRedis, reservedKey).Result()
if reservedLen > 0 {
if err := redisClient.Del(ctxRedis, reservedKey).Err(); err != nil {
ctx.Error(fmt.Sprintf("清理正在执行队列失败: %v", err))
} else {
clearedCount += reservedLen
ctx.Info(fmt.Sprintf("已清理正在执行队列: %d 个任务", reservedLen))
}
}
// 清理延迟队列
delayedKey := r.redisDelayedKey(connectionName, queueName)
delayedLen, _ := redisClient.ZCard(ctxRedis, delayedKey).Result()
if delayedLen > 0 {
if err := redisClient.Del(ctxRedis, delayedKey).Err(); err != nil {
ctx.Error(fmt.Sprintf("清理延迟队列失败: %v", err))
} else {
clearedCount += delayedLen
ctx.Info(fmt.Sprintf("已清理延迟队列: %d 个任务", delayedLen))
}
}
ctx.Info(fmt.Sprintf("队列清理完成!共清理 %d 个任务", clearedCount))
return nil
}
// isRedisDriver 判断是否是 Redis 驱动
func (r *QueueClear) isRedisDriver(connectionName string) bool {
via := facades.Config().Get(fmt.Sprintf("queue.connections.%s.via", connectionName))
return via != nil || strings.Contains(connectionName, "redis")
}
// getRedisConnectionName 从队列连接配置中获取 Redis 连接名称
func (r *QueueClear) getRedisConnectionName(queueConnectionName string) string {
connection := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.connection", queueConnectionName), "default")
if strings.Contains(queueConnectionName, "redis") {
redisHost := facades.Config().GetString(fmt.Sprintf("database.redis.%s.host", queueConnectionName), "")
if redisHost != "" {
return queueConnectionName
}
}
return connection
}
// getRedisQueueStats 获取 Redis 队列统计信息
func (r *QueueClear) getRedisQueueStats(redisConnectionName, queueConnectionName, queueName string) (*RedisQueueStatsInfo, error) {
redisClient, err := utils.GetRedisClient(redisConnectionName)
if err != nil {
errorlog.Record(context.Background(), "queue", "获取 Redis 客户端失败", map[string]any{
"connection": redisConnectionName,
"error": err.Error(),
}, "获取 Redis 客户端失败: %v", err)
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
// 注意:使用公共 Redis 客户端池,不需要手动关闭
ctx := context.Background()
stats := &RedisQueueStatsInfo{}
pendingKey := r.redisQueueKey(queueConnectionName, queueName)
pendingLen, err := redisClient.LLen(ctx, pendingKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询待执行队列失败", map[string]any{
"queue_name": queueName,
"key": pendingKey,
"error": err.Error(),
}, "查询待执行队列失败: %v", err)
return nil, fmt.Errorf("查询待执行队列失败: %v", err)
}
stats.Pending = pendingLen
reservedKey := r.redisReservedKey(queueConnectionName, queueName)
reservedLen, err := redisClient.ZCard(ctx, reservedKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询正在执行队列失败", map[string]any{
"queue_name": queueName,
"key": reservedKey,
"error": err.Error(),
}, "查询正在执行队列失败: %v", err)
return nil, fmt.Errorf("查询正在执行队列失败: %v", err)
}
stats.Reserved = reservedLen
delayedKey := r.redisDelayedKey(queueConnectionName, queueName)
delayedLen, err := redisClient.ZCard(ctx, delayedKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询延迟队列失败", map[string]any{
"queue_name": queueName,
"key": delayedKey,
"error": err.Error(),
}, "查询延迟队列失败: %v", err)
return nil, fmt.Errorf("查询延迟队列失败: %v", err)
}
stats.Delayed = delayedLen
// 失败任务:从数据库 failed_jobs 表查询
var failedCount int64
if queueName != "" {
failedCount, err = facades.Orm().Query().Table("failed_jobs").
Where("queue = ?", queueName).
Count()
} else {
failedCount, err = facades.Orm().Query().Table("failed_jobs").Count()
}
if err != nil {
stats.Failed = 0
} else {
stats.Failed = failedCount
}
stats.Total = stats.Pending + stats.Reserved
return stats, nil
}
// redisQueueKey Goravel Redis queue key format:
// {appName}_queues:{queueConnection}_{queue}
func (r *QueueClear) redisQueueKey(queueConnectionName, queueName string) string {
appName := facades.Config().GetString("app.name", "goravel")
return fmt.Sprintf("%s_queues:%s_%s", appName, queueConnectionName, queueName)
}
func (r *QueueClear) redisReservedKey(queueConnectionName, queueName string) string {
return fmt.Sprintf("%s:reserved", r.redisQueueKey(queueConnectionName, queueName))
}
func (r *QueueClear) redisDelayedKey(queueConnectionName, queueName string) string {
return fmt.Sprintf("%s:delayed", r.redisQueueKey(queueConnectionName, queueName))
}
// hasForceFlag 检查命令行参数中是否包含 --force 标志
func (r *QueueClear) hasForceFlag() bool {
for _, arg := range os.Args {
if arg == "--force" || arg == "-force" {
return true
}
}
return false
}
+369
View File
@@ -0,0 +1,369 @@
package commands
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"strings"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type QueuePeek struct{}
// Signature The name and signature of the console command.
func (r *QueuePeek) Signature() string {
return "queue:peek"
}
// ./main artisan queue:peek --connection=redis --queue=long-running --state=all --limit=5 --full
func (r *QueuePeek) Description() string {
return "查看队列中前 N 条任务内容(支持 Redis/database),用于排查“导出”等任务到底投递了什么"
}
// Extend The console command extend.
func (r *QueuePeek) Extend() command.Extend {
return command.Extend{
Category: "queue",
Flags: []command.Flag{
&command.StringFlag{
Name: "queue",
Aliases: []string{"q"},
Usage: "队列名称(可选;导出任务一般在 long-running",
},
&command.StringFlag{
Name: "connection",
Aliases: []string{"c"},
Usage: "队列连接名称(可选,默认使用默认连接)",
},
&command.StringFlag{
Name: "state",
Aliases: []string{"s"},
Usage: "查看队列状态:pending|reserved|delayed|all(默认 pending",
},
&command.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Value: 10,
Usage: "最多显示多少条(默认 10",
},
&command.BoolFlag{
Name: "raw",
Usage: "输出原始 payload(不做 JSON 美化/摘要)",
},
&command.BoolFlag{
Name: "full",
Usage: "不截断输出(默认会截断到 500 字符)",
},
},
}
}
// Handle Execute the console command.
func (r *QueuePeek) Handle(ctx console.Context) error {
queueName := ctx.Option("queue")
connectionName := ctx.Option("connection")
state := strings.TrimSpace(strings.ToLower(ctx.Option("state")))
limit := ctx.OptionInt("limit")
raw := r.hasFlag("--raw")
full := r.hasFlag("--full")
if limit <= 0 {
limit = 10
}
if state == "" {
state = "pending"
}
if connectionName == "" {
connectionName = facades.Config().GetString("queue.default", "sync")
}
ctx.Info(fmt.Sprintf("队列连接: %s", connectionName))
if queueName != "" {
ctx.Info(fmt.Sprintf("队列名称: %s", queueName))
}
ctx.Info(fmt.Sprintf("查看状态: %s, limit=%d, raw=%v, full=%v", state, limit, raw, full))
ctx.Info("")
driver := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.driver", connectionName), "")
isRedis := r.isRedisDriver(connectionName)
if isRedis {
defaultQueue := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.queue", connectionName), "default")
if queueName == "" {
queueName = defaultQueue
}
redisConnectionName := r.getRedisConnectionName(connectionName)
if redisConnectionName == "" {
ctx.Warning("无法确定 Redis 连接名称")
return nil
}
redisClient, err := utils.GetRedisClient(redisConnectionName)
if err != nil {
errorlog.Record(context.Background(), "queue", "获取 Redis 客户端失败", map[string]any{
"connection": redisConnectionName,
"error": err.Error(),
}, "获取 Redis 客户端失败: %v", err)
return fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
c := context.Background()
printPayload := func(prefix, payload string) {
ctx.Info(prefix)
ctx.Info(r.renderPayload(payload, raw, full))
ctx.Info("")
}
if state == "pending" || state == "all" {
key := r.redisQueueKey(connectionName, queueName)
values, err := redisClient.LRange(c, key, 0, int64(limit-1)).Result()
if err != nil {
return fmt.Errorf("读取 pending 队列失败: %v", err)
}
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("Redis pending: key=%s, count(shown)=%d", key, len(values)))
ctx.Info("═══════════════════════════════════════")
if len(values) == 0 {
ctx.Info("(空)")
ctx.Info("")
}
for i, v := range values {
printPayload(fmt.Sprintf("[%d] pending", i+1), v)
}
}
if state == "reserved" || state == "all" {
key := r.redisReservedKey(connectionName, queueName)
zs, err := redisClient.ZRangeWithScores(c, key, 0, int64(limit-1)).Result()
if err != nil {
return fmt.Errorf("读取 reserved 队列失败: %v", err)
}
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("Redis reserved(ZSET): key=%s, count(shown)=%d", key, len(zs)))
ctx.Info("═══════════════════════════════════════")
if len(zs) == 0 {
ctx.Info("(空)")
ctx.Info("")
}
for i, z := range zs {
member, _ := z.Member.(string)
scoreInfo := r.formatScoreAsTime(z.Score)
printPayload(fmt.Sprintf("[%d] reserved score=%v%s", i+1, z.Score, scoreInfo), member)
}
}
if state == "delayed" || state == "all" {
key := r.redisDelayedKey(connectionName, queueName)
zs, err := redisClient.ZRangeWithScores(c, key, 0, int64(limit-1)).Result()
if err != nil {
return fmt.Errorf("读取 delayed 队列失败: %v", err)
}
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("Redis delayed(ZSET): key=%s, count(shown)=%d", key, len(zs)))
ctx.Info("═══════════════════════════════════════")
if len(zs) == 0 {
ctx.Info("(空)")
ctx.Info("")
}
for i, z := range zs {
member, _ := z.Member.(string)
scoreInfo := r.formatScoreAsTime(z.Score)
printPayload(fmt.Sprintf("[%d] delayed score=%v%s", i+1, z.Score, scoreInfo), member)
}
}
return nil
}
if driver == "sync" {
ctx.Info("同步驱动:任务立即执行,无队列数据")
return nil
}
if driver != "database" {
ctx.Warning(fmt.Sprintf("驱动 %s 暂不支持 peek 查看", driver))
return nil
}
// database driver
q := facades.Orm().Query().Table("jobs").Select("id", "queue", "payload", "attempts", "reserved_at", "available_at", "created_at")
now := time.Now()
switch state {
case "pending":
q = q.Where("reserved_at IS NULL").Where("available_at", "<=", now)
case "reserved":
q = q.Where("reserved_at IS NOT NULL")
case "delayed":
q = q.Where("reserved_at IS NULL").Where("available_at", ">", now)
case "all":
// no filter
default:
ctx.Warning("state 参数只支持 pending|reserved|delayed|all")
return nil
}
if queueName != "" {
q = q.Where("queue", "=", queueName)
}
var rows []map[string]any
if err := q.OrderByDesc("id").Limit(limit).Get(&rows); err != nil {
return fmt.Errorf("查询 jobs 表失败: %v", err)
}
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("Database jobs: state=%s, count(shown)=%d", state, len(rows)))
ctx.Info("═══════════════════════════════════════")
if len(rows) == 0 {
ctx.Info("(空)")
return nil
}
for i, row := range rows {
id := row["id"]
qn := row["queue"]
attempts := row["attempts"]
payload, _ := row["payload"].(string)
ctx.Info(fmt.Sprintf("[%d] id=%v queue=%v attempts=%v", i+1, id, qn, attempts))
ctx.Info(r.renderPayload(payload, raw, full))
ctx.Info("")
}
return nil
}
func (r *QueuePeek) isRedisDriver(connectionName string) bool {
via := facades.Config().Get(fmt.Sprintf("queue.connections.%s.via", connectionName))
return via != nil || strings.Contains(connectionName, "redis")
}
func (r *QueuePeek) getRedisConnectionName(queueConnectionName string) string {
connection := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.connection", queueConnectionName), "default")
if strings.Contains(queueConnectionName, "redis") {
redisHost := facades.Config().GetString(fmt.Sprintf("database.redis.%s.host", queueConnectionName), "")
if redisHost != "" {
return queueConnectionName
}
}
return connection
}
// redisQueueKey Goravel Redis queue key format:
// {appName}_queues:{queueConnection}_{queue}
func (r *QueuePeek) redisQueueKey(queueConnectionName, queueName string) string {
appName := facades.Config().GetString("app.name", "goravel")
return fmt.Sprintf("%s_queues:%s_%s", appName, queueConnectionName, queueName)
}
func (r *QueuePeek) redisReservedKey(queueConnectionName, queueName string) string {
return fmt.Sprintf("%s:reserved", r.redisQueueKey(queueConnectionName, queueName))
}
func (r *QueuePeek) redisDelayedKey(queueConnectionName, queueName string) string {
return fmt.Sprintf("%s:delayed", r.redisQueueKey(queueConnectionName, queueName))
}
func (r *QueuePeek) renderPayload(payload string, raw, full bool) string {
if raw {
return r.maybeTruncate(payload, full)
}
trimmed := strings.TrimSpace(payload)
if trimmed == "" {
return "(空 payload"
}
// best-effort JSON pretty + add a tiny summary if we can detect job/signature fields
var obj any
if err := json.Unmarshal([]byte(trimmed), &obj); err == nil {
summary := r.summarizePayload(obj)
pretty, _ := json.MarshalIndent(obj, "", " ")
out := string(pretty)
if summary != "" {
out = summary + "\n" + out
}
return r.maybeTruncate(out, full)
}
// not JSON, fallback to raw
return r.maybeTruncate(payload, full)
}
func (r *QueuePeek) summarizePayload(obj any) string {
m, ok := obj.(map[string]any)
if !ok {
return ""
}
// Goravel/Laravel-like payloads often include these keys (best-effort)
candidates := []string{"job", "name", "signature", "displayName", "command"}
for _, k := range candidates {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && s != "" {
return fmt.Sprintf("摘要: %s=%s", k, s)
}
}
}
// sometimes job info nested
if data, ok := m["data"].(map[string]any); ok {
for _, k := range candidates {
if v, ok := data[k]; ok {
if s, ok := v.(string); ok && s != "" {
return fmt.Sprintf("摘要: data.%s=%s", k, s)
}
}
}
}
return ""
}
func (r *QueuePeek) maybeTruncate(s string, full bool) string {
if full {
return s
}
const max = 500
if len(s) <= max {
return s
}
return s[:max] + "\n...(truncated, use --full to show all)"
}
func (r *QueuePeek) hasFlag(flag string) bool {
for _, arg := range os.Args {
if arg == flag {
return true
}
}
return false
}
func (r *QueuePeek) formatScoreAsTime(score float64) string {
// Redis ZSET score for delayed jobs is commonly a unix timestamp (seconds).
if math.IsNaN(score) || math.IsInf(score, 0) {
return ""
}
sec := int64(score)
// heuristic: 10-digit seconds timestamp range
if sec < 1000000000 || sec > 5000000000 {
return ""
}
t := time.Unix(sec, 0).Local()
return fmt.Sprintf(" (%s)", t.Format(utils.DateTimeFormat))
}
+524
View File
@@ -0,0 +1,524 @@
package commands
import (
"context"
"fmt"
"strings"
"time"
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/console/command"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type QueueStats struct {
}
// Signature The name and signature of the console command.
func (r *QueueStats) Signature() string {
return "queue:stats"
}
// ./main artisan queue:stats --connection=redis --queue=long-running
func (r *QueueStats) Description() string {
return "查询队列统计信息,显示待执行、正在执行和失败任务数量"
}
// Extend The console command extend.
func (r *QueueStats) Extend() command.Extend {
return command.Extend{
Category: "queue",
Flags: []command.Flag{
&command.StringFlag{
Name: "queue",
Aliases: []string{"q"},
Usage: "队列名称(可选,用于筛选特定队列)",
},
&command.StringFlag{
Name: "connection",
Aliases: []string{"c"},
Usage: "队列连接名称(可选,默认使用默认连接)",
},
},
}
}
// Handle Execute the console command.
func (r *QueueStats) Handle(ctx console.Context) error {
queueName := ctx.Option("queue")
connectionName := ctx.Option("connection")
if connectionName == "" {
connectionName = facades.Config().GetString("queue.default", "sync")
}
ctx.Info(fmt.Sprintf("队列连接: %s", connectionName))
if queueName != "" {
ctx.Info(fmt.Sprintf("队列名称: %s", queueName))
}
ctx.Info("")
// 判断队列驱动类型
driver := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.driver", connectionName), "")
ctx.Info(fmt.Sprintf("驱动类型: %s", driver))
// 检查是否是 Redis 驱动(custom driver with via
isRedis := r.isRedisDriver(connectionName)
if isRedis {
// Redis 驱动:通过 Redis 客户端查询队列大小
defaultQueue := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.queue", connectionName), "default")
originalQueueName := queueName
queueNameForStats := queueName
if queueNameForStats == "" {
queueNameForStats = defaultQueue
}
// 获取 Redis 连接名称(从队列配置中获取)
redisConnectionName := r.getRedisConnectionName(connectionName)
if redisConnectionName == "" {
ctx.Warning("无法确定 Redis 连接名称")
return nil
}
// 查询 Redis 队列统计
stats, err := r.getRedisQueueStats(redisConnectionName, connectionName, queueNameForStats)
if err != nil {
ctx.Error(fmt.Sprintf("查询 Redis 队列统计失败: %v", err))
ctx.Info("提示:请确保 Redis 连接配置正确且 Redis 服务正在运行")
return err
}
totalCount := stats.Pending + stats.Reserved
// 显示统计信息
ctx.Info("═══════════════════════════════════════")
ctx.Info("队列统计信息 (Redis)")
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("队列名称: %s", queueNameForStats))
ctx.Info(fmt.Sprintf("待执行任务: %d", stats.Pending))
ctx.Info(fmt.Sprintf("正在执行任务: %d", stats.Reserved))
ctx.Info(fmt.Sprintf("延迟任务: %d", stats.Delayed))
ctx.Info(fmt.Sprintf("失败任务: %d", stats.Failed))
ctx.Info(fmt.Sprintf("总任务数: %d", totalCount))
ctx.Info("═══════════════════════════════════════")
// 提示信息
if stats.Pending > 0 {
ctx.Info("")
ctx.Warning(fmt.Sprintf("提示:队列中有 %d 个待执行任务", stats.Pending))
ctx.Info("")
ctx.Info("如果需要处理这些任务:")
ctx.Info(" 1. 启动主程序(main.go 中会自动启动队列 Worker")
ctx.Info(" go run .")
ctx.Info(" 2. 确保 Worker 监听正确的队列名称和连接")
ctx.Info(" 3. 确保任务已正确注册到 QueueServiceProvider")
ctx.Info("")
ctx.Info("如果不需要这些任务,可以清理队列:")
ctx.Info(" 使用 Redis 客户端执行以下命令清理队列:")
appName := facades.Config().GetString("app.name", "goravel")
baseKey := r.redisQueueKey(connectionName, queueNameForStats)
ctx.Info(fmt.Sprintf(" # app.name=%s, queue.connection=%s, queue=%s", appName, connectionName, queueNameForStats))
ctx.Info(fmt.Sprintf(" redis-cli DEL %s", baseKey))
ctx.Info(fmt.Sprintf(" redis-cli DEL %s:reserved", baseKey))
ctx.Info(fmt.Sprintf(" redis-cli DEL %s:delayed", baseKey))
ctx.Info(" 或者使用命令:go run . artisan queue:clear --queue=" + queueNameForStats)
}
// 如果未指定队列名称,显示按队列分组的统计
if originalQueueName == "" {
ctx.Info("")
ctx.Info("按队列分组统计:")
byQueue, err := r.getRedisStatsByQueue(redisConnectionName, connectionName)
if err != nil {
ctx.Warning(fmt.Sprintf("获取按队列分组统计失败: %v", err))
} else {
if len(byQueue) == 0 {
ctx.Info(" 暂无队列数据")
} else {
for qName, qStats := range byQueue {
ctx.Info(fmt.Sprintf(" 队列 [%s]:", qName))
ctx.Info(fmt.Sprintf(" 待执行: %d, 正在执行: %d, 延迟: %d, 失败: %d, 总计: %d",
qStats.Pending, qStats.Reserved, qStats.Delayed, qStats.Failed, qStats.Total))
}
}
}
}
return nil
}
if driver == "sync" {
ctx.Info("同步驱动:任务立即执行,无队列数据")
return nil
}
if driver != "database" {
ctx.Warning(fmt.Sprintf("驱动 %s 暂不支持统计查询", driver))
return nil
}
// Database 驱动:查询 jobs 表
var pendingCount, reservedCount int64
var err error
// 查询待执行任务数(available_at <= now 且 reserved_at 为 null
pendingQuery := facades.Orm().Query().Table("jobs").
Where("available_at", "<=", time.Now()).
Where("reserved_at IS NULL")
// 查询正在执行任务数(reserved_at 不为 null
reservedQuery := facades.Orm().Query().Table("jobs").
Where("reserved_at IS NOT NULL")
// 如果指定了队列名称,添加筛选条件
if queueName != "" {
pendingQuery = pendingQuery.Where("queue", "=", queueName)
reservedQuery = reservedQuery.Where("queue", "=", queueName)
}
pendingCount, err = pendingQuery.Count()
if err != nil {
ctx.Error(fmt.Sprintf("查询待执行任务数失败: %v", err))
return err
}
reservedCount, err = reservedQuery.Count()
if err != nil {
ctx.Error(fmt.Sprintf("查询正在执行任务数失败: %v", err))
return err
}
// 查询失败任务数(从 failed_jobs 表)
failedQuery := facades.Orm().Query().Table("failed_jobs")
if queueName != "" {
failedQuery = failedQuery.Where("queue", "=", queueName)
}
failedCount, err := failedQuery.Count()
if err != nil {
ctx.Error(fmt.Sprintf("查询失败任务数失败: %v", err))
return err
}
totalCount := pendingCount + reservedCount
// 显示统计信息
ctx.Info("═══════════════════════════════════════")
ctx.Info("队列统计信息")
ctx.Info("═══════════════════════════════════════")
ctx.Info(fmt.Sprintf("待执行任务: %d", pendingCount))
ctx.Info(fmt.Sprintf("正在执行任务: %d", reservedCount))
ctx.Info(fmt.Sprintf("失败任务: %d", failedCount))
ctx.Info(fmt.Sprintf("总任务数: %d", totalCount))
ctx.Info("═══════════════════════════════════════")
// 如果未指定队列名称,显示按队列分组的统计
if queueName == "" {
ctx.Info("")
ctx.Info("按队列分组统计:")
byQueue, err := r.getStatsByQueue()
if err != nil {
ctx.Warning(fmt.Sprintf("获取按队列分组统计失败: %v", err))
} else {
if len(byQueue) == 0 {
ctx.Info(" 暂无队列数据")
} else {
for qName, stats := range byQueue {
ctx.Info(fmt.Sprintf(" 队列 [%s]:", qName))
ctx.Info(fmt.Sprintf(" 待执行: %d, 正在执行: %d, 失败: %d, 总计: %d",
stats.Pending, stats.Reserved, stats.Failed, stats.Total))
}
}
}
}
return nil
}
// isRedisDriver 判断是否是 Redis 驱动
func (r *QueueStats) isRedisDriver(connectionName string) bool {
via := facades.Config().Get(fmt.Sprintf("queue.connections.%s.via", connectionName))
return via != nil || strings.Contains(connectionName, "redis")
}
// QueueStatsInfo 队列统计信息
type QueueStatsInfo struct {
Pending int64
Reserved int64
Failed int64
Total int64
}
// RedisQueueStatsInfo Redis 队列统计信息
type RedisQueueStatsInfo struct {
Pending int64
Reserved int64
Delayed int64
Failed int64
Total int64
}
// getStatsByQueue 按队列分组获取统计信息
func (r *QueueStats) getStatsByQueue() (map[string]QueueStatsInfo, error) {
// 获取所有队列名称
var queues []string
err := facades.Orm().Query().Table("jobs").
Select("DISTINCT queue").
Pluck("queue", &queues)
if err != nil {
return nil, err
}
// 获取失败任务的队列名称
var failedQueues []string
err = facades.Orm().Query().Table("failed_jobs").
Select("DISTINCT queue").
Pluck("queue", &failedQueues)
if err != nil {
return nil, err
}
// 合并队列名称
queueMap := make(map[string]bool)
for _, q := range queues {
queueMap[q] = true
}
for _, q := range failedQueues {
queueMap[q] = true
}
result := make(map[string]QueueStatsInfo)
now := time.Now()
for qName := range queueMap {
// 待执行任务数
pendingCount, _ := facades.Orm().Query().Table("jobs").
Where("queue", "=", qName).
Where("available_at", "<=", now).
Where("reserved_at IS NULL").
Count()
// 正在执行任务数
reservedCount, _ := facades.Orm().Query().Table("jobs").
Where("queue", "=", qName).
Where("reserved_at IS NOT NULL").
Count()
// 失败任务数
failedCount, _ := facades.Orm().Query().Table("failed_jobs").
Where("queue", "=", qName).
Count()
result[qName] = QueueStatsInfo{
Pending: pendingCount,
Reserved: reservedCount,
Failed: failedCount,
Total: pendingCount + reservedCount,
}
}
return result, nil
}
// getRedisConnectionName 从队列连接配置中获取 Redis 连接名称
func (r *QueueStats) getRedisConnectionName(queueConnectionName string) string {
// 从队列配置中获取 connection 字段
connection := facades.Config().GetString(fmt.Sprintf("queue.connections.%s.connection", queueConnectionName), "default")
// 如果队列连接名称包含 redis,尝试使用它
if strings.Contains(queueConnectionName, "redis") {
// 检查 Redis 配置中是否存在对应的连接
redisHost := facades.Config().GetString(fmt.Sprintf("database.redis.%s.host", queueConnectionName), "")
if redisHost != "" {
return queueConnectionName
}
}
// 默认使用 default
return connection
}
// getRedisQueueStats 获取 Redis 队列统计信息
func (r *QueueStats) getRedisQueueStats(redisConnectionName, queueConnectionName, queueName string) (*RedisQueueStatsInfo, error) {
// 使用公共 Redis 客户端
redisClient, err := utils.GetRedisClient(redisConnectionName)
if err != nil {
errorlog.Record(context.Background(), "queue", "获取 Redis 客户端失败", map[string]any{
"connection": redisConnectionName,
"error": err.Error(),
}, "获取 Redis 客户端失败: %v", err)
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
// 注意:使用公共 Redis 客户端池,不需要手动关闭
ctx := context.Background()
stats := &RedisQueueStatsInfo{}
// Goravel Redis driver:
// pending: {app}_queues:{queueConnection}_{queue} (List)
// reserved: {app}_queues:{queueConnection}_{queue}:reserved (ZSET)
// delayed: {app}_queues:{queueConnection}_{queue}:delayed (ZSET)
// 注意:这里的 queueConnectionName 是队列连接名(例如 redis),不是 redis client connectiondefault
baseKey := r.redisQueueKey(queueConnectionName, queueName)
pendingKey := baseKey
pendingLen, err := redisClient.LLen(ctx, pendingKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询待执行队列失败", map[string]any{
"queue_name": queueName,
"key": pendingKey,
"error": err.Error(),
}, "查询待执行队列失败: %v", err)
return nil, fmt.Errorf("查询待执行队列失败: %v", err)
}
stats.Pending = pendingLen
// 正在执行队列:{baseKey}:reserved (ZSET)
reservedKey := fmt.Sprintf("%s:reserved", baseKey)
reservedLen, err := redisClient.ZCard(ctx, reservedKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询正在执行队列失败", map[string]any{
"queue_name": queueName,
"key": reservedKey,
"error": err.Error(),
}, "查询正在执行队列失败: %v", err)
return nil, fmt.Errorf("查询正在执行队列失败: %v", err)
}
stats.Reserved = reservedLen
// 延迟队列:{baseKey}:delayed (ZSET)
delayedKey := fmt.Sprintf("%s:delayed", baseKey)
delayedLen, err := redisClient.ZCard(ctx, delayedKey).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查询延迟队列失败", map[string]any{
"queue_name": queueName,
"key": delayedKey,
"error": err.Error(),
}, "查询延迟队列失败: %v", err)
return nil, fmt.Errorf("查询延迟队列失败: %v", err)
}
stats.Delayed = delayedLen
// 调试信息:显示 Redis 键的实际值(可选,用于排查问题)
// 可以查看队列中的实际内容
if pendingLen > 0 {
// 查看队列中的第一个任务(不移除)
firstTask, _ := redisClient.LIndex(ctx, pendingKey, 0).Result()
if firstTask != "" {
// 只显示前100个字符,避免输出过长
if len(firstTask) > 100 {
firstTask = firstTask[:100] + "..."
}
}
}
// 失败任务:从数据库 failed_jobs 表查询(Redis 队列的失败任务也存储在数据库中)
var failedCount int64
if queueName != "" {
failedCount, err = facades.Orm().Query().Table("failed_jobs").
Where("queue = ?", queueName).
Count()
} else {
failedCount, err = facades.Orm().Query().Table("failed_jobs").Count()
}
if err != nil {
// 失败任务查询失败不影响其他统计
stats.Failed = 0
} else {
stats.Failed = failedCount
}
stats.Total = stats.Pending + stats.Reserved
return stats, nil
}
// getRedisStatsByQueue 按队列分组获取 Redis 队列统计信息
func (r *QueueStats) getRedisStatsByQueue(redisConnectionName, queueConnectionName string) (map[string]*RedisQueueStatsInfo, error) {
// 使用公共 Redis 客户端
redisClient, err := utils.GetRedisClient(redisConnectionName)
if err != nil {
errorlog.Record(context.Background(), "queue", "获取 Redis 客户端失败", map[string]any{
"connection": redisConnectionName,
"error": err.Error(),
}, "获取 Redis 客户端失败: %v", err)
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
}
// 注意:使用公共 Redis 客户端池,不需要手动关闭
ctx := context.Background()
result := make(map[string]*RedisQueueStatsInfo)
// 查找所有队列键({app}_queues:{queueConnection}_*
appName := facades.Config().GetString("app.name", "goravel")
prefix := fmt.Sprintf("%s_queues:%s_", appName, queueConnectionName)
pattern := prefix + "*"
keys, err := redisClient.Keys(ctx, pattern).Result()
if err != nil {
errorlog.Record(context.Background(), "queue", "查找队列键失败", map[string]any{
"pattern": pattern,
"error": err.Error(),
}, "查找队列键失败: %v", err)
return nil, fmt.Errorf("查找队列键失败: %v", err)
}
// 提取队列名称(排除 reserved 和 delayed 键)
queueNames := lo.FilterMap(keys, func(key string, _ int) (string, bool) {
// 跳过 reserved 和 delayed 键(ZSET
if strings.HasSuffix(key, ":reserved") || strings.HasSuffix(key, ":delayed") {
return "", false
}
// 必须以 prefix 开头
if !strings.HasPrefix(key, prefix) {
return "", false
}
after := strings.TrimPrefix(key, prefix)
if after == "" {
return "", false
}
return after, true
})
// 去重队列名称
queueMap := lo.SliceToMap(lo.Uniq(queueNames), func(queueName string) (string, bool) {
return queueName, true
})
// 如果没有找到队列键,尝试从失败任务表中获取队列名称
if len(queueMap) == 0 {
var failedQueues []string
err = facades.Orm().Query().Table("failed_jobs").
Select("DISTINCT queue").
Pluck("queue", &failedQueues)
if err == nil {
validQueues := lo.Filter(failedQueues, func(q string, _ int) bool {
return q != ""
})
queueMap = lo.SliceToMap(validQueues, func(queueName string) (string, bool) {
return queueName, true
})
}
}
// 为每个队列获取统计信息
for queueName := range queueMap {
stats, err := r.getRedisQueueStats(redisConnectionName, queueConnectionName, queueName)
if err != nil {
// 单个队列查询失败不影响其他队列
continue
}
result[queueName] = stats
}
return result, nil
}
// redisQueueKey Goravel Redis queue key format:
// {appName}_queues:{queueConnection}_{queue}
func (r *QueueStats) redisQueueKey(queueConnectionName, queueName string) string {
appName := facades.Config().GetString("app.name", "goravel")
return fmt.Sprintf("%s_queues:%s_%s", appName, queueConnectionName, queueName)
}
+46
View File
@@ -0,0 +1,46 @@
package console
import (
"github.com/goravel/framework/contracts/console"
"github.com/goravel/framework/contracts/schedule"
"github.com/goravel/framework/facades"
"goravel/app/console/commands"
)
type Kernel struct {
}
func (kernel *Kernel) Schedule() []schedule.Event {
return []schedule.Event{
// 每天凌晨2点执行(北京时间),清理6个月前的日志
// 北京时间 02:00 = UTC 18:00(前一天)
facades.Schedule().Command("app:clear-logs").DailyAt("18:00").OnOneServer(),
// 每天凌晨3点执行(北京时间),清理3天前的分片文件
// 北京时间 03:00 = UTC 19:00(前一天)
facades.Schedule().Command("app:clear-chunks").DailyAt("19:00").OnOneServer(),
// 每天凌晨3点30分执行(UTC 19:30 / 北京时间 03:30), 分析表(更新统计信息)
facades.Schedule().Command("db:analyze-stats").DailyAt("19:30").OnOneServer(),
// 每月1号凌晨1点执行(UTC时间),创建下个月的订单分表
facades.Schedule().Command("order:create-sharding-tables").Monthly().OnOneServer(),
// 每月1号凌晨1点30分执行(UTC时间),创建下个月的支付记录分表
facades.Schedule().Command("payment:create-sharding-tables").Monthly().OnOneServer(),
}
}
func (kernel *Kernel) Commands() []console.Command {
return []console.Command{
&commands.ClearLogs{},
&commands.ClearChunks{},
&commands.CreateToken{},
&commands.QueueStats{},
&commands.QueueClear{},
&commands.QueuePeek{},
commands.NewCreateOrderShardingTables(),
commands.NewCreatePaymentShardingTables(),
&commands.GenerateTestOrders{},
&commands.GenerateTestPayments{},
&commands.AnalyzeStats{},
&commands.OptimizeTables{},
}
}
+6
View File
@@ -0,0 +1,6 @@
package constants
// UserBalanceLogsShards 用户余额变动记录表的分表数量
// 建议设置为 2 的幂次(如 4, 8, 16, 32, 64, 128 等)
// 修改此值即可,所有相关代码会自动使用此常量
const UserBalanceLogsShards = 4
+12
View File
@@ -0,0 +1,12 @@
package constants
import "time"
const (
// OnlineAdminThreshold 在线管理员判断阈值
// 如果管理员的 last_used_at 在这个时间范围内,则认为管理员在线
OnlineAdminThreshold = 15 * time.Minute
// DefaultCleanLogDays 默认清理日志的天数
DefaultCleanLogDays = 30
)
+317
View File
@@ -0,0 +1,317 @@
package errors
import (
stderrors "errors"
"fmt"
"strings"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
// 定义业务错误类型
var (
// 认证相关错误
ErrAccountDisabled = NewBusinessError("account_disabled", "账号已被禁用")
ErrPasswordError = NewBusinessError("password_error", "密码错误")
ErrNotLoggedIn = NewBusinessError("not_logged_in", "未登录")
ErrUsernameOrPasswordErr = NewBusinessError("username_or_password_error", "用户名或密码错误")
ErrLoginFailed = NewBusinessError("login_failed", "登录失败")
// 验证相关错误
ErrValidationFailed = NewBusinessError("validation_failed", "验证失败")
ErrInvalidArgument = NewBusinessError("invalid_argument", "无效的参数")
// 资源相关错误
ErrRecordNotFound = NewBusinessError("record_not_found", "记录不存在")
ErrBlacklistNotFound = NewBusinessError("blacklist_not_found", "黑名单不存在")
ErrNotificationNotFound = NewBusinessError("notification_not_found", "通知不存在")
ErrAdminNotFound = NewBusinessError("admin_not_found", "管理员不存在")
ErrRoleNotFound = NewBusinessError("role_not_found", "角色不存在")
ErrMenuNotFound = NewBusinessError("menu_not_found", "菜单不存在")
ErrPermissionNotFound = NewBusinessError("permission_not_found", "权限不存在")
ErrDepartmentNotFound = NewBusinessError("department_not_found", "部门不存在")
ErrDictionaryNotFound = NewBusinessError("dictionary_not_found", "字典不存在")
ErrLogNotFound = NewBusinessError("log_not_found", "日志不存在")
// IP 相关错误
ErrIPAddressRequired = NewBusinessError("ip_address_required", "IP地址不能为空")
ErrInvalidCIDRFormat = NewBusinessError("invalid_cidr_format", "CIDR格式错误")
ErrInvalidIPRangeFormat = NewBusinessError("invalid_ip_range_format", "IP范围格式错误")
ErrInvalidIPFormat = NewBusinessError("invalid_ip_format", "IP格式错误")
ErrInvalidIPRangeOrder = NewBusinessError("invalid_ip_range_order", "IP范围顺序错误")
// 参数相关错误
ErrIDRequired = NewBusinessError("id_required", "ID不能为空")
ErrIDsRequired = NewBusinessError("ids_required", "IDs不能为空")
ErrParamsError = NewBusinessError("params_error", "参数错误")
ErrParamsRequired = NewBusinessError("params_required", "参数不能为空")
ErrFileRequired = NewBusinessError("file_required", "文件不能为空")
ErrFilePathRequired = NewBusinessError("file_path_required", "文件路径不能为空")
ErrCodeRequired = NewBusinessError("code_required", "验证码不能为空")
ErrTokenIDRequired = NewBusinessError("token_id_required", "Token ID不能为空")
ErrTokenIDsRequired = NewBusinessError("token_ids_required", "Token IDs不能为空")
ErrUserIDRequired = NewBusinessError("user_id_required", "用户ID不能为空")
ErrChunkIDRequired = NewBusinessError("chunk_id_required", "分片ID不能为空")
ErrFilenameRequired = NewBusinessError("filename_required", "文件名不能为空")
// 附件相关错误
ErrChunkUploadOnlyLocalStorage = NewBusinessError("chunk_upload_only_local_storage", "大文件分片上传仅支持本地存储")
ErrInvalidChunkIndex = NewBusinessError("invalid_chunk_index", "分片索引无效")
ErrInvalidTotalChunks = NewBusinessError("invalid_total_chunks", "总分片数无效")
ErrInvalidTotalSize = NewBusinessError("invalid_total_size", "总大小无效")
ErrInvalidChunkSize = NewBusinessError("invalid_chunk_size", "分片大小无效")
ErrChunkFileRequired = NewBusinessError("chunk_file_required", "分片文件不能为空")
ErrInvalidAction = NewBusinessError("invalid_action", "无效的操作")
ErrAttachmentNotFound = NewBusinessError("attachment_not_found", "附件不存在")
ErrChunkNotFound = NewBusinessError("chunk_not_found", "分片不存在")
ErrChunkMissing = NewBusinessError("chunk_missing", "分片缺失")
ErrNoChunkDataToMerge = NewBusinessError("no_chunk_data_to_merge", "没有可合并的分片数据")
ErrSaveChunkFailed = NewBusinessError("save_chunk_failed", "保存分片失败")
ErrCreateDirectoryFailed = NewBusinessError("create_directory_failed", "创建目标目录失败")
ErrCreateFileFailed = NewBusinessError("create_file_failed", "创建目标文件失败")
ErrWriteChunkFailed = NewBusinessError("write_chunk_failed", "写入分片失败")
ErrCloseFileFailed = NewBusinessError("close_file_failed", "关闭目标文件失败")
ErrSaveFileFailed = NewBusinessError("save_file_failed", "保存文件失败")
ErrDeleteFileFailed = NewBusinessError("delete_file_failed", "删除文件失败")
// 数据存在性错误
ErrUsernameExists = NewBusinessError("username_exists", "用户名已存在")
ErrMenuSlugExists = NewBusinessError("menu_slug_exists", "菜单标识已存在")
ErrRoleNameExists = NewBusinessError("role_name_exists", "角色名称已存在")
ErrRoleSlugExists = NewBusinessError("role_slug_exists", "角色标识已存在")
ErrPermissionNameExists = NewBusinessError("permission_name_exists", "权限名称已存在")
ErrPermissionSlugExists = NewBusinessError("permission_slug_exists", "权限标识已存在")
ErrPermissionNameOrSlugExists = NewBusinessError("permission_name_or_slug_exists", "权限名称或标识已存在")
ErrPermissionNameAndSlugRequired = NewBusinessError("permission_name_and_slug_required", "权限名称和标识不能为空")
// 业务逻辑错误
ErrMenuHasChildren = NewBusinessError("menu_has_children", "菜单存在子菜单,无法删除")
ErrDepartmentHasChildren = NewBusinessError("department_has_children", "部门存在子部门,无法删除")
ErrDepartmentHasAdmins = NewBusinessError("department_has_admins", "部门存在管理员,无法删除")
ErrGoogleAuthenticatorNotBound = NewBusinessError("google_authenticator_not_bound", "未绑定谷歌验证器")
ErrGoogleAuthenticatorAlreadyBound = NewBusinessError("google_authenticator_already_bound", "已绑定谷歌验证器")
ErrGoogleCodeInvalid = NewBusinessError("google_code_invalid", "谷歌验证码无效")
ErrGoogleCodeRequired = NewBusinessError("google_code_required", "谷歌验证码不能为空")
ErrSecretAndCodeRequired = NewBusinessError("secret_and_code_required", "密钥和验证码不能为空")
ErrOldPasswordError = NewBusinessError("old_password_error", "旧密码错误")
ErrInvalidTokenID = NewBusinessError("invalid_token_id", "无效的Token ID")
ErrInvalidTokenIDs = NewBusinessError("invalid_token_ids", "无效的Token IDs")
ErrInvalidUserID = NewBusinessError("invalid_user_id", "无效的用户ID")
ErrDictionaryTypeRequired = NewBusinessError("dictionary_type_required", "字典类型不能为空")
ErrConfigGroupRequired = NewBusinessError("config_group_required", "配置组不能为空")
ErrConfigsRequired = NewBusinessError("configs_required", "配置不能为空")
ErrEmailConfigRequired = NewBusinessError("email_config_required", "邮箱配置不能为空")
ErrOptionTypeRequired = NewBusinessError("option_type_required", "选项类型不能为空")
ErrInvalidOptionType = NewBusinessError("invalid_option_type", "无效的选项类型")
// 余额相关错误
ErrInsufficientBalance = NewBusinessError("insufficient_balance", "余额不足")
ErrInvalidBalanceType = NewBusinessError("invalid_balance_type", "无效的变动类型")
// 订单相关错误
ErrOrderNotFound = NewBusinessError("order_not_found", "订单不存在")
ErrOrderIDRequired = NewBusinessError("order_id_required", "订单ID不能为空")
ErrGetLockFailed = NewBusinessError("get_lock_failed", "获取锁失败")
ErrGenerateOrderNoFailed = NewBusinessError("generate_order_no_failed", "生成唯一订单号失败,请重试")
ErrCreateOrderFailed = NewBusinessError("create_order_failed", "创建订单失败")
ErrCreateOrderDetailFailed = NewBusinessError("create_order_detail_failed", "创建订单详情失败")
ErrQueryOrderDetailFailed = NewBusinessError("query_order_detail_failed", "查询订单详情失败")
ErrDeleteOrderDetailFailed = NewBusinessError("delete_order_detail_failed", "删除订单详情失败")
// 支付相关错误
ErrPaymentMethodNotFound = NewBusinessError("payment_method_not_found", "支付方式不存在")
ErrPaymentNotFound = NewBusinessError("payment_not_found", "支付记录不存在")
ErrPaymentMethodDisabled = NewBusinessError("payment_method_disabled", "支付方式已禁用")
ErrPaymentMethodCodeExists = NewBusinessError("payment_method_code_exists", "支付方式代码已存在")
ErrInvalidPaymentType = NewBusinessError("invalid_payment_type", "无效的支付类型")
ErrPaymentConfigRequired = NewBusinessError("payment_config_required", "支付配置不能为空")
ErrCreatePaymentFailed = NewBusinessError("create_payment_failed", "创建支付记录失败")
ErrPaymentAmountInvalid = NewBusinessError("payment_amount_invalid", "支付金额无效")
ErrPaymentStatusInvalid = NewBusinessError("payment_status_invalid", "支付状态无效")
// 导出相关错误
ErrExportRecordNotFound = NewBusinessError("export_record_not_found", "导出记录不存在")
ErrWriteCSVHeaderFailed = NewBusinessError("write_csv_header_failed", "写入CSV表头失败")
ErrWriteCSVDataFailed = NewBusinessError("write_csv_data_failed", "写入CSV数据失败")
ErrCSVWriteFailed = NewBusinessError("csv_write_failed", "CSV写入失败")
ErrExcelNotImplemented = NewBusinessError("excel_not_implemented", "Excel导出功能暂未实现,请使用CSV格式")
ErrBatchDeleteExportFailed = NewBusinessError("batch_delete_export_failed", "批量删除导出记录失败")
// 导入相关错误
ErrInvalidCSVFormat = NewBusinessError("invalid_csv_format", "CSV格式无效")
ErrInvalidFileType = NewBusinessError("invalid_file_type", "文件类型无效")
// 分表相关错误
ErrBaseTableNotRegistered = NewBusinessError("base_table_not_registered", "未注册的基础表名")
ErrCreateShardingTableFailed = NewBusinessError("create_sharding_table_failed", "创建分表失败")
// 操作相关错误
ErrCreateFailed = NewBusinessError("create_failed", "创建失败")
ErrUpdateFailed = NewBusinessError("update_failed", "更新失败")
ErrQueryFailed = NewBusinessError("query_failed", "查询失败")
ErrDeleteFailed = NewBusinessError("delete_failed", "删除失败")
ErrPasswordEncryptFailed = NewBusinessError("password_encrypt_failed", "密码加密失败")
// 资源不存在错误(其他资源错误已在资源相关错误部分定义)
ErrTokenNotFound = NewBusinessError("token_not_found", "Token不存在")
ErrUnauthorized = NewBusinessError("unauthorized", "未授权")
ErrTokenRefreshFailed = NewBusinessError("token_refresh_failed", "Token刷新失败")
ErrUserNotFound = NewBusinessError("user_not_found", "用户不存在")
// 权限保护错误
ErrAdminProtectedCannotDisable = NewBusinessError("admin_protected_cannot_disable", "受保护的管理员不能禁用")
ErrAdminCannotModifyRoles = NewBusinessError("admin_cannot_modify_roles", "不能修改管理员角色")
ErrAdminProtectedCannotDelete = NewBusinessError("admin_protected_cannot_delete", "受保护的管理员不能删除")
ErrAdminCannotDeleteSelf = NewBusinessError("admin_cannot_delete_self", "不能删除自己")
ErrRoleProtectedCannotModifySlug = NewBusinessError("role_protected_cannot_modify_slug", "受保护的角色不能修改标识")
ErrRoleProtectedCannotDisable = NewBusinessError("role_protected_cannot_disable", "受保护的角色不能禁用")
ErrRoleProtectedCannotDelete = NewBusinessError("role_protected_cannot_delete", "受保护的角色不能删除")
// 测试相关错误
ErrTraceTestWarning = NewBusinessError("trace_test_warning", "追踪测试警告")
ErrTraceTestError = NewBusinessError("trace_test_error", "追踪测试错误")
)
// BusinessError 业务错误类型
type BusinessError struct {
Code string
Message string
Err error
Params map[string]any // 用于存储动态参数(如余额值)
}
// NewBusinessError 创建新的业务错误
func NewBusinessError(code, message string) *BusinessError {
return &BusinessError{
Code: code,
Message: message,
}
}
// Error 实现 error 接口
func (e *BusinessError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Unwrap 返回包装的错误
func (e *BusinessError) Unwrap() error {
return e.Err
}
// WithError 包装底层错误
func (e *BusinessError) WithError(err error) *BusinessError {
e.Err = err
return e
}
// WithMessage 设置自定义消息
func (e *BusinessError) WithMessage(message string) *BusinessError {
e.Message = message
return e
}
// WithParams 设置动态参数
// 参数会在控制器中用于替换翻译后消息中的占位符
// 例如:翻译文件中有 "insufficient_balance": "余额不足,当前余额: {balance}"
// 使用 WithParams(map[string]any{"balance": 100}) 后,控制器会替换为 "余额不足,当前余额: 100.00"
func (e *BusinessError) WithParams(params map[string]any) *BusinessError {
if e.Params == nil {
e.Params = make(map[string]any)
}
for k, v := range params {
e.Params[k] = v
}
return e
}
// Is 检查错误是否匹配
func (e *BusinessError) Is(target error) bool {
if t, ok := target.(*BusinessError); ok {
return e.Code == t.Code
}
return stderrors.Is(e.Err, target)
}
// WrapError 包装错误并添加上下文信息
func WrapError(err error, code, message string) *BusinessError {
return &BusinessError{
Code: code,
Message: message,
Err: err,
}
}
// IsBusinessError 检查是否是业务错误
func IsBusinessError(err error) bool {
_, ok := err.(*BusinessError)
return ok
}
// GetBusinessError 获取业务错误
func GetBusinessError(err error) (*BusinessError, bool) {
be, ok := err.(*BusinessError)
return be, ok
}
// GetFormattedMessage 获取格式化后的消息(支持多语言和占位符替换)
// 自动处理翻译和占位符替换,简化控制器代码
// 使用示例:
//
// message := businessErr.GetFormattedMessage(ctx)
func (e *BusinessError) GetFormattedMessage(ctx http.Context) string {
// 1. 获取翻译后的消息
message := trans.Get(ctx, e.Code)
// 2. 如果翻译不存在(返回的是 key),使用默认消息
if message == e.Code {
message = e.Message
}
// 3. 如果有参数,替换占位符
if len(e.Params) > 0 {
message = e.replacePlaceholders(message)
}
return message
}
// replacePlaceholders 替换消息中的占位符
// 支持 {key} 和 ${key} 格式
func (e *BusinessError) replacePlaceholders(message string) string {
result := message
for key, value := range e.Params {
// 支持 {key} 和 ${key} 格式
placeholder1 := fmt.Sprintf("{%s}", key)
placeholder2 := fmt.Sprintf("${%s}", key)
// 格式化值
valueStr := e.formatValue(value)
// 替换占位符
result = strings.ReplaceAll(result, placeholder1, valueStr)
result = strings.ReplaceAll(result, placeholder2, valueStr)
}
return result
}
// formatValue 格式化参数值
// float64/float32 保留2位小数,整数保持原样,其他类型使用 %v
func (e *BusinessError) formatValue(value any) string {
switch v := value.(type) {
case float64:
return fmt.Sprintf("%.2f", v)
case float32:
return fmt.Sprintf("%.2f", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
+14
View File
@@ -0,0 +1,14 @@
package events
import "github.com/goravel/framework/contracts/event"
type OrderCanceled struct {
}
func NewOrderCanceled() *OrderCanceled {
return &OrderCanceled{}
}
func (receiver *OrderCanceled) Handle(args []event.Arg) ([]event.Arg, error) {
return args, nil
}
+12
View File
@@ -0,0 +1,12 @@
package events
import "github.com/goravel/framework/contracts/event"
// OrderCreated 订单创建事件
type OrderCreated struct {
}
func (receiver *OrderCreated) Handle(args []event.Arg) ([]event.Arg, error) {
// 可以在这里对订单数据进行加工
return args, nil
}
+14
View File
@@ -0,0 +1,14 @@
package events
import "github.com/goravel/framework/contracts/event"
type OrderShipped struct {
}
func NewOrderShipped() *OrderShipped {
return &OrderShipped{}
}
func (receiver *OrderShipped) Handle(args []event.Arg) ([]event.Arg, error) {
return args, nil
}
@@ -0,0 +1,730 @@
package admin
import (
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
)
type AdminController struct {
adminService services.AdminService
googleAuthenticatorService services.GoogleAuthenticatorService
}
// AdminExportRequest 导出管理员请求参数
type AdminExportRequest struct {
Username string `json:"username" form:"username" example:"admin"` // 用户名(模糊搜索)
Status string `json:"status" form:"status" example:"1"` // 状态:1-启用,0-禁用
RoleID string `json:"role_id" form:"role_id" example:"1"` // 角色ID
DepartmentID string `json:"department_id" form:"department_id" example:"1"` // 部门ID
Is2FABound string `json:"is_2fa_bound" form:"is_2fa_bound" example:"1"` // 是否绑定2FA:1-已绑定,0-未绑定
StartTime string `json:"start_time" form:"start_time" example:"2024-01-01 00:00:00"` // 开始时间
EndTime string `json:"end_time" form:"end_time" example:"2024-12-31 23:59:59"` // 结束时间
OrderBy string `json:"order_by" form:"order_by" example:"created_at:desc"` // 排序
}
// AdminResponse 管理员响应数据
type AdminResponse struct {
ID uint `json:"id" example:"1"` // 管理员ID
Username string `json:"username" example:"admin"` // 用户名
Nickname string `json:"nickname" example:"管理员"` // 昵称
Avatar string `json:"avatar" example:""` // 头像
Email string `json:"email" example:"admin@example.com"` // 邮箱
Phone string `json:"phone" example:"13800138000"` // 手机号
Status uint8 `json:"status" example:"1"` // 状态:1-启用,0-禁用
Is2FABound bool `json:"is_2fa_bound" example:"true"` // 是否绑定2FA
DepartmentID uint `json:"department_id" example:"1"` // 部门ID
Department map[string]any `json:"department"` // 部门信息
Roles []map[string]any `json:"roles"` // 角色列表
CreatedAt string `json:"created_at" example:"2024-01-01 00:00:00"` // 创建时间
UpdatedAt string `json:"updated_at" example:"2024-01-01 00:00:00"` // 更新时间
}
// PaginatedAdminResponse 分页管理员响应
type PaginatedAdminResponse struct {
Code int `json:"code" example:"200"` // 状态码
Message string `json:"message" example:"获取成功"` // 消息
Data []AdminResponse `json:"data"` // 数据列表
Total int64 `json:"total" example:"100"` // 总数
Page int `json:"page" example:"1"` // 当前页码
PageSize int `json:"page_size" example:"10"` // 每页数量
TraceID string `json:"trace_id,omitempty" example:"abc123"` // 追踪ID
}
// AdminDetailResponse 管理员详情响应
type AdminDetailResponse struct {
Code int `json:"code" example:"200"` // 状态码
Message string `json:"message" example:"获取成功"` // 消息
Data AdminResponse `json:"data"` // 管理员数据
TraceID string `json:"trace_id,omitempty" example:"abc123"` // 追踪ID
}
func NewAdminController() *AdminController {
return &AdminController{
adminService: services.NewAdminServiceImpl(),
googleAuthenticatorService: services.NewGoogleAuthenticatorServiceImpl(),
}
}
// findAdminByID 根据ID查找管理员,如果不存在则返回错误响应
// withDepartment 为 true 时会预加载 Department 关联
// withRoles 为 true 时会预加载 Roles 关联
func (r *AdminController) findAdminByID(ctx http.Context, id uint, withDepartment bool, withRoles bool) (*models.Admin, http.Response) {
admin, err := r.adminService.GetByID(id, withDepartment, withRoles)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
return admin, nil
}
// buildFilters 构建查询过滤器(列表和导出共用)
// 同时支持查询参数(GET)和请求体参数(POST)
func (r *AdminController) buildFilters(ctx http.Context) services.AdminFilters {
// 优先从请求体读取,如果没有则从查询参数读取(兼容 GET 和 POST)
username := ctx.Request().Input("username", ctx.Request().Query("username", ""))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
roleID := ctx.Request().Input("role_id", ctx.Request().Query("role_id", ""))
departmentID := ctx.Request().Input("department_id", ctx.Request().Query("department_id", ""))
is2FABound := ctx.Request().Input("is_2fa_bound", ctx.Request().Query("is_2fa_bound", ""))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 时间参数同时支持从请求体和查询参数读取,并转换为 UTC
startTimeStr := ctx.Request().Input("start_time", ctx.Request().Query("start_time", ""))
endTimeStr := ctx.Request().Input("end_time", ctx.Request().Query("end_time", ""))
startTime := ""
endTime := ""
if startTimeStr != "" {
startTime = helpers.ConvertTimeToUTC(ctx, startTimeStr)
}
if endTimeStr != "" {
endTime = helpers.ConvertTimeToUTC(ctx, endTimeStr)
}
return services.AdminFilters{
Username: username,
Status: status,
RoleID: roleID,
DepartmentID: departmentID,
Is2FABound: is2FABound,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 管理员列表
// @Summary 获取管理员列表
// @Description 分页获取管理员列表,支持按用户名、状态、角色、部门等条件筛选
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param username query string false "用户名(模糊搜索)"
// @Param status query string false "状态:1-启用,0-禁用"
// @Param role_id query string false "角色ID"
// @Param department_id query string false "部门ID"
// @Param start_time query string false "开始时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param end_time query string false "结束时间(格式:YYYY-MM-DD HH:mm:ss"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} PaginatedAdminResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins [get]
// @Security BearerAuth
func (r *AdminController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
admins, total, err := r.adminService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
// 获取超级管理员ID
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
// 转换数据格式
adminList := make([]http.Json, len(admins))
for i, admin := range admins {
isBound := admin.GoogleSecret != ""
adminList[i] = http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"status": admin.Status,
"is_2fa_bound": isBound,
"is_super_admin": admin.ID == superAdminID,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"created_at": admin.CreatedAt,
"updated_at": admin.UpdatedAt,
}
}
return response.Success(ctx, http.Json{
"list": adminList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 管理员详情
// @Summary 获取管理员详情
// @Description 根据ID获取管理员详细信息,包括部门、角色等关联信息
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID"
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [get]
// @Security BearerAuth
func (r *AdminController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
admin, resp := r.findAdminByID(ctx, id, true, true) // 预加载 Department 和 Roles 关联
if resp != nil {
return resp
}
// 获取超级管理员ID
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"status": admin.Status,
"is_super_admin": admin.ID == superAdminID, // 标识是否是超级管理员
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"created_at": admin.CreatedAt,
"updated_at": admin.UpdatedAt,
},
})
}
// Store 创建管理员
// @Summary 创建管理员
// @Description 创建新的管理员账号,支持设置部门、角色等信息
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param username body string true "用户名(必填)" example(admin)
// @Param password body string true "密码(必填)" example(123456)
// @Param nickname body string false "昵称" example(管理员)
// @Param email body string false "邮箱" example(admin@example.com)
// @Param phone body string false "手机号" example(13800138000)
// @Param department_id body int false "部门ID" example(1)
// @Param status body int false "状态:1-启用,0-禁用" example(1)
// @Param role_ids body []int false "角色ID列表" example([1,2])
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误或用户名已存在"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins [post]
// @Security BearerAuth
func (r *AdminController) Store(ctx http.Context) http.Response {
// 使用请求验证
var adminCreate adminrequests.AdminCreate
errors, err := ctx.Request().ValidateRequest(&adminCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查用户名是否已存在
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", adminCreate.Username).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrUsernameExists.Code)
}
// 加密密码
hashedPassword, err := facades.Hash().Make(adminCreate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
now := carbon.Now()
adminData := map[string]any{
"username": adminCreate.Username,
"password": hashedPassword,
"nickname": adminCreate.Nickname,
"avatar": "",
"email": adminCreate.Email,
"phone": adminCreate.Phone,
"department_id": adminCreate.DepartmentID,
"status": adminCreate.Status,
"created_at": now,
"updated_at": now,
}
if err := facades.Orm().Query().Table("admins").Create(adminData); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"username": adminCreate.Username,
})
}
var admin models.Admin
if err := facades.Orm().Query().Where("username", adminCreate.Username).FirstOrFail(&admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"username": adminCreate.Username,
})
}
if len(adminCreate.RoleIDs) > 0 {
if err := r.adminService.SyncRoles(&admin, adminCreate.RoleIDs); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
"role_ids": adminCreate.RoleIDs,
})
}
}
return response.Success(ctx, http.Json{
"admin": admin,
})
}
// Update 更新管理员
// @Summary 更新管理员信息
// @Description 更新管理员的基本信息,包括昵称、邮箱、手机号、部门、状态、角色等。受保护的管理员不能禁用。
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID" example(1)
// @Param nickname body string false "昵称" example(管理员)
// @Param email body string false "邮箱" example(admin@example.com)
// @Param phone body string false "手机号" example(13800138000)
// @Param department_id body int false "部门ID" example(1)
// @Param status body string false "状态:1-启用,0-禁用" example(1)
// @Param password body string false "密码(可选,不传则不更新)" example(123456)
// @Param role_ids body []int false "角色ID列表" example([1,2])
// @Success 200 {object} AdminDetailResponse
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限或受保护管理员不能禁用"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [put]
// @Security BearerAuth
func (r *AdminController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 加载管理员的当前角色,用于后续比较角色是否改变
admin, resp := r.findAdminByID(ctx, id, false, true) // 预加载 Roles 关联
if resp != nil {
return resp
}
allProtectedIDs := r.getAllProtectedAdminIDs()
isProtected := allProtectedIDs[id]
// 使用请求验证
var adminUpdate adminrequests.AdminUpdate
errors, err := ctx.Request().ValidateRequest(&adminUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["nickname"]; exists {
admin.Nickname = adminUpdate.Nickname
}
if _, exists := allInputs["email"]; exists {
admin.Email = adminUpdate.Email
}
if _, exists := allInputs["phone"]; exists {
admin.Phone = adminUpdate.Phone
}
if _, exists := allInputs["department_id"]; exists {
admin.DepartmentID = adminUpdate.DepartmentID
}
if _, exists := allInputs["status"]; exists {
// 请求中提供了 status 字段,使用验证后的值
// 检查是否是超级管理员或受保护的管理员
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
isSuperAdmin := admin.ID == superAdminID
if (isProtected || isSuperAdmin) && adminUpdate.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminProtectedCannotDisable.Code)
}
admin.Status = adminUpdate.Status
}
if adminUpdate.Password != "" {
hashedPassword, err := facades.Hash().Make(adminUpdate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
}
if err := r.adminService.Update(admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
})
}
// 检查是否尝试修改 admin 用户的角色
if _, exists := allInputs["role_ids"]; exists {
// 获取当前管理员的角色ID列表(去重)
currentRoleIDSet := make(map[uint]bool)
var currentRoleIDs []uint
for _, role := range admin.Roles {
if !currentRoleIDSet[role.ID] {
currentRoleIDSet[role.ID] = true
currentRoleIDs = append(currentRoleIDs, role.ID)
}
}
// 对传入的角色ID进行去重
newRoleIDSet := make(map[uint]bool)
var deduplicatedRoleIDs []uint
for _, roleID := range adminUpdate.RoleIDs {
if !newRoleIDSet[roleID] {
newRoleIDSet[roleID] = true
deduplicatedRoleIDs = append(deduplicatedRoleIDs, roleID)
}
}
// 比较新的角色ID列表和当前的角色ID列表
// 只有当角色ID真正改变时才阻止修改
roleIDsChanged := false
// 如果长度不同,肯定改变了
if len(deduplicatedRoleIDs) != len(currentRoleIDs) {
roleIDsChanged = true
} else {
// 长度相同,需要检查内容是否完全一致(忽略顺序)
// 检查新的角色ID是否都在当前角色ID中
for _, newRoleID := range deduplicatedRoleIDs {
if !currentRoleIDSet[newRoleID] {
roleIDsChanged = true
break
}
}
// 如果所有新角色ID都在当前角色ID中,且长度相同,说明没有改变
}
// 检查是否是超级管理员(通过配置的ID判断,不依赖用户名)
superAdminID := cast.ToUint(facades.Config().GetInt("admin.super_admin_id", 1))
isSuperAdmin := admin.ID == superAdminID
// 只有当角色ID真正改变时才阻止修改
// 如果角色ID没有改变,允许调用 SyncRoles 来清理重复数据
if roleIDsChanged && isSuperAdmin {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminCannotModifyRoles.Code)
}
// 即使角色ID没有改变,也调用 SyncRoles 来清理重复数据
// 使用去重后的角色ID列表
if err := r.adminService.SyncRoles(admin, deduplicatedRoleIDs); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
"role_ids": deduplicatedRoleIDs,
})
}
}
return response.Success(ctx, http.Json{
"admin": *admin,
})
}
// Destroy 删除管理员
// @Summary 删除管理员
// @Description 删除指定的管理员账号。受保护的管理员和当前登录的管理员不能删除。
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "管理员ID"
// @Success 200 {object} map[string]any "删除成功"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限、受保护管理员不能删除或不能删除自己"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id} [delete]
// @Security BearerAuth
func (r *AdminController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
allProtectedIDs := r.getAllProtectedAdminIDs()
if allProtectedIDs[id] {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminProtectedCannotDelete.Code)
}
adminValue := ctx.Value("admin")
if adminValue != nil {
var currentAdmin models.Admin
if admin, ok := adminValue.(models.Admin); ok {
currentAdmin = admin
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
currentAdmin = *adminPtr
}
if currentAdmin.ID > 0 && currentAdmin.ID == id {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAdminCannotDeleteSelf.Code)
}
}
admin, resp := r.findAdminByID(ctx, id, false, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(admin); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx)
}
// UnbindGoogleAuthenticator 管理员解绑其他管理员的谷歌验证码
// @Summary 解绑管理员的谷歌验证码
// @Description 管理员可以解绑其他管理员的谷歌验证码,需要当前管理员已绑定谷歌验证码并输入验证码确认
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param id path int true "要解绑的管理员ID"
// @Param code body string true "当前管理员的谷歌验证码"
// @Success 200 {object} map[string]any "解绑成功"
// @Failure 400 {object} map[string]any "参数错误或验证码错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限或当前管理员未绑定谷歌验证码"
// @Failure 404 {object} map[string]any "管理员不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/{id}/unbind-google-auth [post]
// @Security BearerAuth
func (r *AdminController) UnbindGoogleAuthenticator(ctx http.Context) http.Response {
// 获取要解绑的管理员ID
targetAdminID := helpers.GetUintRoute(ctx, "id")
if targetAdminID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
// 检查目标管理员是否存在
var targetAdmin models.Admin
if err := facades.Orm().Query().Where("id", targetAdminID).FirstOrFail(&targetAdmin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
// 从context中获取当前管理员信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var currentAdmin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
currentAdmin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
currentAdmin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查当前管理员是否已绑定谷歌验证码
isBound, err := r.googleAuthenticatorService.IsBound(currentAdmin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": currentAdmin.ID,
})
}
if !isBound {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 需要验证码确认
code := ctx.Request().Input("code")
if code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrCodeRequired.Code)
}
// 获取当前管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(currentAdmin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"admin_id": currentAdmin.ID,
})
}
if secret == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 验证当前管理员的验证码
if !r.googleAuthenticatorService.Verify(secret, code) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
// 检查目标管理员是否已绑定
targetIsBound, err := r.googleAuthenticatorService.IsBound(targetAdminID)
if err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"target_admin_id": targetAdminID,
})
}
if !targetIsBound {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 解绑目标管理员的谷歌验证码
if err := r.googleAuthenticatorService.Unbind(targetAdminID); err != nil {
return response.ErrorWithLog(ctx, "admin", err, map[string]any{
"target_admin_id": targetAdminID,
"current_admin_id": currentAdmin.ID,
})
}
return response.Success(ctx, "unbind_success")
}
// getAllProtectedAdminIDs 获取所有受保护的管理员ID(用于删除等操作)
func (r *AdminController) getAllProtectedAdminIDs() map[uint]bool {
return r.adminService.GetProtectedAdminIDs()
}
// Export 导出管理员列表
// @Summary 导出管理员列表
// @Description 根据筛选条件导出管理员列表为CSV文件,支持与列表查询相同的筛选条件
// @Tags 管理员管理
// @Accept json
// @Produce json
// @Param request body AdminExportRequest false "导出筛选条件(可选)"
// @Success 200 {object} map[string]any "导出成功,返回文件下载信息"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/admins/export [post]
// @Security BearerAuth
func (r *AdminController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击:使用框架自带的原子锁(锁会在10秒后自动过期,防止短时间内重复请求)
lockKey := fmt.Sprintf("export:admins:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
// 尝试获取锁,如果获取失败则返回错误
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 同步导出:锁会在 Redis 中自动过期(10秒),不需要手动释放
filters := r.buildFilters(ctx)
// 导出时获取所有数据,不分页
admins, err := r.adminService.GetAllAdminsForExport(filters)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
headers := []string{
"export_header_id",
"export_header_username",
"export_header_nickname",
"export_header_email",
"export_header_phone",
"export_header_status",
"export_header_department",
"export_header_roles",
"export_header_created_at",
"export_header_updated_at",
}
timezone := helpers.GetCurrentTimezone(ctx)
var data [][]string
for _, admin := range admins {
statusText := trans.Get(ctx, "export_status_disabled")
if admin.Status == 1 {
statusText = trans.Get(ctx, "export_status_enabled")
}
// 部门名称
departmentName := ""
if admin.Department.ID > 0 {
departmentName = admin.Department.Name
}
// 角色名称(多个角色用逗号分隔)
roleNames := ""
if len(admin.Roles) > 0 {
for i, role := range admin.Roles {
if i > 0 {
roleNames += ", "
}
roleNames += role.Name
}
}
// 时间格式化
createdAt := helpers.FormatCarbonWithTimezone(admin.CreatedAt, timezone)
updatedAt := helpers.FormatCarbonWithTimezone(admin.UpdatedAt, timezone)
row := []string{
cast.ToString(admin.ID),
admin.Username,
admin.Nickname,
admin.Email,
admin.Phone,
statusText,
departmentName,
roleNames,
createdAt,
updatedAt,
}
data = append(data, row)
}
// 在 context 中设置导出类型,供 ExportService 使用
ctx.WithValue("export_type", models.ExportTypeAdmins)
return response.Export(ctx, "export_success", headers, data, "admins")
}
@@ -0,0 +1,136 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/services"
)
type ArticleController struct {
ArticleService services.ArticleService
}
func NewArticleController() *ArticleController {
return &ArticleController{
ArticleService: services.NewArticleService(),
}
}
// Index Article列表
func (c *ArticleController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
filters := services.ArticleFilters{
Name: name,
Status: status,
}
list, total, err := c.ArticleService.GetList(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": list,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show Article详情
func (c *ArticleController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
item, err := c.ArticleService.GetByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Store 创建Article
func (c *ArticleController) Store(ctx http.Context) http.Response {
var req adminrequests.ArticleCreate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.ArticleService.Create(&req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Update 更新Article
func (c *ArticleController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
var req adminrequests.ArticleUpdate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
item, err := c.ArticleService.Update(id, &req)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"article": item,
})
}
// Destroy 删除Article
func (c *ArticleController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if err := c.ArticleService.Delete(id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "delete_success", http.Json{})
}
@@ -0,0 +1,600 @@
package admin
import (
"fmt"
"mime"
"path/filepath"
"strconv"
"time"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
type AttachmentController struct {
attachmentService services.AttachmentService
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{}
}
// Index 附件列表
func (r *AttachmentController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
attachmentService := services.NewAttachmentService(ctx)
attachments, total, err := attachmentService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err)
}
type AttachmentWithURL struct {
models.Attachment
FileURL string `json:"file_url"`
}
var resultWithURL []AttachmentWithURL
for _, a := range attachments {
fileURL := attachmentService.GetFileURL(&a)
resultWithURL = append(resultWithURL, AttachmentWithURL{
Attachment: a,
FileURL: fileURL,
})
}
return response.Success(ctx, http.Json{
"list": resultWithURL,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// buildFilters 构建查询过滤器
func (r *AttachmentController) buildFilters(ctx http.Context) services.AttachmentFilters {
adminID := ctx.Request().Query("admin_id", "")
filename := ctx.Request().Query("filename", "")
displayName := ctx.Request().Query("display_name", "")
fileType := ctx.Request().Query("file_type", "")
extension := ctx.Request().Query("extension", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.AttachmentFilters{
AdminID: adminID,
Filename: filename,
DisplayName: displayName,
FileType: fileType,
Extension: extension,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Upload 普通文件上传(小文件)
func (r *AttachmentController) Upload(ctx http.Context) http.Response {
file, err := ctx.Request().File("file")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFileRequired.Code)
}
filename := file.GetClientOriginalName()
if filename == "" {
filename = "uploaded_file"
}
// 读取文件内容:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置,PutFile 返回保存后的路径
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 读取文件内容
fileDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
fileData := []byte(fileDataStr)
// 获取MIME类型:直接根据文件扩展名推断(multipart/form-data 的 Content-Type 不是文件本身的 MIME 类型)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.UploadFile(fileData, filename, mimeType)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "upload_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
}
// ChunkUpload 大文件分片上传统一接口
// 通过 action 参数区分不同操作:init(初始化)、upload(上传分片)、merge(合并分片)、progress(获取进度)
func (r *AttachmentController) ChunkUpload(ctx http.Context) http.Response {
action := ctx.Request().Input("action", "")
if action == "" {
// 兼容 GET 请求获取进度
action = ctx.Request().Query("action", "progress")
}
attachmentService := services.NewAttachmentService(ctx)
switch action {
case "init":
// 初始化分片上传
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalSizeStr := ctx.Request().Input("total_size", "0")
totalSize, err := strconv.ParseInt(totalSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
if totalSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
chunkSizeStr := ctx.Request().Input("chunk_size", "0")
chunkSize, err := strconv.ParseInt(chunkSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
if chunkSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 验证分片数量计算的合理性
expectedChunks := int((totalSize + chunkSize - 1) / chunkSize) // 向上取整
if totalChunks != expectedChunks {
// 不返回错误,使用客户端提供的值(可能是由于浮点数计算差异)
}
chunkID, err := attachmentService.InitChunkUpload(filename, totalSize, chunkSize, totalChunks)
if err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 返回详细的错误信息
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
"total_size": totalSize,
"chunk_size": chunkSize,
"total_chunks": totalChunks,
})
}
return response.Success(ctx, "init_chunk_upload_success", http.Json{
"chunk_id": chunkID,
})
case "upload":
// 上传分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
chunkIndex, err := strconv.Atoi(ctx.Request().Input("chunk_index", "-1"))
if err != nil || chunkIndex < 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkIndex.Code)
}
file, err := ctx.Request().File("chunk")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkFileRequired.Code)
}
// 读取分片数据:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 读取文件内容
chunkDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
chunkData := []byte(chunkDataStr)
if err := attachmentService.UploadChunk(chunkID, chunkIndex, chunkData); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
return response.Success(ctx, "upload_chunk_success")
case "merge":
// 合并分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 获取MIME类型:直接根据文件扩展名推断(前端传递的 mime_type 可能不准确)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachment, err := attachmentService.MergeChunks(chunkID, filename, mimeType, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"filename": filename,
"total_chunks": totalChunks,
})
}
// 合并成功后,记录日志(仅 Debug 模式)
facades.Log().Debugf("Successfully merged chunks for chunkID %s, filename: %s, total_chunks: %d", chunkID, filename, totalChunks)
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "merge_chunks_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
case "progress":
// 获取分片上传进度
chunkID := ctx.Request().Query("chunk_id", "")
if chunkID == "" {
chunkID = ctx.Request().Input("chunk_id", "")
}
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
totalChunks, err := strconv.Atoi(ctx.Request().Query("total_chunks", "0"))
if totalChunks == 0 {
totalChunks, err = strconv.Atoi(ctx.Request().Input("total_chunks", "0"))
}
if err != nil || totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
progress, err := attachmentService.GetChunkProgress(chunkID, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
})
}
return response.Success(ctx, progress)
default:
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidAction.Code)
}
}
// Download 下载附件文件
func (r *AttachmentController) Download(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
filename := attachment.Filename
if filename == "" {
filename = attachment.Path
}
// 根据MIME类型设置 Content-Type
contentType := attachment.MimeType
if contentType == "" {
contentType = "application/octet-stream"
}
// 设置响应头,使用链式调用确保顺序正确
response := ctx.Response().
Header("Content-Type", contentType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)).
Header("Cache-Control", "no-cache, no-store, must-revalidate").
Header("Pragma", "no-cache").
Header("Expires", "0")
return response.String(http.StatusOK, content)
}
// Preview 预览文件(图片、视频、文档)
func (r *AttachmentController) Preview(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
// 这样可以减少服务器带宽和内存占用,提高性能
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
mimeType := attachment.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
// 设置响应头
response := ctx.Response().
Header("Content-Type", mimeType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Cache-Control", "public, max-age=3600")
// 对于图片和视频,支持范围请求(Range request
if attachment.FileType == "image" || attachment.FileType == "video" {
response = response.Header("Accept-Ranges", "bytes")
}
return response.String(http.StatusOK, content)
}
// Destroy 删除附件
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if err := attachmentService.DeleteFile(attachment); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": attachment.ID,
})
}
return response.Success(ctx)
}
type AttachmentBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除附件
func (r *AttachmentController) BatchDestroy(ctx http.Context) http.Response {
var req AttachmentBatchDestroyRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 查询要删除的附件
attachmentService := services.NewAttachmentService(ctx)
attachments, err := attachmentService.GetByIDs(ids)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"ids": ids,
})
}
// 删除文件和记录
for _, attachment := range attachments {
if err := attachmentService.DeleteFile(&attachment); err != nil {
// 批量删除中单个文件删除失败只记录日志,不影响主流程
errorlog.RecordHTTP(ctx, "attachment", "Failed to delete attachment in batch delete", map[string]any{
"error": err.Error(),
"attachId": attachment.ID,
}, "Delete attachment in batch delete error: %v", err)
}
}
return response.Success(ctx)
}
// UpdateDisplayName 更新显示名称
func (r *AttachmentController) UpdateDisplayName(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
displayName := ctx.Request().Input("display_name", "")
if err := attachmentService.UpdateDisplayName(id, displayName); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": id,
})
}
// 重新获取更新后的附件
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
return response.Success(ctx, http.Json{
"attachment": attachment,
})
}
@@ -0,0 +1,756 @@
package admin
import (
"encoding/json"
"strconv"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type AuthController struct {
authService services.AuthService
captchaService services.CaptchaService
googleAuthenticatorService services.GoogleAuthenticatorService
}
func NewAuthController() *AuthController {
adminService := services.NewAdminServiceImpl()
tokenService := services.NewTokenServiceImpl()
authService := services.NewAuthServiceImpl(adminService, tokenService)
return &AuthController{
authService: authService,
captchaService: services.NewCaptchaServiceImpl(),
googleAuthenticatorService: services.NewGoogleAuthenticatorServiceImpl(),
}
}
// getLoginRequestData 获取登录请求数据(排除敏感信息)
func (r *AuthController) getLoginRequestData(ctx http.Context) string {
inputs := make(map[string]any)
allInputs := ctx.Request().All()
for key, value := range allInputs {
// 使用工具函数检查是否是敏感字段
if utils.IsSensitiveField(key) {
inputs[key] = "***"
} else {
inputs[key] = value
}
}
if data, err := json.Marshal(inputs); err == nil {
return string(data)
}
return ""
}
// Login 管理员登录
func (r *AuthController) Login(ctx http.Context) http.Response {
var loginRequest admin.Login
errors, err := ctx.Request().ValidateRequest(&loginRequest)
if err != nil {
// 记录验证失败日志
requestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "validation_failed", requestData)
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
// 记录验证失败日志
requestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "validation_failed", requestData)
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 获取请求数据用于日志记录
requestData := r.getLoginRequestData(ctx)
// 先验证用户名是否存在
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", loginRequest.Username).Exists()
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if !exists {
// 记录用户名不存在日志
r.authService.RecordLoginLog(ctx, 0, loginRequest.Username, 0, "username_not_found", requestData)
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 获取管理员信息
var admin models.Admin
if err := facades.Orm().Query().Where("username", loginRequest.Username).FirstOrFail(&admin); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if admin.Status == 0 {
// 记录账号禁用日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "account_disabled", requestData)
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAccountDisabled.Code)
}
// 验证密码
if !facades.Hash().Check(loginRequest.Password, admin.Password) {
// 记录登录失败日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "password_error", requestData)
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 检查是否绑定了谷歌验证码
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 如果绑定了谷歌验证码,验证谷歌验证码
if isBound {
googleCode := loginRequest.GoogleCode
if googleCode == "" {
// 记录谷歌验证码缺失日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "google_code_required", requestData)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeRequired.Code)
}
// 获取管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 验证谷歌验证码
if !r.googleAuthenticatorService.Verify(secret, googleCode) {
// 记录登录失败日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, "google_code_error", requestData)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
} else {
// 如果没有绑定谷歌验证码,验证图形验证码(如果启用了)
if r.captchaService.Enabled() {
captchaID := ctx.Request().Input("captcha_id")
captchaAnswer := ctx.Request().Input("captcha_answer")
if ok, messageKey := r.captchaService.Verify(captchaID, captchaAnswer); !ok {
if messageKey == "" {
messageKey = "captcha_invalid"
}
// 记录验证码错误日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 0, messageKey, requestData)
return response.Error(ctx, http.StatusBadRequest, messageKey)
}
}
}
// 验证通过,生成token并完成登录
// 获取浏览器和操作系统信息
browser, os := helpers.GetBrowserAndOS(ctx)
// 获取真实IP地址
ip := helpers.GetRealIP(ctx)
// 生成token
var expiresAt *time.Time
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
if ttl > 0 {
exp := time.Now().Add(time.Duration(ttl) * time.Minute)
expiresAt = &exp
}
tokenService := services.NewTokenServiceImpl()
plainToken, _, err := tokenService.CreateToken("admin", admin.ID, "admin-token", expiresAt, browser, ip, os, "")
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
token := plainToken
// 记录登录成功日志
r.authService.RecordLoginLog(ctx, admin.ID, loginRequest.Username, 1, "login_success", requestData)
// 更新最后登录时间(ORM会自动更新UpdatedAt
facades.Orm().Query().Save(&admin)
return response.SuccessWithHeader(ctx, "login_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
},
})
}
// Captcha 获取登录验证码
func (r *AuthController) Captcha(ctx http.Context) http.Response {
enabled := r.captchaService.Enabled()
captchaData := http.Json{
"enabled": enabled,
}
if enabled {
captchaID, image, err := r.captchaService.Generate()
if err != nil {
return response.ErrorWithLog(ctx, "captcha", err)
}
captchaData["captcha_id"] = captchaID
captchaData["captcha_image"] = image
// captchaData["captcha_image"] = "data:image/png;base64," + image
}
return response.Success(ctx, http.Json{
"captcha": captchaData,
})
}
// Info 获取当前登录管理员信息
func (r *AuthController) Info(ctx http.Context) http.Response {
admin, permissions, menus, err := r.authService.GetAdminInfo(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取配置:是否显示无权限的按钮
showButtonsWithoutPermission := facades.Config().GetBool("admin.show_buttons_without_permission", false)
// 检查是否是超级管理员
const SuperAdminRoleSlug = "super-admin"
isSuperAdmin := false
for _, role := range admin.Roles {
if role.Slug == SuperAdminRoleSlug && role.Status == 1 {
isSuperAdmin = true
break
}
}
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
"permissions": permissions,
"menus": menus,
"is_super_admin": isSuperAdmin,
},
"config": http.Json{
"show_buttons_without_permission": showButtonsWithoutPermission,
"monitor_hidden": facades.Config().GetString("admin.monitor_hidden", ""),
},
})
}
// UpdateProfile 更新个人信息
func (r *AuthController) UpdateProfile(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
// 尝试值类型
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
// 尝试指针类型
if adminPtr == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 重新查询admin以确保获取最新数据
if err := facades.Orm().Query().Where("id", admin.ID).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
nickname := ctx.Request().Input("nickname")
email := ctx.Request().Input("email")
phone := ctx.Request().Input("phone")
avatar := ctx.Request().Input("avatar")
if nickname != "" {
admin.Nickname = nickname
}
if email != "" {
admin.Email = email
}
if phone != "" {
admin.Phone = phone
}
if avatar != "" {
admin.Avatar = avatar
}
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 重新加载关联数据(确保部门和角色被正确加载)
var adminWithRelations models.Admin
if err := facades.Orm().Query().With("Department").With("Roles").Where("id", admin.ID).FirstOrFail(&adminWithRelations); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
admin = adminWithRelations
return response.Success(ctx, http.Json{
"admin": http.Json{
"id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"email": admin.Email,
"phone": admin.Phone,
"department_id": admin.DepartmentID,
"department": admin.Department,
"roles": admin.Roles,
},
})
}
// Refresh 刷新Token
// 注意:此接口需要在JWT中间件之前调用,或者使用特殊的中间件处理
// 因为Refresh方法需要token过期但仍在刷新窗口内才能工作
func (r *AuthController) Refresh(ctx http.Context) http.Response {
// 从请求头获取token
token := ctx.Request().Header("Authorization", "")
if token == "" {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUnauthorized.Code)
}
// 移除Bearer前缀
token = str.Of(token).ChopStart("Bearer ").Trim().String()
// 先尝试解析token,如果token有效,直接重新生成(滑动过期)
if _, err := facades.Auth(ctx).Guard("admin").Parse(token); err == nil {
// Token有效,重新生成新token(延长过期时间)
if userID, err := facades.Auth(ctx).Guard("admin").ID(); err == nil {
if newToken, err := facades.Auth(ctx).Guard("admin").LoginUsingID(userID); err == nil {
return response.SuccessWithHeader(ctx, "token_refresh_success", "Authorization", "Bearer "+newToken, http.Json{
"token": newToken,
})
}
}
}
// 如果token已过期,尝试刷新(需要在刷新窗口内)
newToken, err := facades.Auth(ctx).Guard("admin").Refresh()
if err != nil {
// 刷新失败,返回错误
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrTokenRefreshFailed.Code)
}
// 刷新成功,返回新token
return response.SuccessWithHeader(ctx, "token_refresh_success", "Authorization", "Bearer "+newToken, http.Json{
"token": newToken,
})
}
// Heartbeat 心跳接口,用于更新用户的最后活跃时间
// JWT中间件会自动更新 last_used_at,这个接口只是确保用户在线状态
func (r *AuthController) Heartbeat(ctx http.Context) http.Response {
// JWT中间件已经更新了 last_used_at,这里只需要返回成功即可
return response.Success(ctx, "heartbeat_success")
}
// Logout 退出登录
func (r *AuthController) Logout(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue != nil {
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
if adminPtr != nil {
admin = *adminPtr
}
}
if admin.ID > 0 {
// 获取token
token := ctx.Request().Header("Authorization", "")
token = str.Of(token).ChopStart("Bearer ").Trim().String()
if token != "" {
// 删除token
tokenService := services.NewTokenServiceImpl()
_ = tokenService.DeleteToken(token)
}
// 记录退出日志
logoutRequestData := r.getLoginRequestData(ctx)
r.authService.RecordLoginLog(ctx, admin.ID, admin.Username, 1, "logout_success", logoutRequestData)
}
}
return response.Success(ctx, "logout_success")
}
// Tokens 获取当前用户的所有token列表
func (r *AuthController) Tokens(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取用户的所有token
tokenService := services.NewTokenServiceImpl()
tokens, err := tokenService.GetTokensByUser("admin", admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 获取当前使用的token
currentTokenValue := ctx.Value("token")
var currentTokenID uint
if currentTokenValue != nil {
if currentToken, ok := currentTokenValue.(models.PersonalAccessToken); ok {
currentTokenID = currentToken.ID
} else if currentTokenPtr, ok := currentTokenValue.(*models.PersonalAccessToken); ok {
if currentTokenPtr != nil {
currentTokenID = currentTokenPtr.ID
}
}
}
// 格式化token列表
var tokenList []http.Json
for _, token := range tokens {
tokenData := http.Json{
"id": token.ID,
"name": token.Name,
"last_used_at": token.LastUsedAt,
"expires_at": token.ExpiresAt,
"created_at": token.CreatedAt,
"is_current": token.ID == currentTokenID,
}
tokenList = append(tokenList, tokenData)
}
return response.Success(ctx, http.Json{
"tokens": tokenList,
})
}
// RevokeToken 删除指定的token(踢出指定设备)
func (r *AuthController) RevokeToken(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取要删除的token ID
tokenIDStr := ctx.Request().Route("id")
if tokenIDStr == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDRequired.Code)
}
tokenID, err := strconv.ParseUint(tokenIDStr, 10, 32)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTokenID.Code)
}
// 查询token是否存在且属于当前用户
var token models.PersonalAccessToken
if err := facades.Orm().Query().
Where("id", tokenID).
Where("tokenable_type", "admin").
Where("tokenable_id", admin.ID).
First(&token); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrTokenNotFound.Code)
}
// 删除token(直接通过ID删除,因为数据库中存储的是hash值,无法获取原始token)
_, err = facades.Orm().Query().Delete(&token)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"token_id": token.ID,
"admin_id": admin.ID,
})
}
return response.Success(ctx, "revoke_success")
}
// RevokeAllTokens 删除当前用户的所有token(踢出所有设备)
func (r *AuthController) RevokeAllTokens(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 删除用户的所有token
tokenService := services.NewTokenServiceImpl()
if err := tokenService.DeleteTokensByUser("admin", admin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "revoke_all_success")
}
// KickOutUser 踢出指定用户的所有token(管理员操作)
func (r *AuthController) KickOutUser(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 获取要踢出的用户ID
userIDStr := ctx.Request().Route("id")
if userIDStr == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrUserIDRequired.Code)
}
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidUserID.Code)
}
// 查询用户是否存在
var targetAdmin models.Admin
if err := facades.Orm().Query().Where("id", userID).FirstOrFail(&targetAdmin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrUserNotFound.Code)
}
// 删除用户的所有token
tokenService := services.NewTokenServiceImpl()
if err := tokenService.DeleteTokensByUser("admin", targetAdmin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"target_user_id": targetAdmin.ID,
"operator_id": admin.ID,
})
}
return response.Success(ctx, "kick_out_success")
}
// GetGoogleAuthenticatorQRCode 获取谷歌验证码二维码(用于绑定)
func (r *AuthController) GetGoogleAuthenticatorQRCode(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查是否已经绑定
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
if isBound {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorAlreadyBound.Code)
}
// 生成密钥和二维码
accountName := admin.Username
if admin.Email != "" {
accountName = admin.Email
}
secret, qrCodeURL, err := r.googleAuthenticatorService.GenerateSecret(accountName)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
// 生成二维码图片
qrCodeImage, err := r.googleAuthenticatorService.GenerateQRCodeImage(accountName, secret)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, http.Json{
"secret": secret,
"qr_code_url": qrCodeURL,
"qr_code_image": qrCodeImage,
})
}
// BindGoogleAuthenticator 绑定谷歌验证码
func (r *AuthController) BindGoogleAuthenticator(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
secret := ctx.Request().Input("secret")
code := ctx.Request().Input("code")
if secret == "" || code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrSecretAndCodeRequired.Code)
}
// 绑定谷歌验证码
if err := r.googleAuthenticatorService.Bind(admin.ID, secret, code); err != nil {
if err.Error() == "invalid_code" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "bind_success")
}
// UnbindGoogleAuthenticator 解绑谷歌验证码
func (r *AuthController) UnbindGoogleAuthenticator(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 需要验证码确认
code := ctx.Request().Input("code")
if code == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrCodeRequired.Code)
}
// 获取管理员的密钥
secret, err := r.googleAuthenticatorService.GetSecret(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
if secret == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleAuthenticatorNotBound.Code)
}
// 验证验证码
if !r.googleAuthenticatorService.Verify(secret, code) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrGoogleCodeInvalid.Code)
}
// 解绑谷歌验证码
if err := r.googleAuthenticatorService.Unbind(admin.ID); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "unbind_success")
}
// GetGoogleAuthenticatorStatus 获取谷歌验证码绑定状态
func (r *AuthController) GetGoogleAuthenticatorStatus(ctx http.Context) http.Response {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 检查是否绑定
isBound, err := r.googleAuthenticatorService.IsBound(admin.ID)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, http.Json{
"is_bound": isBound,
})
}
@@ -0,0 +1,188 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type BlacklistController struct {
blacklistService services.BlacklistService
}
func NewBlacklistController() *BlacklistController {
return &BlacklistController{
blacklistService: services.NewBlacklistService(),
}
}
// findBlacklistByID 根据ID查找黑名单,如果不存在则返回错误响应
func (r *BlacklistController) findBlacklistByID(ctx http.Context, id uint) (*models.Blacklist, http.Response) {
blacklist, err := r.blacklistService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
return blacklist, nil
}
// buildFilters 构建查询过滤器
func (r *BlacklistController) buildFilters(ctx http.Context) services.BlacklistFilters {
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.BlacklistFilters{
IP: ip,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 黑名单列表
func (r *BlacklistController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
blacklists, total, err := r.blacklistService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": blacklists,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 黑名单详情
func (r *BlacklistController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"blacklist": *blacklist,
})
}
// Store 创建黑名单
func (r *BlacklistController) Store(ctx http.Context) http.Response {
// 使用请求验证
var blacklistCreate adminrequests.BlacklistCreate
errors, err := ctx.Request().ValidateRequest(&blacklistCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证IP格式(使用自定义验证函数)
if err := utils.ValidateBlacklistIP(blacklistCreate.IP); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 如果不是业务错误,返回通用错误
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidIPFormat.Code)
}
blacklist, err := r.blacklistService.Create(
blacklistCreate.IP,
blacklistCreate.Remark,
blacklistCreate.Status,
)
if err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"ip": blacklistCreate.IP,
})
}
return response.Success(ctx, http.Json{
"blacklist": blacklist,
})
}
// Update 更新黑名单
func (r *BlacklistController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var blacklistUpdate adminrequests.BlacklistUpdate
errors, err := ctx.Request().ValidateRequest(&blacklistUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["ip"]; exists {
// 验证IP格式(使用自定义验证函数)
if err := utils.ValidateBlacklistIP(blacklistUpdate.IP); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 如果不是业务错误,返回通用错误
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidIPFormat.Code)
}
blacklist.IP = blacklistUpdate.IP
}
if _, exists := allInputs["remark"]; exists {
blacklist.Remark = blacklistUpdate.Remark
}
if _, exists := allInputs["status"]; exists {
blacklist.Status = blacklistUpdate.Status
}
if err := r.blacklistService.Update(blacklist); err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"blacklist_id": blacklist.ID,
})
}
return response.Success(ctx, http.Json{
"blacklist": *blacklist,
})
}
// Destroy 删除黑名单
func (r *BlacklistController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
blacklist, resp := r.findBlacklistByID(ctx, id)
if resp != nil {
return resp
}
if err := r.blacklistService.Delete(blacklist); err != nil {
return response.ErrorWithLog(ctx, "blacklist", err, map[string]any{
"blacklist_id": blacklist.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,153 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/services"
)
type CodeGeneratorController struct {
codeGeneratorService services.CodeGeneratorService
}
type GenerateRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
Files []string `json:"files"`
Options map[string]bool `json:"options"`
}
type PreviewRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
FileType string `json:"file_type"`
Options map[string]bool `json:"options"`
}
type SaveRequest struct {
ModuleName string `json:"module_name"`
TableName string `json:"table_name"`
Fields []services.FieldConfig `json:"fields"`
Force bool `json:"force"`
Files []string `json:"files"`
Options map[string]bool `json:"options"`
}
func NewCodeGeneratorController() *CodeGeneratorController {
return &CodeGeneratorController{
codeGeneratorService: services.NewCodeGeneratorService(),
}
}
// Generate 生成CRUD代码
func (c *CodeGeneratorController) Generate(ctx http.Context) http.Response {
var req GenerateRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
files, err := c.codeGeneratorService.Generate(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"files": files,
})
}
// Preview 预览生成的代码
func (c *CodeGeneratorController) Preview(ctx http.Context) http.Response {
var req PreviewRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
if req.FileType == "" {
return response.Error(ctx, http.StatusBadRequest, "file_type_required")
}
code, err := c.codeGeneratorService.Preview(req.ModuleName, req.TableName, req.Fields, req.FileType, req.Options)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"code": code,
})
}
// Save 保存生成的代码到文件系统
func (c *CodeGeneratorController) Save(ctx http.Context) http.Response {
var req SaveRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_fields")
}
if req.ModuleName == "" {
return response.Error(ctx, http.StatusBadRequest, "module_name_required")
}
if req.TableName == "" {
return response.Error(ctx, http.StatusBadRequest, "table_name_required")
}
var savedFiles []string
var err error
if req.Force {
savedFiles, err = c.codeGeneratorService.ForceSave(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
} else {
savedFiles, err = c.codeGeneratorService.Save(req.ModuleName, req.TableName, req.Fields, req.Files, req.Options)
}
if err != nil {
if filesExistErr, ok := err.(*services.FilesExistError); ok {
return ctx.Response().Json(409, http.Json{
"code": 409,
"message": facades.Lang(ctx).Get("files_exist"),
"error_code": "files_exist",
"files": filesExistErr.Files,
})
}
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"saved_files": savedFiles,
})
}
// GetFieldTypes 获取支持的字段类型
func (c *CodeGeneratorController) GetFieldTypes(ctx http.Context) http.Response {
fieldTypes := c.codeGeneratorService.GetFieldTypes()
return response.Success(ctx, http.Json{
"field_types": fieldTypes,
})
}
@@ -0,0 +1,325 @@
package admin
import (
"crypto/tls"
"fmt"
"net/smtp"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/models"
)
type ConfigController struct {
}
func NewConfigController() *ConfigController {
return &ConfigController{}
}
// GetByGroup 根据分组获取配置
func (r *ConfigController) GetByGroup(ctx http.Context) http.Response {
group := ctx.Request().Route("group")
if group == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigGroupRequired.Code)
}
var configs []models.Config
// 查询配置,即使没有数据也返回空数组,不返回错误
_ = facades.Orm().Query().Where("group", group).Order("sort asc, id asc").Get(&configs)
// 如果是邮箱配置分组,将密码字段的值设为空,不让前端看到
if group == "email" {
for i := range configs {
if configs[i].Key == "email_password" {
configs[i].Value = ""
}
}
}
return response.Success(ctx, http.Json{
"configs": configs,
})
}
// Save 保存配置(按分组批量保存)
func (r *ConfigController) Save(ctx http.Context) http.Response {
group := ctx.Request().Input("group")
if group == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigGroupRequired.Code)
}
configsMap := ctx.Request().InputMap("configs")
if len(configsMap) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrConfigsRequired.Code)
}
// 获取该分组下的所有配置(即使查询失败也继续,使用空数组)
var existingConfigs []models.Config
_ = facades.Orm().Query().Where("group", group).Get(&existingConfigs)
// 创建key到config的映射
configMap := make(map[string]*models.Config)
for i := range existingConfigs {
configMap[existingConfigs[i].Key] = &existingConfigs[i]
}
now := carbon.Now()
// 对于 storage 分组,只允许保存驱动选择字段(白名单)
if group == "storage" {
allowedKeys := map[string]bool{
"file_disk": true,
"storage_disk": true, // 向后兼容,保留但不推荐使用
"export_disk": true, // 向后兼容
}
filteredConfigs := make(map[string]any)
for key, value := range configsMap {
if allowedKeys[key] {
filteredConfigs[key] = value
}
}
configsMap = filteredConfigs
}
// 批量处理配置更新和创建
for key, value := range configsMap {
// 转换值为字符串,处理布尔值
var valueStr string
switch v := value.(type) {
case bool:
if v {
valueStr = "1"
} else {
valueStr = "0"
}
case nil:
valueStr = ""
default:
valueStr = cast.ToString(value)
}
// 如果是邮箱配置的密码字段,且值为空,且配置已存在,则跳过更新(保持原有值)
if group == "email" && key == "email_password" && valueStr == "" {
if _, exists := configMap[key]; exists {
continue
}
// 如果配置不存在,则创建空值配置(允许首次创建时为空)
}
if config, exists := configMap[key]; exists {
// 更新现有配置
config.Value = valueStr
if err := facades.Orm().Query().Save(config); err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"group": group,
"key": key,
})
}
} else {
// 创建新配置
configData := map[string]any{
"group": group,
"key": key,
"value": valueStr,
"type": "input",
"sort": 0,
"created_at": now,
"updated_at": now,
}
if err := facades.Orm().Query().Table("configs").Create(configData); err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"group": group,
"key": key,
})
}
}
}
return response.Success(ctx)
}
// TestEmail 测试邮件发送
func (r *ConfigController) TestEmail(ctx http.Context) http.Response {
emailHost := ctx.Request().Input("email_host")
emailPort := cast.ToInt(ctx.Request().Input("email_port", "587"))
emailUsername := ctx.Request().Input("email_username")
emailPassword := ctx.Request().Input("email_password")
emailFrom := ctx.Request().Input("email_from")
emailFromName := ctx.Request().Input("email_from_name")
emailEncryption := ctx.Request().Input("email_encryption", "tls")
// 验证必填字段
if emailHost == "" || emailPort == 0 || emailUsername == "" || emailFrom == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrEmailConfigRequired.Code)
}
// 获取当前登录的管理员邮箱作为测试收件人
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
admin, ok := adminValue.(models.Admin)
if !ok {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 如果没有邮箱,使用发件人邮箱作为测试收件人
testEmail := emailFrom
if admin.Email != "" {
testEmail = admin.Email
}
// 构建邮件内容
fromName := emailFromName
if fromName == "" {
fromName = emailFrom
}
subject := "测试邮件"
body := fmt.Sprintf(`<h2>这是一封测试邮件</h2>
<p>如果您收到这封邮件,说明邮件配置正确。</p>
<p>发送时间:%s</p>
<p>SMTP服务器:%s:%d</p>
<p>加密方式:%s</p>`, carbon.Now().ToDateTimeString(), emailHost, emailPort, emailEncryption)
// 构建邮件消息
message := fmt.Sprintf("From: %s <%s>\r\n", fromName, emailFrom)
message += fmt.Sprintf("To: %s\r\n", testEmail)
message += fmt.Sprintf("Subject: %s\r\n", subject)
message += "MIME-Version: 1.0\r\n"
message += "Content-Type: text/html; charset=UTF-8\r\n"
message += "\r\n" + body
// 构建SMTP地址
addr := fmt.Sprintf("%s:%d", emailHost, emailPort)
// 创建SMTP认证
auth := smtp.PlainAuth("", emailUsername, emailPassword, emailHost)
// 发送邮件
var err error
if emailEncryption == "ssl" {
// SSL连接
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: emailHost,
}
conn, connErr := tls.Dial("tcp", addr, tlsConfig)
if connErr != nil {
return response.ErrorWithLog(ctx, "config", connErr, map[string]any{
"host": emailHost,
"port": emailPort,
})
}
defer conn.Close()
client, clientErr := smtp.NewClient(conn, emailHost)
if clientErr != nil {
return response.ErrorWithLog(ctx, "config", clientErr)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = client.Mail(emailFrom); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = client.Rcpt(testEmail); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
writer, writerErr := client.Data()
if writerErr != nil {
return response.ErrorWithLog(ctx, "config", writerErr)
}
_, err = writer.Write([]byte(message))
if err != nil {
writer.Close()
return response.ErrorWithLog(ctx, "config", err)
}
err = writer.Close()
if err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
} else {
// TLS或普通连接
if emailEncryption == "tls" {
// TLS连接
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: emailHost,
}
err = smtp.SendMail(addr, auth, emailFrom, []string{testEmail}, []byte(message))
if err != nil {
// 如果直接SendMail失败,尝试手动TLS
conn, connErr := smtp.Dial(addr)
if connErr != nil {
return response.ErrorWithLog(ctx, "config", connErr, map[string]any{
"host": emailHost,
"port": emailPort,
})
}
defer conn.Close()
if err = conn.StartTLS(tlsConfig); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Auth(auth); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Mail(emailFrom); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
if err = conn.Rcpt(testEmail); err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
writer, writerErr := conn.Data()
if writerErr != nil {
return response.ErrorWithLog(ctx, "config", writerErr)
}
_, err = writer.Write([]byte(message))
if err != nil {
writer.Close()
return response.ErrorWithLog(ctx, "config", err)
}
err = writer.Close()
if err != nil {
return response.ErrorWithLog(ctx, "config", err)
}
}
} else {
// 普通连接(无加密)
err = smtp.SendMail(addr, auth, emailFrom, []string{testEmail}, []byte(message))
}
}
if err != nil {
return response.ErrorWithLog(ctx, "config", err, map[string]any{
"host": emailHost,
"port": emailPort,
"from": emailFrom,
"to": testEmail,
})
}
return response.Success(ctx, "test_email_success", http.Json{
"message": "测试邮件已发送到 " + testEmail,
})
}
@@ -0,0 +1,454 @@
package admin
import (
"encoding/json"
"fmt"
nethttp "net/http"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type DashboardController struct{}
func NewDashboardController() *DashboardController {
return &DashboardController{}
}
// GetCount 获取统计数据
func (r *DashboardController) GetCount(ctx http.Context) http.Response {
countData := r.getCountData()
// 获取今日访问量(今日登录日志数)
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
todayVisits, _ := facades.Orm().Query().Model(&models.LoginLog{}).
Where("created_at >= ?", todayStart).
Where("created_at <= ?", todayEnd).
Where("status", 1).
Count()
// 获取在线管理员数
onlineAdminCount := r.getOnlineAdminCount()
// 获取最近一年的订单总数
orderService := services.NewOrderService()
orderCountInYear, _ := orderService.GetOrdersCountInYear()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": map[string]any{
"admin_count": countData["admins"],
"role_count": countData["roles"],
"menu_count": countData["menus"],
"today_visits": todayVisits,
"online_admins": onlineAdminCount,
"order_count_in_year": orderCountInYear,
},
})
}
// GetUserAccessSource 获取用户访问来源数据(根据 UserAgent 判断设备类型)
func (r *DashboardController) GetUserAccessSource(ctx http.Context) http.Response {
// 从登录日志中统计不同设备类型的访问量
// 统计最近30天的数据
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
var loginLogs []models.LoginLog
facades.Orm().Query().Model(&models.LoginLog{}).
Where("created_at >= ?", thirtyDaysAgo).
Where("status", 1).
Get(&loginLogs)
// 统计设备类型
deviceStats := make(map[string]int64)
for _, log := range loginLogs {
deviceType := r.parseDeviceType(log.UserAgent)
deviceStats[deviceType]++
}
// 转换为数组格式
result := []map[string]any{
{"name": "桌面端", "value": deviceStats["desktop"]},
{"name": "移动端", "value": deviceStats["mobile"]},
{"name": "平板端", "value": deviceStats["tablet"]},
{"name": "其他", "value": deviceStats["other"]},
}
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": result,
})
}
// parseDeviceType 根据 UserAgent 解析设备类型
func (r *DashboardController) parseDeviceType(userAgent string) string {
if userAgent == "" {
return "other"
}
ua := strings.ToLower(userAgent)
// 平板设备检测(需要在移动设备之前检测)
if strings.Contains(ua, "ipad") || (strings.Contains(ua, "tablet") && !strings.Contains(ua, "mobile")) {
return "tablet"
}
// 移动设备检测
if strings.Contains(ua, "mobile") || strings.Contains(ua, "android") || strings.Contains(ua, "iphone") {
return "mobile"
}
// 桌面设备
return "desktop"
}
// GetWeeklyUserActivity 获取每周用户活跃量(从操作日志统计)
func (r *DashboardController) GetWeeklyUserActivity(ctx http.Context) http.Response {
weeklyData := r.getWeeklyUserActivityData()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": weeklyData,
})
}
// GetMonthlySales 获取每月操作统计(替换销售额数据)
func (r *DashboardController) GetMonthlySales(ctx http.Context) http.Response {
// 替换成操作日志月度统计
monthlyData := r.getMonthlyOperationData()
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": monthlyData,
})
}
// GetRecentActivities 获取最近活动
func (r *DashboardController) GetRecentActivities(ctx http.Context) http.Response {
// 获取最近10条操作日志
var logs []models.OperationLog
facades.Orm().Query().Model(&models.OperationLog{}).
With("Admin").
Order("id desc").
Limit(10).
Get(&logs)
activities := make([]map[string]any, 0, len(logs))
for _, log := range logs {
adminName := "未知用户"
if log.Admin.ID > 0 {
adminName = log.Admin.Nickname
if adminName == "" {
adminName = log.Admin.Username
}
}
statusText := "成功"
statusType := "success"
if log.Status == 0 {
statusText = "失败"
statusType = "danger"
}
// 计算时间差
var timeAgo string
if log.CreatedAt != nil {
// carbon.DateTime 转换为 time.Time
timeStr := log.CreatedAt.ToDateTimeString()
if t, err := utils.ParseDateTime(timeStr); err == nil {
timeAgo = r.formatTimeAgo(t)
} else {
timeAgo = "未知"
}
} else {
timeAgo = "未知"
}
activities = append(activities, map[string]any{
"user": adminName,
"action": log.Title,
"time": timeAgo,
"status": statusText,
"type": statusType,
"avatarColor": r.getAvatarColor(adminName),
})
}
return ctx.Response().Success().Json(http.Json{
"code": 200,
"message": "get_success",
"data": activities,
})
}
// formatTimeAgo 格式化时间差
func (r *DashboardController) formatTimeAgo(t time.Time) string {
now := time.Now()
duration := now.Sub(t)
if duration < time.Minute {
return "刚刚"
} else if duration < time.Hour {
minutes := int(duration.Minutes())
return fmt.Sprintf("%d分钟前", minutes)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
return fmt.Sprintf("%d小时前", hours)
} else {
days := int(duration.Hours() / 24)
return fmt.Sprintf("%d天前", days)
}
}
// getAvatarColor 根据用户名生成头像颜色
func (r *DashboardController) getAvatarColor(name string) string {
colors := []string{"#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#909399", "#606266"}
if name == "" {
return colors[0]
}
hash := 0
for _, char := range name {
hash = hash*31 + int(char)
}
return colors[hash%len(colors)]
}
// StreamDashboardData SSE 实时推送 Dashboard 数据
// 定期推送所有 Dashboard 统计数据,包括计数、用户来源、用户活跃度、销售额等
func (r *DashboardController) StreamDashboardData(ctx http.Context) http.Response {
// 获取推送间隔(秒),默认 5 秒
interval := 5
if intervalStr := ctx.Request().Query("interval", ""); intervalStr != "" {
if parsed, err := time.ParseDuration(intervalStr + "s"); err == nil {
interval = int(parsed.Seconds())
if interval < 2 {
interval = 2
}
if interval > 60 {
interval = 60
}
}
}
// 设置 SSE 响应头
writer := ctx.Response().Writer()
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
writer.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
// 发送初始连接消息
initMsg := map[string]any{
"type": "connected",
"message": "SSE连接已建立,开始推送 Dashboard 数据",
"interval": interval,
}
initData, _ := json.Marshal(initMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(initData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 创建 ticker,定期推送数据
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()
// 检测客户端断开连接
clientGone := ctx.Request().Origin().Context().Done()
for {
select {
case <-clientGone:
// 客户端断开连接
return nil
case <-ticker.C:
// 收集所有 Dashboard 数据
dashboardData := r.collectDashboardData(ctx)
// 构造 SSE 消息
message := map[string]any{
"type": "dashboard_data",
"data": dashboardData,
"timestamp": time.Now().Format(time.RFC3339),
}
messageData, err := json.Marshal(message)
if err != nil {
// 记录错误但继续推送
facades.Log().Errorf("Dashboard SSE: failed to marshal data: %v", err)
continue
}
// 发送 SSE 消息
fmt.Fprintf(writer, "data: %s\n\n", string(messageData))
// 刷新缓冲区
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
}
}
}
// collectDashboardData 收集 Dashboard 数据
func (r *DashboardController) collectDashboardData(ctx http.Context) map[string]any {
data := make(map[string]any)
// 1. 获取统计数据(管理员、角色、权限等)
countData := r.getCountData()
data["count"] = countData
// 2. 获取用户访问来源数据
accessSourceData := r.getUserAccessSourceData()
data["user_access_source"] = accessSourceData
// 3. 获取每周用户活跃量
weeklyActivityData := r.getWeeklyUserActivityData()
data["weekly_user_activity"] = weeklyActivityData
// 4. 获取每月销售额
monthlySalesData := r.getMonthlySalesData()
data["monthly_sales"] = monthlySalesData
// 5. 获取在线管理员数
onlineAdminCount := r.getOnlineAdminCount()
data["online_admin_count"] = onlineAdminCount
return data
}
// getCountData 获取统计数据
func (r *DashboardController) getCountData() map[string]any {
// 统计各种数据
adminCount, _ := facades.Orm().Query().Model(&models.Admin{}).Count()
roleCount, _ := facades.Orm().Query().Model(&models.Role{}).Count()
permissionCount, _ := facades.Orm().Query().Model(&models.Permission{}).Count()
menuCount, _ := facades.Orm().Query().Model(&models.Menu{}).Count()
departmentCount, _ := facades.Orm().Query().Model(&models.Department{}).Count()
dictionaryCount, _ := facades.Orm().Query().Model(&models.Dictionary{}).Count()
configCount, _ := facades.Orm().Query().Model(&models.Config{}).Count()
return map[string]any{
"admins": adminCount,
"roles": roleCount,
"permissions": permissionCount,
"menus": menuCount,
"departments": departmentCount,
"dictionaries": dictionaryCount,
"configs": configCount,
}
}
// getUserAccessSourceData 获取用户访问来源数据
func (r *DashboardController) getUserAccessSourceData() []map[string]any {
// 这里可以根据实际业务逻辑查询用户访问来源
// 例如:根据登录日志统计不同来源的用户数
// 暂时返回示例数据
return []map[string]any{
{"source": "web", "count": 0},
{"source": "mobile", "count": 0},
{"source": "api", "count": 0},
}
}
// getWeeklyUserActivityData 获取每周用户活跃量(从操作日志统计)
func (r *DashboardController) getWeeklyUserActivityData() []map[string]any {
now := time.Now()
weeklyData := make([]map[string]any, 7)
for i := 6; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStr := utils.FormatDate(date)
// 计算当天的开始和结束时间
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
// 统计当天的操作日志数(访问量)
visitCount, _ := facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfDay).
Where("created_at <= ?", endOfDay).
Where("status", 1).
Count()
// 统计当天活跃的管理员数(去重)
var uniqueAdmins []uint
facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfDay).
Where("created_at <= ?", endOfDay).
Where("status", 1).
Select("DISTINCT admin_id").
Pluck("admin_id", &uniqueAdmins)
userCount := int64(len(uniqueAdmins))
weeklyData[6-i] = map[string]any{
"date": dateStr,
"visits": visitCount,
"users": userCount,
}
}
return weeklyData
}
// getMonthlySalesData 获取每月销售额(保留方法名以兼容 SSE)
func (r *DashboardController) getMonthlySalesData() []map[string]any {
return r.getMonthlyOperationData()
}
// getMonthlyOperationData 获取每月操作统计(替换销售额)
func (r *DashboardController) getMonthlyOperationData() []map[string]any {
now := time.Now()
monthlyData := make([]map[string]any, 12)
for i := 11; i >= 0; i-- {
date := now.AddDate(0, -i, 0)
monthStr := date.Format("2006-01")
// 计算当月的开始和结束时间
startOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
var endOfMonth time.Time
if i == 0 {
// 当前月,使用当前时间
endOfMonth = now
} else {
// 历史月份,使用月末
endOfMonth = startOfMonth.AddDate(0, 1, -1).Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
// 统计当月的操作日志数
operationCount, _ := facades.Orm().Query().Model(&models.OperationLog{}).
Where("created_at >= ?", startOfMonth).
Where("created_at <= ?", endOfMonth).
Where("status", 1).
Count()
monthlyData[11-i] = map[string]any{
"month": monthStr,
"count": operationCount,
}
}
return monthlyData
}
// getOnlineAdminCount 获取在线管理员数
func (r *DashboardController) getOnlineAdminCount() int64 {
// 统计最近15分钟内有活动的管理员(在线管理员)
onlineThreshold := time.Now().Add(-15 * time.Minute)
count, _ := facades.Orm().Query().Model(&models.PersonalAccessToken{}).
Where("tokenable_type", "admin").
Where("last_used_at IS NOT NULL").
Where("last_used_at >= ?", onlineThreshold).
Count()
return count
}
@@ -0,0 +1,99 @@
package admin
import (
"fmt"
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/utils/errorlog"
"goravel/app/utils/traceid"
)
type DebugController struct {
}
func NewDebugController() *DebugController {
return &DebugController{}
}
// TraceTest 手动触发不同级别的日志,方便校验 trace_id
// 支持 query 参数:
// - level: 日志级别 (error/warning/info/debug),默认 error
// - message: 自定义消息,默认 "manual trace log test"
// - trace_id: 自定义 trace_id(可选)
func (r *DebugController) TraceTest(ctx http.Context) http.Response {
traceID := traceid.EnsureHTTPContext(ctx, ctx.Request().Query("trace_id", ""))
message := ctx.Request().Query("message", "manual trace log test")
level := ctx.Request().Query("level", "error")
// 根据级别记录不同级别的日志
switch level {
case "warning":
errorlog.RecordHTTPWithLevel(ctx, "warning", "trace-test", "Trace test warning log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "warning",
}, "Trace test warning: %s", message)
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTraceTestWarning.Code)
case "info":
errorlog.RecordHTTPWithLevel(ctx, "info", "trace-test", "Trace test info log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "info",
}, "Trace test info: %s", message)
return response.Success(ctx, "trace_test_info", http.Json{
"message": message,
"level": "info",
"hint": "Check system logs with this trace_id to see info level log",
})
case "debug":
errorlog.RecordHTTPWithLevel(ctx, "debug", "trace-test", "Trace test debug log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "debug",
}, "Trace test debug: %s", message)
return response.Success(ctx, "trace_test_debug", http.Json{
"message": message,
"level": "debug",
"hint": "Check system logs with this trace_id to see debug level log",
})
default: // error
errorlog.RecordHTTP(ctx, "trace-test", "Trace test error log", map[string]any{
"path": ctx.Request().Path(),
"method": ctx.Request().Method(),
"trace_id": traceID,
"message": message,
"level": "error",
}, "Trace test error: %s", message)
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrTraceTestError.Code)
}
}
// TestErrorLog 测试 ErrorWithLog 日志记录功能
// 用于手动触发异常,测试日志是否正常写入
func (r *MenuController) TestErrorLog(ctx http.Context) http.Response {
// 创建一个测试错误
testErr := fmt.Errorf("测试错误日志记录功能 - 这是一个手动触发的异常测试")
return response.ErrorWithLog(ctx, "menu-test", testErr)
// return response.ErrorWithLog(ctx, "menu-test", testErr, map[string]any{
// "test_type": "manual_test",
// "test_purpose": "验证 ErrorWithLog 是否能正确写入日志记录",
// "test_time": carbon.Now().ToDateTimeString(),
// "controller": "MenuController",
// "method": "TestErrorLog",
// })
}
@@ -0,0 +1,227 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type DepartmentController struct {
treeService services.TreeService
departmentService services.DepartmentService
}
func NewDepartmentController() *DepartmentController {
treeService := services.NewTreeServiceImpl()
return &DepartmentController{
treeService: treeService,
departmentService: services.NewDepartmentServiceImpl(treeService),
}
}
// findDepartmentByID 根据ID查找部门,如果不存在则返回错误响应
func (r *DepartmentController) findDepartmentByID(ctx http.Context, id uint) (*models.Department, http.Response) {
department, err := r.departmentService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrDepartmentNotFound.Code)
}
return department, nil
}
// buildFilters 构建查询过滤器
func (r *DepartmentController) buildFilters(ctx http.Context) services.DepartmentFilters {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.DepartmentFilters{
Name: name,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 部门列表(树形结构)
func (r *DepartmentController) Index(ctx http.Context) http.Response {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
// 如果有搜索条件,返回扁平列表;否则返回树形结构
if name != "" || status != "" || startTime != "" || endTime != "" {
filters := r.buildFilters(ctx)
// 搜索时获取所有匹配的记录,不限制分页
departments, _, err := r.departmentService.GetList(filters, 1, 10000)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"list": departments,
})
}
// 无搜索条件时返回树形结构
departments, err := r.treeService.BuildDepartmentTree(0)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"list": departments,
})
}
// Show 部门详情
func (r *DepartmentController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"department": *department,
})
}
// Store 创建部门
func (r *DepartmentController) Store(ctx http.Context) http.Response {
// 使用请求验证
var departmentCreate adminrequests.DepartmentCreate
errors, err := ctx.Request().ValidateRequest(&departmentCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
department, err := r.departmentService.Create(
departmentCreate.ParentID,
departmentCreate.Name,
departmentCreate.Code,
departmentCreate.Leader,
departmentCreate.Phone,
departmentCreate.Email,
departmentCreate.Remark,
departmentCreate.Status,
departmentCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"name": departmentCreate.Name,
})
}
return response.Success(ctx, http.Json{
"department": department,
})
}
// Update 更新部门
func (r *DepartmentController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var departmentUpdate adminrequests.DepartmentUpdate
errors, err := ctx.Request().ValidateRequest(&departmentUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
department.Name = departmentUpdate.Name
}
if _, exists := allInputs["parent_id"]; exists {
department.ParentID = departmentUpdate.ParentID
}
if _, exists := allInputs["code"]; exists {
department.Code = departmentUpdate.Code
}
if _, exists := allInputs["leader"]; exists {
department.Leader = departmentUpdate.Leader
}
if _, exists := allInputs["phone"]; exists {
department.Phone = departmentUpdate.Phone
}
if _, exists := allInputs["email"]; exists {
department.Email = departmentUpdate.Email
}
if _, exists := allInputs["status"]; exists {
department.Status = departmentUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
department.Sort = departmentUpdate.Sort
}
if _, exists := allInputs["remark"]; exists {
department.Remark = departmentUpdate.Remark
}
if err := r.departmentService.Update(department); err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"department_id": department.ID,
})
}
return response.Success(ctx, http.Json{
"department": *department,
})
}
// Destroy 删除部门
func (r *DepartmentController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
department, resp := r.findDepartmentByID(ctx, id)
if resp != nil {
return resp
}
// 检查是否有子部门
hasChildren, err := r.treeService.HasDepartmentChildren(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasChildren {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDepartmentHasChildren.Code)
}
// 检查是否有管理员
hasAdmins, err := r.departmentService.HasAdmins(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasAdmins {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDepartmentHasAdmins.Code)
}
if err := r.departmentService.Delete(department); err != nil {
return response.ErrorWithLog(ctx, "department", err, map[string]any{
"department_id": department.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,215 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type DictionaryController struct {
dictionaryService services.DictionaryService
}
func NewDictionaryController() *DictionaryController {
return &DictionaryController{
dictionaryService: services.NewDictionaryService(),
}
}
// findDictionaryByID 根据ID查找字典,如果不存在则返回错误响应
func (r *DictionaryController) findDictionaryByID(ctx http.Context, id uint) (*models.Dictionary, http.Response) {
dictionary, err := r.dictionaryService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrDictionaryNotFound.Code)
}
return dictionary, nil
}
// buildFilters 构建查询过滤器
func (r *DictionaryController) buildFilters(ctx http.Context) services.DictionaryFilters {
dictType := ctx.Request().Query("type", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.DictionaryFilters{
Type: dictType,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 字典列表
func (r *DictionaryController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
dictionaries, total, err := r.dictionaryService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": dictionaries,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 字典详情
func (r *DictionaryController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"dictionary": *dictionary,
})
}
// Store 创建字典
func (r *DictionaryController) Store(ctx http.Context) http.Response {
// 使用请求验证
var dictionaryCreate adminrequests.DictionaryCreate
errors, err := ctx.Request().ValidateRequest(&dictionaryCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
dictionary, err := r.dictionaryService.Create(
dictionaryCreate.Type,
dictionaryCreate.Label,
dictionaryCreate.Value,
dictionaryCreate.TranslationKey,
dictionaryCreate.Description,
dictionaryCreate.Remark,
dictionaryCreate.Status,
dictionaryCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"type": dictionaryCreate.Type,
"label": dictionaryCreate.Label,
})
}
return response.Success(ctx, http.Json{
"dictionary": dictionary,
})
}
func (r *DictionaryController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var dictionaryUpdate adminrequests.DictionaryUpdate
errors, err := ctx.Request().ValidateRequest(&dictionaryUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["type"]; exists {
dictionary.Type = dictionaryUpdate.Type
}
if _, exists := allInputs["label"]; exists {
dictionary.Label = dictionaryUpdate.Label
}
if _, exists := allInputs["value"]; exists {
dictionary.Value = dictionaryUpdate.Value
}
if _, exists := allInputs["translation_key"]; exists {
dictionary.TranslationKey = dictionaryUpdate.TranslationKey
}
if _, exists := allInputs["description"]; exists {
dictionary.Description = dictionaryUpdate.Description
}
if _, exists := allInputs["status"]; exists {
dictionary.Status = dictionaryUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
dictionary.Sort = dictionaryUpdate.Sort
}
if _, exists := allInputs["remark"]; exists {
dictionary.Remark = dictionaryUpdate.Remark
}
if err := r.dictionaryService.Update(dictionary); err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"dictionary_id": dictionary.ID,
})
}
return response.Success(ctx, http.Json{
"dictionary": *dictionary,
})
}
// Destroy 删除字典
func (r *DictionaryController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
dictionary, resp := r.findDictionaryByID(ctx, id)
if resp != nil {
return resp
}
if err := r.dictionaryService.Delete(dictionary); err != nil {
return response.ErrorWithLog(ctx, "dictionary", err, map[string]any{
"dictionary_id": dictionary.ID,
})
}
return response.Success(ctx)
}
func (r *DictionaryController) GetByType(ctx http.Context) http.Response {
dictType := ctx.Request().Route("type")
if dictType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrDictionaryTypeRequired.Code)
}
dictionaries, err := r.dictionaryService.GetByType(dictType)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"dictionaries": dictionaries,
})
}
func (r *DictionaryController) GetAllTypes(ctx http.Context) http.Response {
types, err := r.dictionaryService.GetAllTypes()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"types": types,
})
}
@@ -0,0 +1,403 @@
package admin
import (
"encoding/json"
"fmt"
nethttp "net/http"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
)
type ExportController struct {
exportRecordService services.ExportRecordService
}
func NewExportController() *ExportController {
return &ExportController{
exportRecordService: services.NewExportRecordService(),
}
}
// Index 导出记录列表
func (r *ExportController) Index(ctx http.Context) http.Response {
page, pageSize := helpers.ValidatePagination(
helpers.GetIntQuery(ctx, "page", 1),
helpers.GetIntQuery(ctx, "page_size", 10),
)
filters := r.buildFilters(ctx)
exports, total, err := r.exportRecordService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 为每个导出记录生成可访问的 file_url
exportService := services.NewExportService(ctx)
type ExportWithURL struct {
models.Export
FileURL string `json:"file_url"`
}
var resultWithURL []ExportWithURL
for _, e := range exports {
fileURL := ""
if e.Path != "" {
// 对于 local 和 public 存储,使用下载接口
if e.Disk == "local" || e.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", e.ID)
} else {
// 对于云存储,使用 GetExportURL 生成 URL
fileURL = exportService.GetExportURL(e.Path)
}
}
resultWithURL = append(resultWithURL, ExportWithURL{
Export: e,
FileURL: fileURL,
})
}
return response.Paginate(ctx, resultWithURL, total, page, pageSize)
}
// buildFilters 构建导出记录查询过滤器
func (r *ExportController) buildFilters(ctx http.Context) services.ExportRecordFilters {
adminID := ctx.Request().Query("admin_id", "")
exportType := ctx.Request().Query("type", "")
filename := ctx.Request().Query("filename", "")
disk := ctx.Request().Query("disk", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.ExportRecordFilters{
AdminID: adminID,
Type: exportType,
Filename: filename,
Disk: disk,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Destroy 删除导出记录并删除源文件
func (r *ExportController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
export, err := r.exportRecordService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
// 尝试删除源文件(忽略失败,仅记录日志)
if export.Path != "" && export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if err := storage.Delete(export.Path); err != nil {
// 删除源文件失败只记录日志,不影响主流程
errorlog.RecordHTTP(ctx, "export", "Failed to delete export source file", map[string]any{
"error": err.Error(),
"disk": export.Disk,
"path": export.Path,
}, "Delete export source file error: %v", err)
}
}
if err := r.exportRecordService.Delete(id); err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"exportId": id,
})
}
return response.Success(ctx)
}
// Download 下载导出文件
func (r *ExportController) Download(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
export, err := r.exportRecordService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if export.Path == "" || export.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 获取存储驱动
storage := facades.Storage().Disk(export.Disk)
// 读取文件内容
content, err := storage.Get(export.Path)
if err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"disk": export.Disk,
"path": export.Path,
})
}
// 设置响应头
filename := export.Filename
if filename == "" {
filename = export.Path
}
// 根据文件扩展名设置 Content-Type
contentType := "application/octet-stream"
if export.Extension == "csv" {
contentType = "text/csv; charset=utf-8"
} else if export.Extension == "xlsx" || export.Extension == "xls" {
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
// 设置响应头,使用链式调用确保顺序正确
response := ctx.Response().
Header("Content-Type", contentType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)).
Header("Cache-Control", "no-cache, no-store, must-revalidate").
Header("Pragma", "no-cache").
Header("Expires", "0")
return response.String(http.StatusOK, content)
}
type ExportBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除导出记录并删除源文件
func (r *ExportController) BatchDestroy(ctx http.Context) http.Response {
var req ExportBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 查询要删除的导出记录(用于删除源文件)
exports, err := r.exportRecordService.GetByIDs(ids)
if err == nil {
// 尝试删除源文件(忽略失败,仅记录日志)
for _, export := range exports {
if export.Path != "" && export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if err := storage.Delete(export.Path); err != nil {
errorlog.RecordHTTP(ctx, "export", "Failed to delete export source file in batch delete", map[string]any{
"error": err.Error(),
"disk": export.Disk,
"path": export.Path,
}, "Delete export source file in batch delete error: %v", err)
}
}
}
}
// 批量删除数据库记录
if err := r.exportRecordService.BatchDelete(ids); err != nil {
return response.ErrorWithLog(ctx, "export", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// StreamExportProgress SSE 实时推送导出任务进度
// 监控导出任务的状态变化,实时推送进度信息
func (r *ExportController) StreamExportProgress(ctx http.Context) http.Response {
// 获取参数
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
// 尝试从查询参数获取
exportID = helpers.GetUintQuery(ctx, "id", 0)
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
}
// 获取推送间隔(毫秒),默认 1 秒
interval := 1000
if intervalStr := ctx.Request().Query("interval", ""); intervalStr != "" {
if parsed, err := time.ParseDuration(intervalStr + "ms"); err == nil {
interval = max(int(parsed.Milliseconds()), 500)
if interval > 5000 {
interval = 5000
}
}
}
// 设置 SSE 响应头
writer := ctx.Response().Writer()
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
writer.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
// 发送初始连接消息
initMsg := map[string]any{
"type": "connected",
"message": "SSE连接已建立,开始监控导出任务进度",
"export_id": exportID,
"interval": interval,
}
initData, _ := json.Marshal(initMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(initData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 创建 ticker,定期检查导出任务状态
ticker := time.NewTicker(time.Duration(interval) * time.Millisecond)
defer ticker.Stop()
// 检测客户端断开连接
clientGone := ctx.Request().Origin().Context().Done()
// 记录上次的状态,避免重复推送
lastStatus := uint8(255) // 使用一个不可能的值作为初始值
lastPath := ""
exportService := services.NewExportService(ctx)
for {
select {
case <-clientGone:
// 客户端断开连接
return nil
case <-ticker.C:
// 查询导出任务
export, err := r.exportRecordService.GetByID(exportID)
if err != nil {
// 导出任务不存在或已删除
errorMsg := map[string]any{
"type": "error",
"message": "导出任务不存在或已删除",
"error": err.Error(),
}
errorData, _ := json.Marshal(errorMsg)
fmt.Fprintf(writer, "data: %s\n\n", string(errorData))
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 继续监控,可能任务还在创建中
continue
}
// 检查状态是否有变化
if export.Status == lastStatus && export.Path == lastPath {
// 状态和路径都没有变化,跳过本次推送
// 但如果已完成,可以继续推送完成状态
if export.Status == 1 && lastStatus == 1 {
continue
}
}
// 更新记录
lastStatus = export.Status
lastPath = export.Path
// 构造进度消息
message := map[string]any{
"type": "progress",
"export_id": export.ID,
"status": export.Status,
"timestamp": time.Now().Format(time.RFC3339),
}
// 根据状态设置不同的消息
switch export.Status {
case 1:
// 导出成功
message["type"] = "completed"
message["message"] = "导出任务已完成"
message["status_text"] = "成功"
// 生成下载链接
fileURL := ""
if export.Path != "" {
if export.Disk == "local" || export.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", export.ID)
} else {
fileURL = exportService.GetExportURL(export.Path)
}
}
message["file_url"] = fileURL
message["filename"] = export.Filename
message["size"] = export.Size
case 0:
// 导出失败
message["type"] = "failed"
message["message"] = "导出任务失败"
message["status_text"] = "失败"
default:
// 处理中(Status 可能是其他值,或者我们不知道的状态)
message["message"] = "导出任务处理中"
message["status_text"] = "处理中"
}
// 如果文件路径已存在,说明正在生成文件
if export.Path != "" && export.Status != 1 {
message["message"] = "正在生成导出文件"
// 可以尝试检查文件大小来判断进度(如果存储驱动支持)
if export.Disk != "" {
storage := facades.Storage().Disk(export.Disk)
if size, err := storage.Size(export.Path); err == nil {
message["file_size"] = size
if export.Size > 0 {
progress := float64(size) / float64(export.Size) * 100
if progress > 100 {
progress = 100
}
message["progress"] = progress
}
}
}
}
messageData, err := json.Marshal(message)
if err != nil {
errorlog.RecordHTTP(ctx, "export", "Failed to marshal progress", map[string]any{
"error": err.Error(),
}, "Marshal progress error: %v", err)
continue
}
// 发送 SSE 消息
fmt.Fprintf(writer, "data: %s\n\n", string(messageData))
// 刷新缓冲区
if flusher, ok := writer.(nethttp.Flusher); ok {
flusher.Flush()
}
// 如果已完成或失败,可以选择继续推送一段时间后关闭,或者保持连接
// 这里选择继续推送,让前端决定何时关闭
}
}
}
@@ -0,0 +1,155 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type LoginLogController struct {
loginLogService services.LoginLogService
}
func NewLoginLogController() *LoginLogController {
return &LoginLogController{
loginLogService: services.NewLoginLogService(),
}
}
// findLoginLogByID 根据ID查找登录日志,如果不存在则返回错误响应
// withAdmin 为 true 时会预加载 Admin 关联
func (r *LoginLogController) findLoginLogByID(ctx http.Context, id uint, withAdmin bool) (*models.LoginLog, http.Response) {
log, err := r.loginLogService.GetByID(id, withAdmin)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *LoginLogController) buildFilters(ctx http.Context) services.LoginLogFilters {
adminID := ctx.Request().Query("admin_id", "")
username := ctx.Request().Query("username", "")
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.LoginLogFilters{
AdminID: adminID,
Username: username,
IP: ip,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 获取登录日志列表
func (r *LoginLogController) Index(ctx http.Context) http.Response {
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.loginLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "login-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取登录日志详情
func (r *LoginLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findLoginLogByID(ctx, id, true) // 预加载 Admin 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除登录日志
func (r *LoginLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findLoginLogByID(ctx, id, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type LoginLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除登录日志
func (r *LoginLogController) BatchDestroy(ctx http.Context) http.Response {
var req LoginLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.LoginLog{}); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理登录日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *LoginLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.LoginLog{}).Where("created_at < ?", cutoffTime).Delete(&models.LoginLog{}); err != nil {
return response.ErrorWithLog(ctx, "login-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,298 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type MenuController struct {
treeService services.TreeService
menuService services.MenuService
}
func NewMenuController() *MenuController {
return &MenuController{
treeService: services.NewTreeServiceImpl(),
menuService: services.NewMenuService(),
}
}
// findMenuByID 根据ID查找菜单,如果不存在则返回错误响应
func (r *MenuController) findMenuByID(ctx http.Context, id uint) (*models.Menu, http.Response) {
return response.FindByID[models.Menu](ctx, id, &response.FindByIDOptions{
NotFoundMessageKey: apperrors.ErrMenuNotFound.Code,
})
}
// Index 菜单列表(树形结构)
func (r *MenuController) Index(ctx http.Context) http.Response {
menus, err := r.treeService.BuildMenuTree(0)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
// 检查是否需要隐藏服务监控菜单
// 只有当配置值不为空且不等于 "0" 时才隐藏("0" 表示不隐藏)
monitorHidden := facades.Config().GetString("admin.monitor_hidden", "")
if monitorHidden != "" && monitorHidden != "0" {
// 获取当前管理员ID
adminValue := ctx.Value("admin")
var adminID uint
if adminValue != nil {
if admin, ok := adminValue.(models.Admin); ok {
adminID = admin.ID
} else if adminPtr, ok := adminValue.(*models.Admin); ok && adminPtr != nil {
adminID = adminPtr.ID
}
}
// 检查是否是开发者管理员
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
isDeveloperAdmin := r.isDeveloperAdmin(adminID, developerIDsStr)
// 如果不是开发者管理员,则过滤掉服务监控菜单
if !isDeveloperAdmin {
menus = r.filterMonitorMenu(menus)
}
}
// 检查是否需要隐藏开发工具菜单
enableDevTool := facades.Config().GetBool("app.enable_dev_tool")
if !enableDevTool {
menus = r.filterDevMenu(menus)
}
return response.Success(ctx, http.Json{
"menus": menus,
})
}
// Show 菜单详情
func (r *MenuController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
// Store 创建菜单
func (r *MenuController) Store(ctx http.Context) http.Response {
// 使用请求验证
var menuCreate adminrequests.MenuCreate
errors, err := ctx.Request().ValidateRequest(&menuCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查 slug 是否已存在
exists, err := facades.Orm().Query().Model(&models.Menu{}).Where("slug", menuCreate.Slug).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuSlugExists.Code)
}
menu, err := r.menuService.Create(
menuCreate.ParentID,
menuCreate.Title,
menuCreate.Slug,
menuCreate.Icon,
menuCreate.Path,
menuCreate.Component,
menuCreate.Permission,
menuCreate.Type,
menuCreate.Status,
menuCreate.Sort,
menuCreate.IsHidden,
menuCreate.LinkType,
menuCreate.OpenType,
)
if err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"title": menuCreate.Title,
"slug": menuCreate.Slug,
})
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
func (r *MenuController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
// 使用请求验证
var menuUpdate adminrequests.MenuUpdate
errors, err := ctx.Request().ValidateRequest(&menuUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["title"]; exists {
menu.Title = menuUpdate.Title
}
if _, exists := allInputs["slug"]; exists {
// 检查 slug 是否已被其他菜单使用
exists, err := facades.Orm().Query().Model(&models.Menu{}).Where("slug", menuUpdate.Slug).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuSlugExists.Code)
}
menu.Slug = menuUpdate.Slug
}
// 处理 parent_id,需要根据是否存在来更新,如果存在但为空或0,则设为0
if val, exists := allInputs["parent_id"]; exists {
if val == nil {
menu.ParentID = 0
} else {
menu.ParentID = menuUpdate.ParentID
}
}
if _, exists := allInputs["icon"]; exists {
menu.Icon = menuUpdate.Icon
}
if _, exists := allInputs["path"]; exists {
menu.Path = menuUpdate.Path
}
if _, exists := allInputs["component"]; exists {
menu.Component = menuUpdate.Component
}
if _, exists := allInputs["permission"]; exists {
menu.Permission = menuUpdate.Permission
}
if _, exists := allInputs["type"]; exists {
menu.Type = menuUpdate.Type
}
if _, exists := allInputs["status"]; exists {
menu.Status = menuUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
menu.Sort = menuUpdate.Sort
}
if _, exists := allInputs["is_hidden"]; exists {
menu.IsHidden = menuUpdate.IsHidden
}
if _, exists := allInputs["link_type"]; exists {
menu.LinkType = menuUpdate.LinkType
}
if _, exists := allInputs["open_type"]; exists {
menu.OpenType = menuUpdate.OpenType
}
if err := r.menuService.Update(menu); err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"menu_id": menu.ID,
})
}
return response.Success(ctx, http.Json{
"menu": *menu,
})
}
func (r *MenuController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
menu, resp := r.findMenuByID(ctx, id)
if resp != nil {
return resp
}
hasChildren, err := r.treeService.HasMenuChildren(id)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
if hasChildren {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrMenuHasChildren.Code)
}
if err := r.menuService.Delete(menu); err != nil {
return response.ErrorWithLog(ctx, "menu", err, map[string]any{
"menu_id": menu.ID,
})
}
return response.Success(ctx)
}
// isDeveloperAdmin 检查是否是开发者管理员
func (r *MenuController) isDeveloperAdmin(adminID uint, developerIDsStr string) bool {
if developerIDsStr == "" {
return false
}
// 解析开发者ID列表
parts := str.Of(developerIDsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 && id == adminID {
return true
}
}
}
return false
}
// filterMonitorMenu 递归过滤掉服务监控菜单
func (r *MenuController) filterMonitorMenu(menus []models.Menu) []models.Menu {
var filteredMenus []models.Menu
for _, menu := range menus {
// 如果当前菜单不是服务监控菜单,则保留
if menu.Slug != "monitor" {
// 递归过滤子菜单
if len(menu.Children) > 0 {
menu.Children = r.filterMonitorMenu(menu.Children)
}
filteredMenus = append(filteredMenus, menu)
}
}
return filteredMenus
}
// filterDevMenu 递归过滤掉开发工具菜单
func (r *MenuController) filterDevMenu(menus []models.Menu) []models.Menu {
var filteredMenus []models.Menu
for _, menu := range menus {
// 如果当前菜单不是开发工具菜单,则保留
if menu.Slug != "dev" {
// 递归过滤子菜单
if len(menu.Children) > 0 {
menu.Children = r.filterDevMenu(menu.Children)
}
filteredMenus = append(filteredMenus, menu)
}
}
return filteredMenus
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
)
type NotificationController struct {
service services.NotificationService
}
func NewNotificationController() *NotificationController {
return &NotificationController{
service: services.NewNotificationServiceImpl(),
}
}
func (r *NotificationController) Index(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
notifType := ctx.Request().Query("type", "")
isRead := ctx.Request().Query("is_read", "")
notifications, total, err := r.service.List(admin.ID, page, pageSize, notifType, isRead)
if err != nil {
logger.ErrorfHTTP(ctx, "list notifications error: %v", err)
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
count, err := r.service.UnreadCount(admin.ID)
if err != nil {
logger.ErrorfHTTP(ctx, "unread count error: %v", err)
}
return response.Success(ctx, http.Json{
"notifications": notifications,
"unread_count": count,
"pagination": http.Json{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
},
})
}
func (r *NotificationController) UnreadCount(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
count, err := r.service.UnreadCount(admin.ID)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, http.Json{
"count": count,
})
}
func (r *NotificationController) Recent(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
limit := helpers.GetIntQuery(ctx, "limit", 5)
notifications, err := r.service.ListRecent(admin.ID, limit)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
count, _ := r.service.UnreadCount(admin.ID)
return response.Success(ctx, http.Json{
"notifications": notifications,
"unread_count": count,
})
}
func (r *NotificationController) MarkRead(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsRequired.Code)
}
if err := r.service.MarkRead(admin.ID, id); err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
return response.Success(ctx, http.Json{
"id": id,
})
}
func (r *NotificationController) MarkAllRead(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
if err := r.service.MarkAllRead(admin.ID); err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
return response.Success(ctx)
}
func (r *NotificationController) Store(ctx http.Context) http.Response {
admin := r.currentAdmin(ctx)
if admin == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
title := ctx.Request().Input("title")
content := ctx.Request().Input("content")
notificationType := ctx.Request().Input("type", "announcement")
if title == "" || content == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsRequired.Code)
}
var receiverID *uint
receiverVal := ctx.Request().Input("receiver_id")
if receiverVal != "" {
id := cast.ToUint(receiverVal)
if id > 0 {
receiverID = &id
}
}
senderID := admin.ID
notification, err := r.service.Create(title, content, notificationType, &senderID, receiverID)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if notification == nil {
return response.Success(ctx)
}
return response.Success(ctx, http.Json{
"notification": notification,
})
}
func (r *NotificationController) currentAdmin(ctx http.Context) *models.Admin {
if adminValue := ctx.Value("admin"); adminValue != nil {
if admin, ok := adminValue.(models.Admin); ok {
return &admin
}
if adminPtr, ok := adminValue.(*models.Admin); ok {
return adminPtr
}
}
return nil
}
@@ -0,0 +1,83 @@
package admin
import (
"net/http"
apphttp "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/gorilla/websocket"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
wsnotifications "goravel/app/websocket/notifications"
)
type NotificationWsController struct {
tokenService services.TokenService
}
func NewNotificationWsController() *NotificationWsController {
return &NotificationWsController{
tokenService: services.NewTokenServiceImpl(),
}
}
func (r *NotificationWsController) Server(ctx apphttp.Context) apphttp.Response {
// 记录 WebSocket 连接尝试(仅 Debug 模式)
logger.DebugfHTTP(ctx, "WebSocket connection attempt from %s, path: %s, upgrade: %s, connection: %s",
ctx.Request().Ip(),
ctx.Request().Path(),
ctx.Request().Header("Upgrade", ""),
ctx.Request().Header("Connection", ""))
token := ctx.Request().Query("token")
if token == "" {
logger.WarnfHTTP(ctx, "WebSocket connection rejected: token required")
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "token_required",
}).Abort()
return nil
}
token = str.Of(token).ChopStart("Bearer ").Trim().String()
accessToken, err := r.tokenService.FindToken(token)
if err != nil || accessToken == nil || accessToken.TokenableType != "admin" {
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "invalid_token",
}).Abort()
return nil
}
var admin models.Admin
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).FirstOrFail(&admin); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, apphttp.Json{
"code": http.StatusUnauthorized,
"message": "user_not_found",
}).Abort()
return nil
}
_ = r.tokenService.UpdateLastUsedAt(token)
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 1024, // 读缓冲区大小
WriteBufferSize: 1024, // 写缓冲区大小
}
conn, err := upgrader.Upgrade(ctx.Response().Writer(), ctx.Request().Origin(), nil)
if err != nil {
logger.ErrorfHTTP(ctx, "notification ws upgrade error: %v", err)
return ctx.Response().String(http.StatusInternalServerError, "upgrade_failed")
}
// logger.InfofHTTP(ctx, "WebSocket connection established for admin ID: %d", admin.ID)
wsnotifications.Hub().RegisterConnection(conn, admin.ID)
return nil
}
@@ -0,0 +1,215 @@
package admin
import (
"strings"
"time"
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
)
type OnlineAdminController struct {
}
func NewOnlineAdminController() *OnlineAdminController {
return &OnlineAdminController{}
}
// Index 获取在线管理员列表
// buildQuery 构建在线管理员查询(基于 token)
func (r *OnlineAdminController) buildQuery(ctx http.Context) orm.Query {
ip := ctx.Request().Query("ip", "")
browser := ctx.Request().Query("browser", "")
os := ctx.Request().Query("os", "")
// 只查询最近15分钟内有活动的token(在线管理员)
// 默认只显示admin类型的token
onlineThreshold := time.Now().Add(-constants.OnlineAdminThreshold)
query := facades.Orm().Query().Model(&models.PersonalAccessToken{}).
Where("tokenable_type", "admin").
Where("last_used_at IS NOT NULL").
Where("last_used_at >= ?", onlineThreshold)
// 搜索条件
if ip != "" {
query = query.Where("ip LIKE ?", "%"+ip+"%")
}
if browser != "" {
query = query.Where("browser LIKE ?", "%"+browser+"%")
}
if os != "" {
query = query.Where("os LIKE ?", "%"+os+"%")
}
orderBy := ctx.Request().Query("order_by", "")
// 应用排序,默认排序为 last_used_at desc
query = helpers.ApplySort(query, orderBy, "last_used_at:desc")
return query
}
// 只显示最近15分钟内有活动的管理员(根据 OnlineAdminThreshold 常量判断)
func (r *OnlineAdminController) Index(ctx http.Context) http.Response {
// 验证并规范化分页参数
page, pageSize := helpers.ValidatePagination(
helpers.GetIntQuery(ctx, "page", 1),
helpers.GetIntQuery(ctx, "page_size", 10),
)
username := ctx.Request().Query("username", "")
query := r.buildQuery(ctx)
var tokens []models.PersonalAccessToken
if err := query.Get(&tokens); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err)
}
// 批量查询所有 admin 信息,避免 N+1 查询
var adminIDs []uint
adminIDMap := make(map[uint]bool) // 用于去重
for _, token := range tokens {
if !adminIDMap[token.TokenableID] {
adminIDs = append(adminIDs, token.TokenableID)
adminIDMap[token.TokenableID] = true
}
}
// 批量查询 admin(排除开发者ID)
adminMap := make(map[uint]models.Admin)
if len(adminIDs) > 0 {
// 获取开发者ID列表并过滤
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
developerIDs := r.parseProtectedIDs(developerIDsStr)
query := facades.Orm().Query().Where("id IN ?", adminIDs)
if len(developerIDs) > 0 {
query = query.Where("id NOT IN ?", developerIDs)
}
var admins []models.Admin
if err := query.Find(&admins); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"admin_ids": adminIDs,
})
}
// 构建 admin map
for _, admin := range admins {
adminMap[admin.ID] = admin
}
}
// 组装数据,同时过滤 username
var onlineAdmins []http.Json
for _, token := range tokens {
admin, ok := adminMap[token.TokenableID]
if !ok {
continue
}
// 如果指定了username搜索条件,进行过滤
if username != "" && !strings.Contains(strings.ToLower(admin.Username), strings.ToLower(username)) {
continue
}
onlineAdmin := http.Json{
"id": token.ID,
"admin_id": admin.ID,
"username": admin.Username,
"nickname": admin.Nickname,
"avatar": admin.Avatar,
"browser": token.Browser,
"ip": token.IP,
"os": token.OS,
"session_id": token.SessionID,
"last_active": token.LastUsedAt,
"created_at": token.CreatedAt,
}
onlineAdmins = append(onlineAdmins, onlineAdmin)
}
// 使用工具函数进行分页
paginatedAdmins, total := helpers.PaginateSlice(onlineAdmins, page, pageSize)
return response.Paginate(ctx, paginatedAdmins, total, page, pageSize)
}
// KickOut 踢下线(删除token
func (r *OnlineAdminController) KickOut(ctx http.Context) http.Response {
tokenID := helpers.GetUintRoute(ctx, "id")
if tokenID == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDRequired.Code)
}
// 查询token是否存在
var token models.PersonalAccessToken
if err := facades.Orm().Query().Where("id", tokenID).FirstOrFail(&token); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrTokenNotFound.Code)
}
// 删除token
if _, err := facades.Orm().Query().Delete(&token); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"token_id": tokenID,
})
}
return response.Success(ctx, "kick_out_success")
}
// BatchKickOut 批量踢下线
func (r *OnlineAdminController) BatchKickOut(ctx http.Context) http.Response {
tokenIDs := ctx.Request().Input("token_ids")
if tokenIDs == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrTokenIDsRequired.Code)
}
// 使用工具函数解析 token IDs
ids := helpers.ParseIDsFromString(tokenIDs)
if len(ids) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTokenIDs.Code)
}
// 批量删除token
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.PersonalAccessToken{}); err != nil {
return response.ErrorWithLog(ctx, "online_admin", err, map[string]any{
"token_ids": ids,
})
}
return response.Success(ctx, "batch_kick_out_success", http.Json{
"count": len(ids),
})
}
// parseProtectedIDs 解析受保护的管理员ID字符串(支持逗号分隔)
func (r *OnlineAdminController) parseProtectedIDs(idsStr string) []uint {
var ids []uint
if idsStr == "" {
return ids
}
// 使用字符串分割
parts := str.Of(idsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
if id := cast.ToUint(part); id > 0 {
ids = append(ids, id)
}
}
}
return ids
}
@@ -0,0 +1,239 @@
package admin
import (
"fmt"
"sort"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/samber/lo"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type OperationLogController struct {
operationLogService services.OperationLogService
}
func NewOperationLogController() *OperationLogController {
return &OperationLogController{
operationLogService: services.NewOperationLogService(),
}
}
// findOperationLogByID 根据ID查找操作日志,如果不存在则返回错误响应
// withAdmin 为 true 时会预加载 Admin 关联
func (r *OperationLogController) findOperationLogByID(ctx http.Context, id uint, withAdmin bool) (*models.OperationLog, http.Response) {
log, err := r.operationLogService.GetByID(id, withAdmin)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *OperationLogController) buildFilters(ctx http.Context) services.OperationLogFilters {
adminID := ctx.Request().Query("admin_id", "")
username := ctx.Request().Query("username", "")
method := ctx.Request().Query("method", "")
path := ctx.Request().Query("path", "")
title := ctx.Request().Query("title", "")
ip := ctx.Request().Query("ip", "")
status := ctx.Request().Query("status", "")
request := ctx.Request().Query("request", "")
startTimeStr := helpers.GetTimeQueryParam(ctx, "start_time")
endTimeStr := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.OperationLogFilters{
AdminID: adminID,
Username: username,
Method: method,
Path: path,
Title: title,
IP: ip,
Status: status,
Request: request,
StartTime: startTimeStr,
EndTime: endTimeStr,
OrderBy: orderBy,
}
}
// Index 获取操作日志列表
func (r *OperationLogController) Index(ctx http.Context) http.Response {
// 验证时间范围(操作日志查询限制为3个月,可通过配置修改)
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
// 如果只填了开始时间,结束时间默认为当前时间
if startTimeStr != "" {
startTimeUTC := helpers.GetTimeQueryParam(ctx, "start_time")
if startTimeUTC != "" {
startTime, err1 := utils.ParseDateTime(startTimeUTC)
if err1 == nil {
// 如果结束时间为空,使用当前时间
var endTime time.Time
if endTimeStr != "" {
endTimeUTC := helpers.GetTimeQueryParam(ctx, "end_time")
if endTimeUTC != "" {
var err2 error
endTime, err2 = utils.ParseDateTime(endTimeUTC)
if err2 != nil {
endTime = time.Now().UTC()
}
} else {
endTime = time.Now().UTC()
}
} else {
endTime = time.Now().UTC()
}
valid, err := utils.ValidateTimeRange(startTime, endTime, 3)
if !valid {
// 如果是 TimeRangeError,使用翻译键和参数进行翻译
if timeRangeErr, ok := err.(*utils.TimeRangeError); ok {
message := trans.Get(ctx, timeRangeErr.Key)
// 如果有参数,替换占位符 {key}
if timeRangeErr.Params != nil {
for key, value := range timeRangeErr.Params {
placeholder := fmt.Sprintf("{%s}", key)
message = strings.ReplaceAll(message, placeholder, fmt.Sprintf("%v", value))
}
}
return response.Error(ctx, http.StatusBadRequest, message)
}
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
}
}
}
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.operationLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "operation-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取操作日志详情
func (r *OperationLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findOperationLogByID(ctx, id, true) // 预加载 Admin 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除操作日志
func (r *OperationLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findOperationLogByID(ctx, id, false)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type OperationLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除操作日志
func (r *OperationLogController) BatchDestroy(ctx http.Context) http.Response {
var req OperationLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.OperationLog{}); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理操作日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *OperationLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.OperationLog{}).Where("created_at < ?", cutoffTime).Delete(&models.OperationLog{}); err != nil {
return response.ErrorWithLog(ctx, "operation-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
// GetTitleOptions 获取所有可用的操作标题选项
func (r *OperationLogController) GetTitleOptions(ctx http.Context) http.Response {
// 从数据库查询已存在的标题(现在标题直接存权限标识 slug,如 admin.update
var dbTitles []string
_ = facades.Orm().Query().Model(&models.OperationLog{}).
Select("DISTINCT title").
Where("title IS NOT NULL AND title != ''"). // 排除空标题
Order("title ASC").
Pluck("title", &dbTitles)
// 过滤并去重标题(权限标识),忽略旧的 operation.xxx 配置
result := lo.Uniq(lo.Filter(dbTitles, func(title string, _ int) bool {
// 排除空标题、未知标题以及旧的 operation.xxx 标题
return title != "" && title != "operation.unknown" && !strings.HasPrefix(title, "operation.")
}))
sort.Strings(result)
return response.Success(ctx, http.Json{
"titles": result,
})
}
@@ -0,0 +1,102 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/services"
"goravel/app/services/option_providers"
)
type OptionController struct {
providers map[string]services.OptionProvider
dictionaryService services.DictionaryService
}
func NewOptionController() *OptionController {
// 注册所有选项提供者
// 添加新的选项类型时,只需:
// 1. 在 app/services/option_providers/ 目录下创建新的提供者文件
// 2. 实现 services.OptionProvider 接口
// 3. 在此处注册新的提供者
providers := make(map[string]services.OptionProvider)
providers["role"] = option_providers.NewRoleOptionProvider()
providers["department"] = option_providers.NewDepartmentOptionProvider()
providers["menu"] = option_providers.NewMenuOptionProvider(services.NewTreeServiceImpl())
providers["status"] = option_providers.NewStatusOptionProvider()
providers["method"] = option_providers.NewMethodOptionProvider()
providers["yes_no"] = option_providers.NewYesNoOptionProvider()
providers["admin"] = option_providers.NewAdminOptionProvider()
providers["payment_method"] = option_providers.NewPaymentMethodOptionProvider()
// 在此处添加新的选项提供者,例如:
// providers["new_type"] = option_providers.NewNewTypeOptionProvider()
return &OptionController{
providers: providers,
dictionaryService: services.NewDictionaryService(),
}
}
// Index 获取选项列表
// 通过 type 参数指定选项类型,例如: /options?type=role
func (r *OptionController) Index(ctx http.Context) http.Response {
optionType := ctx.Request().Query("type", "")
if optionType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOptionTypeRequired.Code)
}
// 如果 type 是 "dictionary",则需要进一步检查 dictionary_type 参数
if optionType == "dictionary" {
dictType := ctx.Request().Query("dictionary_type", "")
if dictType == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOptionTypeRequired.Code)
}
dictionaries, err := r.dictionaryService.GetByType(dictType)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
// 转换为选项格式,并处理多语言
var options []map[string]any
for _, dict := range dictionaries {
label := dict.Label
// 如果有 translation_key,优先使用翻译键
if dict.TranslationKey != "" {
translated := trans.Get(ctx, dict.TranslationKey)
// 只有当翻译结果不同于 key 本身(表示找到了翻译)且不为空时才使用
if translated != "" && translated != dict.TranslationKey {
label = translated
}
}
options = append(options, map[string]any{
"label": label,
"value": dict.Value,
})
}
return response.Success(ctx, options)
}
provider, exists := r.providers[optionType]
if !exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidOptionType.Code)
}
data, err := provider.GetOptions(ctx)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrQueryFailed.Code)
}
return response.Success(ctx, data)
}
// RegisterProvider 注册新的选项提供者(可选,用于动态注册)
// 如果需要在运行时动态添加提供者,可以使用此方法
func (r *OptionController) RegisterProvider(optionType string, provider services.OptionProvider) {
r.providers[optionType] = provider
}
@@ -0,0 +1,743 @@
package admin
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type OrderController struct {
orderService services.OrderService
}
// OrderProductItem 订单商品项(用于 Swagger 文档)
type OrderProductItem struct {
ProductID uint `json:"product_id" example:"1" binding:"required"` // 商品ID
ProductName string `json:"product_name" example:"商品名称" binding:"required"` // 商品名称
Price float64 `json:"price" example:"99.99" binding:"required"` // 单价
Quantity int `json:"quantity" example:"2" binding:"required"` // 数量
}
func NewOrderController() *OrderController {
return &OrderController{
orderService: services.NewOrderService(),
}
}
// buildFilters 构建筛选条件(列表和导出共用)
// 同时支持查询参数(GET)和请求体参数(POST)
func (r *OrderController) buildFilters(ctx http.Context) (services.OrderFilters, http.Response) {
// 优先从请求体读取,如果没有则从查询参数读取(兼容 GET 和 POST)
userID := cast.ToUint(ctx.Request().Input("user_id", ctx.Request().Query("user_id", "0")))
orderNo := ctx.Request().Input("order_no", ctx.Request().Query("order_no", ""))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
minAmount := cast.ToFloat64(ctx.Request().Input("min_amount", ctx.Request().Query("min_amount", "0")))
maxAmount := cast.ToFloat64(ctx.Request().Input("max_amount", ctx.Request().Query("max_amount", "0")))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 解析时间参数(使用 GetTimeQueryParam 处理时区转换)
// GetTimeQueryParam 会自动从查询参数读取并转换为 UTC 时间字符串
// 如果查询参数不存在,尝试从请求体读取
startTimeStr := ctx.Request().Query("start_time", "")
if startTimeStr == "" {
startTimeStr = ctx.Request().Input("start_time", "")
}
endTimeStr := ctx.Request().Query("end_time", "")
if endTimeStr == "" {
endTimeStr = ctx.Request().Input("end_time", "")
}
startTime, endTime, err := r.parseTimeRange(ctx, startTimeStr, endTimeStr)
if err != nil {
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
// 验证时间范围(订单查询限制为3个月,可通过配置修改)
valid, err := utils.ValidateTimeRange(startTime, endTime, 3)
if !valid {
// 如果是 TimeRangeError,使用翻译键和参数进行翻译
if timeRangeErr, ok := err.(*utils.TimeRangeError); ok {
message := trans.Get(ctx, timeRangeErr.Key)
// 如果有参数,替换占位符 {key}
if timeRangeErr.Params != nil {
for key, value := range timeRangeErr.Params {
placeholder := fmt.Sprintf("{%s}", key)
message = strings.ReplaceAll(message, placeholder, fmt.Sprintf("%v", value))
}
}
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, message)
}
return services.OrderFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
return services.OrderFilters{
UserID: userID,
OrderNo: orderNo,
Status: status,
MinAmount: minAmount,
MaxAmount: maxAmount,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}, nil
}
// parseTimeRange 解析时间范围(默认最近1周)
func (r *OrderController) parseTimeRange(ctx http.Context, startTimeStr, endTimeStr string) (time.Time, time.Time, error) {
var startTime, endTime time.Time
var err error
if startTimeStr == "" {
// 默认查询最近1周(UTC 时间)
startTime = time.Now().UTC().AddDate(0, 0, -7)
} else {
// 使用 ConvertTimeToUTC 处理时区转换(将本地时区转换为 UTC)
utcTimeStr := helpers.ConvertTimeToUTC(ctx, startTimeStr)
if utcTimeStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_start_time")
}
// 解析 UTC 时间字符串
startTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_start_time")
}
}
if endTimeStr == "" {
// 不传结束时间则不限制,返回零值(WHERE 条件中会跳过)
endTime = time.Time{}
} else {
// 使用 ConvertTimeToUTC 处理时区转换(将本地时区转换为 UTC)
utcTimeStr := helpers.ConvertTimeToUTC(ctx, endTimeStr)
if utcTimeStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_end_time")
}
// 解析 UTC 时间字符串
endTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid_end_time")
}
}
return startTime, endTime, nil
}
// formatOrderStatus 格式化订单状态文本
func (r *OrderController) formatOrderStatus(ctx http.Context, status string) string {
switch status {
case "pending":
return trans.Get(ctx, "export_order_status_pending")
case "paid":
return trans.Get(ctx, "export_order_status_paid")
case "cancelled":
return trans.Get(ctx, "export_order_status_cancelled")
default:
return status
}
}
// formatTime 格式化时间为字符串(支持 time.Time 和 carbon.DateTime
func (r *OrderController) formatTime(t any) string {
if t == nil {
return ""
}
switch v := t.(type) {
case time.Time:
return utils.FormatDateTime(v)
case *time.Time:
return utils.FormatDateTimePtr(v)
case carbon.DateTime:
if v.IsZero() {
return ""
}
return v.ToDateTimeString()
case *carbon.DateTime:
if v == nil || v.IsZero() {
return ""
}
return v.ToDateTimeString()
default:
// 尝试转换为字符串(其他类型)
if str := fmt.Sprintf("%v", t); str != "" && str != "<nil>" {
return str
}
return ""
}
}
// convertOrderToJson 转换订单为响应格式
func (r *OrderController) convertOrderToJson(order models.Order) http.Json {
return http.Json{
"id": order.ID,
"order_no": order.OrderNo,
"user_id": order.UserID,
"amount": order.Amount,
"status": order.Status,
"remark": order.Remark,
"created_at": order.CreatedAt,
"updated_at": order.UpdatedAt,
}
}
// Index 订单列表
// @Summary 获取订单列表
// @Description 分页获取订单列表,支持多条件筛选,查询时间范围不能超过3个月
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param user_id query int false "用户ID"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param status query string false "订单状态(pending/paid/cancelled"
// @Param min_amount query float64 false "最小金额"
// @Param max_amount query float64 false "最大金额"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders [get]
// @Security BearerAuth
func (r *OrderController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
// 构建筛选条件(列表和导出共用)
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 查询订单(包含详情)
ordersWithDetails, total, err := r.orderService.GetOrdersWithDetails(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据
orderList := make([]http.Json, len(ordersWithDetails))
for i, orderWithDetails := range ordersWithDetails {
orderJson := r.convertOrderToJson(orderWithDetails.Order)
// 添加订单详情
detailsList := make([]http.Json, len(orderWithDetails.Details))
for j, detail := range orderWithDetails.Details {
detailsList[j] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
"created_at": detail.CreatedAt,
"updated_at": detail.UpdatedAt,
}
}
orderJson["details"] = detailsList
orderList[i] = orderJson
}
return response.Success(ctx, http.Json{
"data": orderList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 订单详情
// @Summary 获取订单详情
// @Description 根据订单号获取订单详细信息,返回订单主表数据和订单详情表数据(支持分表查询)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Success 200 {object} map[string]any "返回数据包含 order(订单主表)和 details(订单详情表数组)"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "订单不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [get]
// @Security BearerAuth
func (r *OrderController) Show(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
order, details, err := r.orderService.GetOrderByOrderNo(orderNo)
if err != nil {
return response.Error(ctx, http.StatusNotFound, "order_not_found")
}
return r.buildOrderDetailResponse(ctx, order, details)
}
// buildOrderDetailResponse 构建订单详情响应(提取公共逻辑)
func (r *OrderController) buildOrderDetailResponse(ctx http.Context, order *models.Order, details []models.OrderDetail) http.Response {
// 转换订单主表数据(使用统一的方法)
orderJson := r.convertOrderToJson(*order)
// 转换订单详情数据
detailList := make([]http.Json, len(details))
for i, detail := range details {
detailList[i] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
"created_at": detail.CreatedAt,
"updated_at": detail.UpdatedAt,
}
}
// 返回主表和详情表数据
return response.Success(ctx, http.Json{
"order": orderJson,
"details": detailList,
})
}
// Store 创建订单
// @Summary 创建订单
// @Description 创建新订单,自动防止重复提交
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param user_id body int true "用户ID"
// @Param amount body float64 true "订单金额"
// @Param products body []OrderProductItem true "商品列表"
// @Param request_id body string false "请求ID(用于防重复提交,不传则自动生成)"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误或重复提交"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders [post]
// @Security BearerAuth
func (r *OrderController) Store(ctx http.Context) http.Response {
var req struct {
UserID uint `json:"user_id" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
Products []services.OrderProduct `json:"products" binding:"required"`
RequestID string `json:"request_id"`
Remark string `json:"remark"`
}
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_params")
}
if len(req.Products) == 0 {
return response.Error(ctx, http.StatusBadRequest, "empty_products")
}
// 创建订单
order, details, err := r.orderService.CreateOrder(req.UserID, req.Amount, req.Products, req.RequestID, req.Remark)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, "create_failed")
}
// 转换订单详情
detailList := make([]http.Json, len(details))
for i, detail := range details {
detailList[i] = http.Json{
"id": detail.ID,
"order_id": detail.OrderID,
"product_id": detail.ProductID,
"product_name": detail.ProductName,
"price": detail.Price,
"quantity": detail.Quantity,
"subtotal": detail.Subtotal,
}
}
orderJson := r.convertOrderToJson(*order)
// 移除不需要的字段(创建订单时不需要返回 created_at 和 updated_at
delete(orderJson, "created_at")
delete(orderJson, "updated_at")
return response.Success(ctx, http.Json{
"order": orderJson,
"details": detailList,
})
}
// Update 更新订单
// @Summary 更新订单
// @Description 更新订单信息(主要是状态)。使用订单号查询(可直接定位分表)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Param status body string true "订单状态"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [put]
// @Security BearerAuth
func (r *OrderController) Update(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
var req struct {
Status string `json:"status" binding:"required"`
Remark string `json:"remark"`
}
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, "invalid_params")
}
if err := r.orderService.UpdateOrderByOrderNo(orderNo, req.Status, req.Remark); err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"order_no": orderNo,
"status": req.Status,
"remark": req.Remark,
})
}
return response.Success(ctx)
}
// Destroy 删除订单
// @Summary 删除订单
// @Description 删除订单及其详情。使用订单号查询(可直接定位分表)
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path string true "订单号"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/{id} [delete]
// @Security BearerAuth
func (r *OrderController) Destroy(ctx http.Context) http.Response {
// 使用订单号查询(可直接定位分表)
orderNo := ctx.Request().Route("id")
if orderNo == "" {
return response.Error(ctx, http.StatusBadRequest, "order_no_required")
}
if err := r.orderService.DeleteOrderByOrderNo(orderNo); err != nil {
return response.ErrorWithLog(ctx, "order", err, map[string]any{
"order_no": orderNo,
})
}
return response.Success(ctx)
}
// Export 导出订单列表
// @Summary 导出订单列表
// @Description 根据筛选条件导出订单列表为CSV文件,支持与列表查询相同的筛选条件,查询时间范围不能超过3个月
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param user_id query int false "用户ID"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param status query string false "订单状态(pending/paid/cancelled"
// @Param min_amount query float64 false "最小金额"
// @Param max_amount query float64 false "最大金额"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any "导出成功,返回文件下载信息"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/export [post]
// @Security BearerAuth
func (r *OrderController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击:使用框架自带的原子锁(锁会在10秒后自动过期,防止短时间内重复请求)
lockKey := fmt.Sprintf("export:orders:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
// 尝试获取锁,如果获取失败则返回错误
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 构建筛选条件
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 创建导出记录(状态为处理中)
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypeOrders,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "", // 处理完成后更新
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 将筛选条件序列化为 JSON
filtersMap := map[string]any{
"user_id": filters.UserID,
"order_no": filters.OrderNo,
"status": filters.Status,
"min_amount": filters.MinAmount,
"max_amount": filters.MaxAmount,
"order_by": filters.OrderBy,
}
if !filters.StartTime.IsZero() {
filtersMap["start_time"] = utils.FormatDateTime(filters.StartTime)
}
if !filters.EndTime.IsZero() {
filtersMap["end_time"] = utils.FormatDateTime(filters.EndTime)
}
// 获取当前语言(从请求头或查询参数,与 middleware 逻辑一致)
lang := r.getCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
// 异步执行导出任务(使用 Job
// 将参数序列化为 JSON 字符串传递,避免框架对复杂类型的序列化问题
exportArgsStruct := jobs.ExportOrdersArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "orders",
Language: lang,
Timezone: timezone,
}
// 序列化为 JSON 字符串
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
// 记录任务提交日志
facades.Log().Infof("提交导出任务到队列: export_id=%d, queue_driver=%s, args_json=%s",
exportRecord.ID, facades.Config().GetString("queue.default"), string(exportArgsJSON))
// 使用 queue.Arg 包装 JSON 字符串参数
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
// 传递 JSON 字符串作为参数,使用 long-running 队列,避免长时间运行的导出任务影响其他队列任务
// 所有耗时任务(导出、报表生成、批量处理等)都应该使用 long-running 队列
if err := facades.Queue().Job(&jobs.ExportOrders{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
// 如果任务提交失败,立即释放锁,让用户可以立即重试
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("导出任务已成功提交到队列: export_id=%d", exportRecord.ID)
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": trans.Get(ctx, "export_task_submitted"),
})
}
// GetExportStatus 查询导出状态
// @Summary 查询导出状态
// @Description 根据导出记录ID查询导出任务的状态
// @Tags 订单管理
// @Accept json
// @Produce json
// @Param id path int true "导出记录ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/export/status/{id} [get]
// @Security BearerAuth
func (r *OrderController) GetExportStatus(ctx http.Context) http.Response {
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, "export_id_required")
}
exportRecordService := services.NewExportRecordService()
exportRecord, err := exportRecordService.GetByID(exportID)
if err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 检查权限:只能查看自己的导出记录
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
if exportRecord.AdminID != adminID {
return response.Error(ctx, http.StatusForbidden, "forbidden")
}
// 生成文件URL
fileURL := ""
if exportRecord.Path != "" && exportRecord.Status == models.ExportStatusSuccess {
exportService := services.NewExportService(ctx)
if exportRecord.Disk == "local" || exportRecord.Disk == "public" {
fileURL = fmt.Sprintf("/api/admin/exports/%d/download", exportRecord.ID)
} else {
fileURL = exportService.GetExportURL(exportRecord.Path)
}
}
return response.Success(ctx, http.Json{
"id": exportRecord.ID,
"status": exportRecord.Status,
"status_text": r.getExportStatusText(ctx, exportRecord.Status),
"file_url": fileURL,
"filename": exportRecord.Filename,
"size": exportRecord.Size,
"error_msg": exportRecord.ErrorMsg,
"created_at": exportRecord.CreatedAt.ToDateTimeString(),
"updated_at": exportRecord.UpdatedAt.ToDateTimeString(),
})
}
func (r *OrderController) getExportStatusText(ctx http.Context, status uint8) string {
switch status {
case models.ExportStatusProcessing:
return trans.Get(ctx, "export_task_status_processing")
case models.ExportStatusSuccess:
return trans.Get(ctx, "export_task_status_success")
case models.ExportStatusFailed:
return trans.Get(ctx, "export_task_status_failed")
default:
return trans.Get(ctx, "export_task_status_unknown")
}
}
// getCurrentLanguage 获取当前请求的语言(使用通用工具函数)
func (r *OrderController) getCurrentLanguage(ctx http.Context) string {
return utils.GetCurrentLanguage(ctx)
}
// Import 导入订单
// @Summary 导入订单
// @Description 从CSV文件导入订单数据,支持批量导入
// @Tags 订单管理
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "CSV文件"
// @Success 200 {object} map[string]any "导入成功,返回导入结果"
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 401 {object} map[string]any "未登录"
// @Failure 403 {object} map[string]any "无权限"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/orders/import [post]
// @Security BearerAuth
func (r *OrderController) Import(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 获取上传的文件
file, err := ctx.Request().File("file")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, "file_required")
}
// 验证文件类型(只允许CSV
filename := file.GetClientOriginalName()
if !strings.HasSuffix(strings.ToLower(filename), ".csv") {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidFileType.Code)
}
// 读取文件内容
storage := facades.Storage().Disk("local")
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
})
}
// 读取文件内容
csvContent, err := storage.Get(savedPath)
if err != nil {
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
})
}
// 清理临时文件
defer func() {
_ = storage.Delete(savedPath)
}()
// 导入订单
importService := services.NewImportOrderService(ctx)
result, err := importService.ImportOrders(csvContent)
if err != nil {
return response.ErrorWithLog(ctx, "import", err, map[string]any{
"filename": filename,
"admin_id": adminID,
})
}
return response.Success(ctx, http.Json{
"total_rows": result.TotalRows,
"success_count": result.SuccessCount,
"failed_count": result.FailedCount,
"errors": result.Errors,
"message": trans.Get(ctx, "import_success"),
})
}
@@ -0,0 +1,102 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
)
type PasswordController struct {
}
func NewPasswordController() *PasswordController {
return &PasswordController{}
}
func (r *PasswordController) UpdatePassword(ctx http.Context) http.Response {
adminValue := ctx.Value("admin")
if adminValue == nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
var admin models.Admin
if adminVal, ok := adminValue.(models.Admin); ok {
admin = adminVal
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
admin = *adminPtr
} else {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
if err := facades.Orm().Query().Where("id", admin.ID).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
// 使用请求验证
var updatePasswordRequest adminrequests.UpdatePassword
errors, err := ctx.Request().ValidateRequest(&updatePasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证旧密码是否正确
if !facades.Hash().Check(updatePasswordRequest.OldPassword, admin.Password) {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrOldPasswordError.Code)
}
hashedPassword, err := facades.Hash().Make(updatePasswordRequest.NewPassword)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "password_update_success")
}
// ResetPassword 重置密码(管理员操作)
func (r *PasswordController) ResetPassword(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var resetPasswordRequest adminrequests.ResetPassword
errors, err := ctx.Request().ValidateRequest(&resetPasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
var admin models.Admin
if err := facades.Orm().Query().Where("id", id).FirstOrFail(&admin); err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrAdminNotFound.Code)
}
hashedPassword, err := facades.Hash().Make(resetPasswordRequest.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrPasswordEncryptFailed.Code)
}
admin.Password = hashedPassword
if err := facades.Orm().Query().Save(&admin); err != nil {
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"admin_id": admin.ID,
})
}
return response.Success(ctx, "password_reset_success")
}
@@ -0,0 +1,421 @@
package admin
import (
"encoding/json"
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/http/trans"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type PaymentController struct {
paymentService services.PaymentService
}
func NewPaymentController() *PaymentController {
return &PaymentController{
paymentService: services.NewPaymentService(),
}
}
// buildFilters 构建筛选条件(列表和导出共用)
func (r *PaymentController) buildFilters(ctx http.Context) (services.PaymentFilters, http.Response) {
paymentNo := ctx.Request().Input("payment_no", ctx.Request().Query("payment_no", ""))
orderNo := ctx.Request().Input("order_no", ctx.Request().Query("order_no", ""))
paymentMethodID := cast.ToUint(ctx.Request().Input("payment_method_id", ctx.Request().Query("payment_method_id", "0")))
userID := cast.ToUint(ctx.Request().Input("user_id", ctx.Request().Query("user_id", "0")))
status := ctx.Request().Input("status", ctx.Request().Query("status", ""))
orderBy := ctx.Request().Input("order_by", ctx.Request().Query("order_by", ""))
// 解析时间参数
startTimeStr := ctx.Request().Query("start_time", "")
if startTimeStr == "" {
startTimeStr = ctx.Request().Input("start_time", "")
}
endTimeStr := ctx.Request().Query("end_time", "")
if endTimeStr == "" {
endTimeStr = ctx.Request().Input("end_time", "")
}
var startTime, endTime time.Time
var err error
if startTimeStr != "" {
utcTimeStr := helpers.ConvertTimeToUTC(ctx, startTimeStr)
if utcTimeStr == "" {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_start_time")
}
startTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_start_time")
}
}
if endTimeStr != "" {
utcTimeStr := helpers.ConvertTimeToUTC(ctx, endTimeStr)
if utcTimeStr == "" {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_end_time")
}
endTime, err = utils.ParseDateTime(utcTimeStr)
if err != nil {
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, "invalid_end_time")
}
}
// 与列表保持一致:未传 start_time 时默认最近 7 天;未传 end_time 时默认当前时间
// 这样导出数据集与列表查询数据集一致,并避免扫到未建表的历史月份
if startTime.IsZero() {
startTime = time.Now().UTC().AddDate(0, 0, -7)
}
if endTime.IsZero() {
endTime = time.Now().UTC()
}
// 校验时间范围不超过 3 个月(与列表/导出一致)
if valid, err := utils.ValidateTimeRange(startTime, endTime); !valid {
// ValidateTimeRange 返回的是可翻译错误键,这里直接返回 key 交给前端处理
return services.PaymentFilters{}, response.Error(ctx, http.StatusBadRequest, err.Error())
}
return services.PaymentFilters{
PaymentNo: paymentNo,
OrderNo: orderNo,
PaymentMethodID: paymentMethodID,
UserID: userID,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}, nil
}
// Index 支付记录列表
// @Summary 获取支付记录列表
// @Description 分页获取支付记录列表,支持多条件筛选
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param payment_no query string false "支付单号(模糊搜索)"
// @Param order_no query string false "订单号(模糊搜索)"
// @Param payment_method_id query int false "支付方式ID"
// @Param user_id query int false "用户ID"
// @Param status query string false "支付状态(pending/paid/failed/cancelled"
// @Param start_time query string false "开始时间(格式:2006-01-02 15:04:05"
// @Param end_time query string false "结束时间(格式:2006-01-02 15:04:05"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments [get]
// @Security BearerAuth
func (r *PaymentController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
payments, total, err := r.paymentService.GetPayments(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "payment", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据
paymentList := make([]http.Json, len(payments))
for i, payment := range payments {
paymentJson := http.Json{
"id": payment.ID,
"payment_no": payment.PaymentNo,
"order_no": payment.OrderNo,
"payment_method_id": payment.PaymentMethodID,
"user_id": payment.UserID,
"amount": payment.Amount,
"status": payment.Status,
"third_party_no": payment.ThirdPartyNo,
"pay_time": r.formatPayTime(payment.PayTime),
"fail_reason": payment.FailReason,
"remark": payment.Remark,
"created_at": payment.CreatedAt,
"updated_at": payment.UpdatedAt,
}
// 添加支付方式信息
if payment.PaymentMethod.ID > 0 {
paymentJson["payment_method"] = http.Json{
"id": payment.PaymentMethod.ID,
"name": payment.PaymentMethod.Name,
"code": payment.PaymentMethod.Code,
"type": payment.PaymentMethod.Type,
}
}
paymentList[i] = paymentJson
}
return response.Success(ctx, http.Json{
"data": paymentList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 支付记录详情
// @Summary 获取支付记录详情
// @Description 根据支付单号获取支付记录详细信息(分表后ID可能重复,使用支付单号查询)
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path string true "支付单号"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "支付记录不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/{id} [get]
// @Security BearerAuth
func (r *PaymentController) Show(ctx http.Context) http.Response {
paymentNo := ctx.Request().Route("id") // 路由参数名保持兼容
if paymentNo == "" {
return response.Error(ctx, http.StatusBadRequest, "payment_no_required")
}
payment, err := r.paymentService.GetPaymentByPaymentNo(paymentNo)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentNotFound.Code)
}
paymentJson := http.Json{
"id": payment.ID,
"payment_no": payment.PaymentNo,
"order_no": payment.OrderNo,
"payment_method_id": payment.PaymentMethodID,
"user_id": payment.UserID,
"amount": payment.Amount,
"status": payment.Status,
"third_party_no": payment.ThirdPartyNo,
"pay_time": r.formatPayTime(payment.PayTime),
"fail_reason": payment.FailReason,
"remark": payment.Remark,
"created_at": payment.CreatedAt,
"updated_at": payment.UpdatedAt,
}
// 添加支付方式信息
if payment.PaymentMethod.ID > 0 {
paymentJson["payment_method"] = http.Json{
"id": payment.PaymentMethod.ID,
"name": payment.PaymentMethod.Name,
"code": payment.PaymentMethod.Code,
"type": payment.PaymentMethod.Type,
}
}
return response.Success(ctx, paymentJson)
}
// formatPayTime 格式化支付时间为字符串
func (r *PaymentController) formatPayTime(t *time.Time) string {
return utils.FormatDateTimePtr(t)
}
// Export 导出支付记录
// @Summary 导出支付记录
// @Description 异步导出支付记录为CSV文件
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param payment_no query string false "支付单号"
// @Param order_no query string false "订单号"
// @Param payment_method_id query int false "支付方式ID"
// @Param user_id query int false "用户ID"
// @Param status query string false "支付状态"
// @Param start_time query string false "开始时间"
// @Param end_time query string false "结束时间"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 429 {object} map[string]any "导出任务正在进行中"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/export [post]
// @Security BearerAuth
func (r *PaymentController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击
lockKey := fmt.Sprintf("export:payments:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 构建筛选条件
filters, resp := r.buildFilters(ctx)
if resp != nil {
return resp
}
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypePayments,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "",
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 序列化筛选条件
filtersMap := map[string]any{
"payment_no": filters.PaymentNo,
"order_no": filters.OrderNo,
"payment_method_id": filters.PaymentMethodID,
"user_id": filters.UserID,
"status": filters.Status,
"order_by": filters.OrderBy,
}
if !filters.StartTime.IsZero() {
filtersMap["start_time"] = utils.FormatDateTime(filters.StartTime)
}
if !filters.EndTime.IsZero() {
filtersMap["end_time"] = utils.FormatDateTime(filters.EndTime)
}
lang := r.getCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
exportArgsStruct := jobs.ExportPaymentsArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "payments",
Language: lang,
Timezone: timezone,
}
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("提交支付记录导出任务到队列: export_id=%d", exportRecord.ID)
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
if err := facades.Queue().Job(&jobs.ExportPayments{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("支付记录导出任务已成功提交到队列: export_id=%d", exportRecord.ID)
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": trans.Get(ctx, "export_task_submitted"),
})
}
// GetExportStatus 查询导出状态
// @Summary 查询支付记录导出状态
// @Description 根据导出记录ID查询导出任务的状态
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "导出记录ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "导出记录不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payments/export/status/{id} [get]
// @Security BearerAuth
func (r *PaymentController) GetExportStatus(ctx http.Context) http.Response {
exportID := helpers.GetUintRoute(ctx, "id")
if exportID == 0 {
return response.Error(ctx, http.StatusBadRequest, "export_id_required")
}
var exportRecord models.Export
if err := facades.Orm().Query().Where("id", exportID).FirstOrFail(&exportRecord); err != nil {
return response.Error(ctx, http.StatusNotFound, "export_not_found")
}
result := http.Json{
"id": exportRecord.ID,
"status": exportRecord.Status,
"status_text": r.getExportStatusText(ctx, exportRecord.Status),
"path": exportRecord.Path,
"filename": exportRecord.Filename,
"size": exportRecord.Size,
"error_msg": exportRecord.ErrorMsg,
"created_at": exportRecord.CreatedAt,
"updated_at": exportRecord.UpdatedAt,
}
if exportRecord.Status == models.ExportStatusSuccess && exportRecord.Path != "" {
result["download_url"] = fmt.Sprintf("/api/admin/exports/%d/download", exportRecord.ID)
}
return response.Success(ctx, result)
}
// getCurrentLanguage 获取当前语言(使用通用工具函数)
func (r *PaymentController) getCurrentLanguage(ctx http.Context) string {
return utils.GetCurrentLanguage(ctx)
}
// getExportStatusText 获取导出状态文本
func (r *PaymentController) getExportStatusText(ctx http.Context, status uint8) string {
switch status {
case models.ExportStatusProcessing:
return trans.Get(ctx, "export_task_status_processing")
case models.ExportStatusSuccess:
return trans.Get(ctx, "export_task_status_success")
case models.ExportStatusFailed:
return trans.Get(ctx, "export_task_status_failed")
default:
return trans.Get(ctx, "export_task_status_unknown")
}
}
@@ -0,0 +1,289 @@
package admin
import (
"encoding/json"
"github.com/goravel/framework/contracts/http"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/services"
)
type PaymentMethodController struct {
paymentService services.PaymentService
}
func NewPaymentMethodController() *PaymentMethodController {
return &PaymentMethodController{
paymentService: services.NewPaymentService(),
}
}
// Index 支付方式列表
// @Summary 获取支付方式列表
// @Description 分页获取支付方式列表,支持多条件筛选
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param name query string false "支付方式名称(模糊搜索)"
// @Param code query string false "支付方式代码"
// @Param type query string false "支付类型"
// @Param is_active query string false "是否启用:1-启用,0-禁用"
// @Param order_by query string false "排序(格式:字段:asc/desc,如:created_at:desc"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods [get]
// @Security BearerAuth
func (r *PaymentMethodController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := services.PaymentMethodFilters{
Name: ctx.Request().Query("name", ""),
Code: ctx.Request().Query("code", ""),
Type: ctx.Request().Query("type", ""),
IsActive: ctx.Request().Query("is_active", ""),
Description: ctx.Request().Query("description", ""),
OrderBy: ctx.Request().Query("order_by", ""),
}
paymentMethods, total, err := r.paymentService.GetPaymentMethods(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"filters": filters,
})
}
// 转换响应数据(不返回敏感配置信息)
paymentMethodList := make([]http.Json, len(paymentMethods))
for i, pm := range paymentMethods {
paymentMethodList[i] = http.Json{
"id": pm.ID,
"name": pm.Name,
"code": pm.Code,
"type": pm.Type,
"is_active": pm.IsActive,
"sort": pm.Sort,
"description": pm.Description,
"created_at": pm.CreatedAt,
"updated_at": pm.UpdatedAt,
}
}
return response.Success(ctx, http.Json{
"data": paymentMethodList,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 支付方式详情
// @Summary 获取支付方式详情
// @Description 根据ID获取支付方式详细信息
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 404 {object} map[string]any "支付方式不存在"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [get]
// @Security BearerAuth
func (r *PaymentMethodController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
paymentMethod, err := r.paymentService.GetPaymentMethodByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentMethodNotFound.Code)
}
// 解析配置 JSON
var config map[string]any
if paymentMethod.Config != "" {
if err := json.Unmarshal([]byte(paymentMethod.Config), &config); err != nil {
config = make(map[string]any)
}
} else {
config = make(map[string]any)
}
return response.Success(ctx, http.Json{
"id": paymentMethod.ID,
"name": paymentMethod.Name,
"code": paymentMethod.Code,
"type": paymentMethod.Type,
"config": config,
"is_active": paymentMethod.IsActive,
"sort": paymentMethod.Sort,
"description": paymentMethod.Description,
"created_at": paymentMethod.CreatedAt,
"updated_at": paymentMethod.UpdatedAt,
})
}
// Store 创建支付方式
// @Summary 创建支付方式
// @Description 创建新的支付方式
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param name body string true "支付方式名称"
// @Param code body string true "支付方式代码"
// @Param type body string true "支付类型"
// @Param config body object true "支付配置(JSON对象)"
// @Param is_active body bool false "是否启用"
// @Param sort body int false "排序"
// @Param description body string false "描述"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods [post]
// @Security BearerAuth
func (r *PaymentMethodController) Store(ctx http.Context) http.Response {
var req adminrequests.PaymentMethodCreate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
paymentMethod, err := r.paymentService.CreatePaymentMethod(
req.Name,
req.Code,
req.Type,
req.Config,
req.IsActive,
req.Sort,
req.Description,
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"name": req.Name,
"code": req.Code,
})
}
return response.Success(ctx, http.Json{
"id": paymentMethod.ID,
"name": paymentMethod.Name,
"code": paymentMethod.Code,
"type": paymentMethod.Type,
"is_active": paymentMethod.IsActive,
"sort": paymentMethod.Sort,
"description": paymentMethod.Description,
"created_at": paymentMethod.CreatedAt,
"updated_at": paymentMethod.UpdatedAt,
})
}
// Update 更新支付方式
// @Summary 更新支付方式
// @Description 更新支付方式信息
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Param name body string true "支付方式名称"
// @Param config body object false "支付配置(JSON对象)"
// @Param is_active body bool false "是否启用"
// @Param sort body int false "排序"
// @Param description body string false "描述"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [put]
// @Security BearerAuth
func (r *PaymentMethodController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 获取支付方式
paymentMethod, err := r.paymentService.GetPaymentMethodByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, apperrors.ErrPaymentMethodNotFound.Code)
}
var req adminrequests.PaymentMethodUpdate
errors, err := ctx.Request().ValidateRequest(&req)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
paymentMethod.Name = req.Name
}
if _, exists := allInputs["config"]; exists && req.Config != nil {
configBytes, err := json.Marshal(req.Config)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPaymentConfigRequired.Code)
}
paymentMethod.Config = string(configBytes)
}
if _, exists := allInputs["is_active"]; exists {
paymentMethod.IsActive = req.IsActive
}
if _, exists := allInputs["sort"]; exists {
paymentMethod.Sort = req.Sort
}
if _, exists := allInputs["description"]; exists {
paymentMethod.Description = req.Description
}
if err := r.paymentService.UpdatePaymentMethodModel(paymentMethod); err != nil {
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"id": id,
})
}
return response.Success(ctx, http.Json{
"payment_method": *paymentMethod,
})
}
// Destroy 删除支付方式
// @Summary 删除支付方式
// @Description 删除支付方式
// @Tags 支付管理
// @Accept json
// @Produce json
// @Param id path int true "支付方式ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any "参数错误"
// @Failure 500 {object} map[string]any "服务器错误"
// @Router /api/admin/payment-methods/{id} [delete]
// @Security BearerAuth
func (r *PaymentMethodController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
err := r.paymentService.DeletePaymentMethod(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "payment_method", err, map[string]any{
"id": id,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,224 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type PermissionController struct {
permissionService services.PermissionService
treeService services.TreeService
}
func NewPermissionController() *PermissionController {
return &PermissionController{
permissionService: services.NewPermissionService(),
treeService: services.NewTreeServiceImpl(),
}
}
// findPermissionByID 根据ID查找权限,如果不存在则返回错误响应
// withMenu 为 true 时会预加载 Menu 关联
func (r *PermissionController) findPermissionByID(ctx http.Context, id uint, withMenu bool) (*models.Permission, http.Response) {
permission, err := r.permissionService.GetByID(id, withMenu)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrPermissionNotFound.Code)
}
return permission, nil
}
// buildFilters 构建查询过滤器
func (r *PermissionController) buildFilters(ctx http.Context) services.PermissionFilters {
name := ctx.Request().Query("name", "")
slug := ctx.Request().Query("slug", "")
method := ctx.Request().Query("method", "")
path := ctx.Request().Query("path", "")
status := ctx.Request().Query("status", "")
menuID := ctx.Request().Query("menu_id", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.PermissionFilters{
Name: name,
Slug: slug,
Method: method,
Path: path,
Status: status,
MenuID: menuID,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 权限列表
func (r *PermissionController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
permissions, total, err := r.permissionService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "permission", err)
}
return response.Success(ctx, http.Json{
"list": permissions,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 权限详情
func (r *PermissionController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, true) // 预加载 Menu 关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
// Store 创建权限
func (r *PermissionController) Store(ctx http.Context) http.Response {
name := ctx.Request().Input("name")
slug := ctx.Request().Input("slug")
method := ctx.Request().Input("method")
path := ctx.Request().Input("path")
description := ctx.Request().Input("description")
status := cast.ToUint8(ctx.Request().Input("status", "0"))
sort := cast.ToInt(ctx.Request().Input("sort", "0"))
menuID := cast.ToUint(ctx.Request().Input("menu_id", "0"))
if name == "" || slug == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameAndSlugRequired.Code)
}
exists, err := facades.Orm().Query().Model(&models.Permission{}).
Where("name", name).
OrWhere("slug", slug).
Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameOrSlugExists.Code)
}
permission, err := r.permissionService.Create(
name,
slug,
method,
path,
description,
status,
sort,
menuID,
)
if err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"name": name,
"slug": slug,
})
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
func (r *PermissionController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, false)
if resp != nil {
return resp
}
name := ctx.Request().Input("name")
slug := ctx.Request().Input("slug")
method := ctx.Request().Input("method")
path := ctx.Request().Input("path")
description := ctx.Request().Input("description")
status := ctx.Request().Input("status", "")
sort := ctx.Request().Input("sort", "")
menuIDStr := ctx.Request().Input("menu_id", "")
if name != "" {
exists, err := facades.Orm().Query().Model(&models.Permission{}).Where("name", name).Where("id <> ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionNameExists.Code)
}
permission.Name = name
}
if slug != "" {
exists, err := facades.Orm().Query().Model(&models.Permission{}).Where("slug", slug).Where("id <> ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrPermissionSlugExists.Code)
}
permission.Slug = slug
}
if method != "" {
permission.Method = method
}
if path != "" {
permission.Path = path
}
if description != "" {
permission.Description = description
}
if status != "" {
permission.Status = cast.ToUint8(status)
}
if sort != "" {
permission.Sort = cast.ToInt(sort)
}
if menuIDStr != "" {
permission.MenuID = cast.ToUint(menuIDStr)
}
if err := r.permissionService.Update(permission); err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"permission_id": permission.ID,
})
}
return response.Success(ctx, http.Json{
"permission": *permission,
})
}
// Destroy 删除权限
func (r *PermissionController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
permission, resp := r.findPermissionByID(ctx, id, false) // 不需要预加载关联
if resp != nil {
return resp
}
if err := r.permissionService.Delete(permission); err != nil {
return response.ErrorWithLog(ctx, "permission", err, map[string]any{
"permission_id": permission.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,311 @@
package admin
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type RoleController struct {
roleService services.RoleService
}
func NewRoleController() *RoleController {
return &RoleController{
roleService: services.NewRoleServiceImpl(),
}
}
// findRoleByID 根据ID查找角色,如果不存在则返回错误响应
// withRelations 为 true 时会预加载 Permissions 和 Menus 关联
func (r *RoleController) findRoleByID(ctx http.Context, id uint, withRelations bool) (*models.Role, http.Response) {
role, err := r.roleService.GetByID(id, withRelations)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrRoleNotFound.Code)
}
return role, nil
}
// buildFilters 构建查询过滤器
func (r *RoleController) buildFilters(ctx http.Context) services.RoleFilters {
name := ctx.Request().Query("name", "")
status := ctx.Request().Query("status", "")
// 使用辅助函数自动转换时区
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.RoleFilters{
Name: name,
Status: status,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 角色列表
func (r *RoleController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
roles, total, err := r.roleService.GetList(filters, page, pageSize)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": roles,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 角色详情
func (r *RoleController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, true) // 预加载关联
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// Store 创建角色
func (r *RoleController) Store(ctx http.Context) http.Response {
// 使用请求验证
var roleCreate adminrequests.RoleCreate
errors, err := ctx.Request().ValidateRequest(&roleCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 检查名称是否已存在
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("name", roleCreate.Name).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleNameExists.Code)
}
// 检查标识是否已存在
exists, err = facades.Orm().Query().Model(&models.Role{}).Where("slug", roleCreate.Slug).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrCreateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleSlugExists.Code)
}
role, err := r.roleService.Create(
roleCreate.Name,
roleCreate.Slug,
roleCreate.Description,
roleCreate.Status,
roleCreate.Sort,
)
if err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"name": roleCreate.Name,
"slug": roleCreate.Slug,
})
}
// 处理权限关联
permissionIDs := r.roleService.ParseIDsFromRequest(ctx, "permission_ids")
if len(permissionIDs) > 0 {
if err := r.roleService.SyncPermissions(role, permissionIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"permission_ids": permissionIDs,
})
}
}
// 处理菜单关联
menuIDs := r.roleService.ParseIDsFromRequest(ctx, "menu_ids")
if len(menuIDs) > 0 {
if err := r.roleService.SyncMenus(role, menuIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"menu_ids": menuIDs,
})
}
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// parseProtectedRoleSlugs 解析受保护的角色标识字符串(支持逗号分隔)
func (r *RoleController) parseProtectedRoleSlugs(slugsStr string) []string {
var slugs []string
if slugsStr == "" {
return slugs
}
parts := str.Of(slugsStr).Split(",")
for _, part := range parts {
part = str.Of(part).Trim().String()
if !str.Of(part).IsEmpty() {
slugs = append(slugs, part)
}
}
return slugs
}
// isProtectedRole 检查角色是否是受保护的(通过slug判断)
func (r *RoleController) isProtectedRole(roleSlug string) bool {
protectedSlugsStr := facades.Config().GetString("role.protected_slugs", "super-admin")
protectedSlugs := r.parseProtectedRoleSlugs(protectedSlugsStr)
for _, protectedSlug := range protectedSlugs {
if roleSlug == protectedSlug {
return true
}
}
return false
}
// Update 更新角色
func (r *RoleController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, false)
if resp != nil {
return resp
}
// 检查是否是受保护的角色(通过slug判断)
isProtected := r.isProtectedRole(role.Slug)
// 使用请求验证
var roleUpdate adminrequests.RoleUpdate
errors, err := ctx.Request().ValidateRequest(&roleUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用 All() 方法检查字段是否存在
allInputs := ctx.Request().All()
if _, exists := allInputs["name"]; exists {
// 检查名称是否已被其他角色使用(排除当前角色)
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("name", roleUpdate.Name).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleNameExists.Code)
}
role.Name = roleUpdate.Name
}
if _, exists := allInputs["slug"]; exists {
// 只有当 slug 值真正改变时才检查
if roleUpdate.Slug != role.Slug {
// 受保护角色的标识不能修改
if isProtected {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotModifySlug.Code)
}
// 检查标识是否已被其他角色使用(排除当前角色)
exists, err := facades.Orm().Query().Model(&models.Role{}).Where("slug", roleUpdate.Slug).Where("id != ?", id).Exists()
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
if exists {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrRoleSlugExists.Code)
}
role.Slug = roleUpdate.Slug
}
// 如果 slug 值未改变,跳过更新(允许其他字段正常更新)
}
if _, exists := allInputs["description"]; exists {
// 描述字段允许修改,包括受保护角色(如 super-admin)也可以修改描述
role.Description = roleUpdate.Description
}
if _, exists := allInputs["status"]; exists {
// 受保护角色不能禁用
if isProtected && roleUpdate.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotDisable.Code)
}
role.Status = roleUpdate.Status
}
if _, exists := allInputs["sort"]; exists {
role.Sort = roleUpdate.Sort
}
if err := r.roleService.Update(role); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
})
}
// super-admin 角色拥有所有权限,不需要设置菜单和权限
// 处理权限关联
if !isProtected && ctx.Request().Input("permission_ids") != "" {
permissionIDs := r.roleService.ParseIDsFromRequest(ctx, "permission_ids")
if err := r.roleService.SyncPermissions(role, permissionIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"permission_ids": permissionIDs,
})
}
}
// 处理菜单关联
if !isProtected && ctx.Request().Input("menu_ids") != "" {
menuIDs := r.roleService.ParseIDsFromRequest(ctx, "menu_ids")
if err := r.roleService.SyncMenus(role, menuIDs); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
"menu_ids": menuIDs,
})
}
}
return response.Success(ctx, http.Json{
"role": *role,
})
}
// Destroy 删除角色
func (r *RoleController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
role, resp := r.findRoleByID(ctx, id, false)
if resp != nil {
return resp
}
// 检查是否是受保护的角色(通过slug判断)
if r.isProtectedRole(role.Slug) {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrRoleProtectedCannotDelete.Code)
}
if _, err := facades.Orm().Query().Delete(role); err != nil {
return response.ErrorWithLog(ctx, "role", err, map[string]any{
"role_id": role.ID,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,154 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/constants"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type SystemLogController struct {
systemLogService services.SystemLogService
}
func NewSystemLogController() *SystemLogController {
return &SystemLogController{
systemLogService: services.NewSystemLogService(),
}
}
// findSystemLogByID 根据ID查找系统日志,如果不存在则返回错误响应
func (r *SystemLogController) findSystemLogByID(ctx http.Context, id uint) (*models.SystemLog, http.Response) {
log, err := r.systemLogService.GetByID(id)
if err != nil {
return nil, response.Error(ctx, http.StatusNotFound, apperrors.ErrLogNotFound.Code)
}
return log, nil
}
// buildFilters 构建查询过滤器
func (r *SystemLogController) buildFilters(ctx http.Context) services.SystemLogFilters {
level := ctx.Request().Query("level", "")
module := ctx.Request().Query("module", "")
traceID := ctx.Request().Query("trace_id", "")
message := ctx.Request().Query("message", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.SystemLogFilters{
Level: level,
Module: module,
TraceID: traceID,
Message: message,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Index 获取系统日志列表
func (r *SystemLogController) Index(ctx http.Context) http.Response {
filters := r.buildFilters(ctx)
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
logs, total, err := r.systemLogService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "system-log", err)
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 获取系统日志详情
func (r *SystemLogController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findSystemLogByID(ctx, id)
if resp != nil {
return resp
}
return response.Success(ctx, http.Json{
"log": *log,
})
}
// Destroy 删除系统日志
func (r *SystemLogController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
log, resp := r.findSystemLogByID(ctx, id)
if resp != nil {
return resp
}
if _, err := facades.Orm().Query().Delete(log); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"log_id": log.ID,
})
}
return response.Success(ctx)
}
type SystemLogBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除系统日志
func (r *SystemLogController) BatchDestroy(ctx http.Context) http.Response {
var req SystemLogBatchDestroyRequest
// 使用结构体绑定
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 使用工具函数转换为 []any
idsAny := helpers.ConvertUintSliceToAny(ids)
if _, err := facades.Orm().Query().WhereIn("id", idsAny).Delete(&models.SystemLog{}); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"ids": ids,
})
}
return response.Success(ctx)
}
// Clean 清理系统日志
// 删除指定天数之前的日志,默认删除30天前的日志
func (r *SystemLogController) Clean(ctx http.Context) http.Response {
days := helpers.GetIntQuery(ctx, "days", constants.DefaultCleanLogDays)
if days <= 0 {
days = constants.DefaultCleanLogDays
}
cutoffTime := time.Now().AddDate(0, 0, -days)
if _, err := facades.Orm().Query().Model(&models.SystemLog{}).Where("created_at < ?", cutoffTime).Delete(&models.SystemLog{}); err != nil {
return response.ErrorWithLog(ctx, "system-log", err, map[string]any{
"days": days,
})
}
return response.Success(ctx)
}
@@ -0,0 +1,170 @@
package admin
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/services"
"goravel/app/utils"
)
type UserBalanceLogController struct {
balanceLogService services.UserBalanceLogService
}
func NewUserBalanceLogController() *UserBalanceLogController {
return &UserBalanceLogController{
balanceLogService: services.NewUserBalanceLogService(),
}
}
// Index 余额变动记录列表
func (r *UserBalanceLogController) Index(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Query("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required_for_sharding")
}
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
// 解析时间
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
var startTime, endTime time.Time
if startTimeStr != "" {
startTime, _ = utils.ParseDateTime(startTimeStr)
}
if endTimeStr != "" {
endTime, _ = utils.ParseDateTime(endTimeStr)
}
var operatorID *uint
if operatorIDStr := ctx.Request().Query("operator_id", ""); operatorIDStr != "" {
id := cast.ToUint(operatorIDStr)
operatorID = &id
}
filters := services.UserBalanceLogFilters{
UserID: userID,
Type: ctx.Request().Query("type", ""),
Source: ctx.Request().Query("source", ""),
Status: ctx.Request().Query("status", ""),
StartTime: startTime,
EndTime: endTime,
OperatorID: operatorID,
}
logs, total, err := r.balanceLogService.GetLogs(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Statistics 用户余额统计
func (r *UserBalanceLogController) Statistics(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Query("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required")
}
startTimeStr := ctx.Request().Query("start_time", "")
endTimeStr := ctx.Request().Query("end_time", "")
var startTime, endTime time.Time
if startTimeStr != "" {
startTime, _ = utils.ParseDateTime(startTimeStr)
}
if endTimeStr != "" {
endTime, _ = utils.ParseDateTime(endTimeStr)
}
stats, err := r.balanceLogService.GetUserStatistics(userID, startTime, endTime)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"statistics": stats,
})
}
// Store 创建余额变动记录
func (r *UserBalanceLogController) Store(ctx http.Context) http.Response {
userID := cast.ToUint(ctx.Request().Input("user_id", "0"))
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required_for_sharding")
}
logType := ctx.Request().Input("type", "")
if logType == "" {
return response.Error(ctx, http.StatusBadRequest, "balance_type_required")
}
amount := cast.ToFloat64(ctx.Request().Input("amount", "0"))
if amount == 0 {
return response.Error(ctx, http.StatusBadRequest, "amount_cannot_be_zero")
}
balance := cast.ToFloat64(ctx.Request().Input("balance", "0"))
source := ctx.Request().Input("source", "manual")
description := ctx.Request().Input("description", "")
remark := ctx.Request().Input("remark", "")
status := ctx.Request().Input("status", "success")
var sourceID *uint
if sourceIDStr := ctx.Request().Input("source_id", ""); sourceIDStr != "" {
id := cast.ToUint(sourceIDStr)
sourceID = &id
}
var operatorID *uint
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err == nil && adminID > 0 {
operatorID = &adminID
}
// 如果未提供 balance,从用户表获取当前余额
if balance == 0 {
currentBalance, err := r.balanceLogService.GetUserBalance(userID)
if err != nil {
// 检查是否是业务错误
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, "get_user_balance_failed")
}
balance = currentBalance
}
log, err := r.balanceLogService.CreateLog(userID, logType, amount, balance, source, sourceID, description, operatorID, status, remark)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "balance_log_create_success", http.Json{
"data": log,
})
}
@@ -0,0 +1,332 @@
package admin
import (
"encoding/json"
"fmt"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/queue"
"github.com/goravel/framework/facades"
"github.com/spf13/cast"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
adminrequests "goravel/app/http/requests/admin"
"goravel/app/http/response"
"goravel/app/jobs"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
)
type UserController struct {
userService services.UserService
}
func NewUserController() *UserController {
return &UserController{
userService: services.NewUserService(),
}
}
// Index 用户列表
func (r *UserController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := services.UserFilters{
Username: ctx.Request().Query("username", ""),
Email: ctx.Request().Query("email", ""),
Phone: ctx.Request().Query("phone", ""),
Status: ctx.Request().Query("status", ""),
}
users, total, err := r.userService.GetList(filters, page, pageSize)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"list": users,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Show 用户详情
func (r *UserController) Show(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
user, err := r.userService.GetByID(id)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusNotFound, businessErr.Code)
}
return response.Error(ctx, http.StatusNotFound, err.Error())
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Store 创建用户
func (r *UserController) Store(ctx http.Context) http.Response {
// 使用请求验证
var userCreate adminrequests.UserCreate
errors, err := ctx.Request().ValidateRequest(&userCreate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法创建用户(包含验证、密码加密、默认货币设置)
user, err := r.userService.CreateWithValidation(
userCreate.Username,
userCreate.Password,
userCreate.Nickname,
userCreate.Email,
userCreate.Phone,
userCreate.Status,
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "user", err, map[string]any{
"username": userCreate.Username,
})
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Update 更新用户
func (r *UserController) Update(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var userUpdate adminrequests.UserUpdate
errors, err := ctx.Request().ValidateRequest(&userUpdate)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法验证用户是否存在(排除当前用户)
if err := r.userService.ValidateUserExists("", userUpdate.Email, userUpdate.Phone, id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, apperrors.ErrUpdateFailed.Code)
}
user := models.User{
Nickname: userUpdate.Nickname,
Email: userUpdate.Email,
Phone: userUpdate.Phone,
Status: userUpdate.Status,
}
// 如果提供了密码,则加密
if userUpdate.Password != "" {
hashedPassword, err := facades.Hash().Make(userUpdate.Password)
if err != nil {
return response.Error(ctx, http.StatusInternalServerError, "password_encrypt_failed")
}
user.Password = hashedPassword
}
if err := r.userService.Update(id, &user); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, http.Json{
"user": user,
})
}
// Destroy 删除用户
func (r *UserController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if err := r.userService.Delete(id); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusInternalServerError, businessErr.Code)
}
return response.Error(ctx, http.StatusInternalServerError, err.Error())
}
return response.Success(ctx, "delete_success", http.Json{})
}
// UpdateBalance 更新用户余额
func (r *UserController) UpdateBalance(ctx http.Context) http.Response {
// 从路由参数获取 user_id
userID := helpers.GetUintRoute(ctx, "id")
amount := cast.ToFloat64(ctx.Request().Input("amount", "0"))
logType := ctx.Request().Input("type", "")
source := ctx.Request().Input("source", "manual")
description := ctx.Request().Input("description", "")
remark := ctx.Request().Input("remark", "")
var sourceID *uint
if sourceIDStr := ctx.Request().Input("source_id", ""); sourceIDStr != "" {
id := cast.ToUint(sourceIDStr)
sourceID = &id
}
var operatorID *uint
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err == nil && adminID > 0 {
operatorID = &adminID
}
if userID == 0 {
return response.Error(ctx, http.StatusBadRequest, "user_id_required")
}
if amount == 0 {
return response.Error(ctx, http.StatusBadRequest, "amount_cannot_be_zero")
}
if logType == "" {
return response.Error(ctx, http.StatusBadRequest, "balance_type_required")
}
if err := r.userService.UpdateBalance(userID, amount, logType, source, sourceID, description, operatorID, remark); err != nil {
// response.Error 会自动检测 BusinessError 并处理占位符替换
return response.Error(ctx, http.StatusBadRequest, err)
}
return response.Success(ctx, "balance_update_success", http.Json{})
}
// ResetPassword 重置用户密码(管理员操作)
func (r *UserController) ResetPassword(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
// 使用请求验证
var resetPasswordRequest adminrequests.ResetPassword
errors, err := ctx.Request().ValidateRequest(&resetPasswordRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法重置密码
if err := r.userService.ResetPassword(id, resetPasswordRequest.Password); err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "password", err, map[string]any{
"user_id": id,
})
}
return response.Success(ctx, "password_reset_success", http.Json{})
}
// Export 导出用户列表
func (r *UserController) Export(ctx http.Context) http.Response {
adminID, err := helpers.GetAdminIDFromContext(ctx)
if err != nil {
return response.Error(ctx, http.StatusUnauthorized, "unauthorized")
}
// 防重复点击
lockKey := fmt.Sprintf("export:users:lock:%d", adminID)
lock := facades.Cache().Lock(lockKey, 10*time.Second)
if !lock.Get() {
return response.Error(ctx, http.StatusTooManyRequests, "export_in_progress")
}
// 获取存储驱动配置
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "export_disk", "")
}
if disk == "" {
disk = "local"
}
exportRecord := models.Export{
AdminID: adminID,
Type: models.ExportTypeUsers,
Status: models.ExportStatusProcessing,
Disk: disk,
Path: "",
}
if err := facades.Orm().Query().Create(&exportRecord); err != nil {
return response.ErrorWithLog(ctx, "export", err)
}
// 构建筛选条件(POST 请求,从 body 获取参数)
filtersMap := map[string]any{
"username": ctx.Request().Input("username", ""),
"nickname": ctx.Request().Input("nickname", ""),
"email": ctx.Request().Input("email", ""),
"phone": ctx.Request().Input("phone", ""),
"order_by": ctx.Request().Input("order_by", "id:desc"),
}
if statusStr := ctx.Request().Input("status", ""); statusStr != "" {
filtersMap["status"] = cast.ToUint(statusStr)
}
lang := utils.GetCurrentLanguage(ctx)
timezone := helpers.GetCurrentTimezone(ctx)
exportArgsStruct := jobs.ExportUsersArgs{
ExportID: exportRecord.ID,
AdminID: adminID,
Filters: filtersMap,
Type: "users",
Language: lang,
Timezone: timezone,
}
exportArgsJSON, err := json.Marshal(exportArgsStruct)
if err != nil {
facades.Log().Errorf("序列化导出参数失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
facades.Log().Infof("提交用户导出任务到队列: export_id=%d", exportRecord.ID)
exportArgs := []queue.Arg{
{
Type: "string",
Value: string(exportArgsJSON),
},
}
if err := facades.Queue().Job(&jobs.ExportUsers{}, exportArgs).OnQueue("long-running").Dispatch(); err != nil {
lock.Release()
facades.Log().Errorf("提交导出任务失败: export_id=%d, error=%v", exportRecord.ID, err)
exportRecord.Status = models.ExportStatusFailed
exportRecord.ErrorMsg = err.Error()
facades.Orm().Query().Save(&exportRecord)
return response.ErrorWithLog(ctx, "export", err)
}
return response.Success(ctx, http.Json{
"export_id": exportRecord.ID,
"message": "export_task_submitted",
})
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
apirequests "goravel/app/http/requests/api"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
)
type AuthController struct {
userService services.UserService
}
func NewAuthController() *AuthController {
return &AuthController{
userService: services.NewUserService(),
}
}
// Register 用户注册
func (r *AuthController) Register(ctx http.Context) http.Response {
var registerRequest apirequests.UserRegister
errors, err := ctx.Request().ValidateRequest(&registerRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 使用服务方法创建用户(包含验证、密码加密、默认货币设置)
user, err := r.userService.CreateWithValidation(
registerRequest.Username,
registerRequest.Password,
registerRequest.Nickname,
registerRequest.Email,
registerRequest.Phone,
1, // C端注册默认启用
)
if err != nil {
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
return response.ErrorWithLog(ctx, "user", err, map[string]any{
"username": registerRequest.Username,
})
}
// 注册成功后自动登录,使用Goravel标准Auth生成token
token, err := facades.Auth(ctx).Guard("user").Login(&user)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"user_id": user.ID,
})
}
return response.SuccessWithHeader(ctx, "register_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"status": user.Status,
},
})
}
// Login 用户登录
func (r *AuthController) Login(ctx http.Context) http.Response {
var loginRequest apirequests.UserLogin
errors, err := ctx.Request().ValidateRequest(&loginRequest)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, err.Error())
}
if errors != nil {
return response.ValidationError(ctx, http.StatusBadRequest, "validation_failed", errors.All())
}
// 验证用户名是否存在
exists, err := facades.Orm().Query().Model(&models.User{}).Where("username", loginRequest.Username).Exists()
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if !exists {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 获取用户信息
var user models.User
if err := facades.Orm().Query().Where("username", loginRequest.Username).FirstOrFail(&user); err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"username": loginRequest.Username,
})
}
if user.Status == 0 {
return response.Error(ctx, http.StatusForbidden, apperrors.ErrAccountDisabled.Code)
}
// 验证密码
if !facades.Hash().Check(loginRequest.Password, user.Password) {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrUsernameOrPasswordErr.Code)
}
// 使用Goravel标准Auth生成token
token, err := facades.Auth(ctx).Guard("user").Login(&user)
if err != nil {
return response.ErrorWithLog(ctx, "auth", err, map[string]any{
"user_id": user.ID,
})
}
// 更新最后登录时间
now := time.Now()
user.LastLoginAt = &now
facades.Orm().Query().Save(&user)
return response.SuccessWithHeader(ctx, "login_success", "Authorization", "Bearer "+token, http.Json{
"token": token,
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"status": user.Status,
"last_login_at": user.LastLoginAt,
},
})
}
// Info 获取当前用户信息
func (r *AuthController) Info(ctx http.Context) http.Response {
// 使用Goravel标准Auth获取用户
var user models.User
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
return response.Error(ctx, http.StatusUnauthorized, apperrors.ErrNotLoggedIn.Code)
}
// 重新查询用户以确保获取最新数据(包括关联的货币信息)
if err := facades.Orm().Query().With("Currency").Where("id", user.ID).FirstOrFail(&user); err != nil {
return response.Error(ctx, http.StatusNotFound, "user_not_found")
}
return response.Success(ctx, http.Json{
"user": http.Json{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"email": user.Email,
"phone": user.Phone,
"balance": user.Balance,
"currency_id": user.CurrencyID,
"currency": user.Currency,
"status": user.Status,
"last_login_at": user.LastLoginAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
},
})
}
// Logout 用户登出
func (r *AuthController) Logout(ctx http.Context) http.Response {
// 使用Goravel标准Auth登出
if err := facades.Auth(ctx).Guard("user").Logout(); err != nil {
// 即使登出失败也返回成功,因为token可能已经过期
facades.Log().Warningf("Failed to logout: %v", err)
}
return response.Success(ctx, "logout_success", http.Json{})
}
@@ -0,0 +1,56 @@
package controllers
import (
"github.com/goravel/framework/contracts/http"
httpSwagger "github.com/swaggo/http-swagger/v2"
_ "goravel/docs"
)
/*********************************
1. Install swag
document: https://github.com/swaggo/http-swagger
go install github.com/swaggo/swag/cmd/swag@latest
2. Install http-swagger
go get -u github.com/swaggo/http-swagger
3. Optimize the document of endpoint: `app/http/controllers/swagger_controller.go`
4. Add route to `/route/web.go`
5. Init document
swag init
6. Run Server
air
7. Visit: http://localhost:3000/swagger/
********************************/
type SwaggerController struct {
// Dependent services
}
func NewSwaggerController() *SwaggerController {
return &SwaggerController{
// Inject services
}
}
// Index an example for Swagger
//
// @Summary Summary
// @Description Description
// @Tags example
// @Accept json
// @Success 200
// @Failure 400
// @Router /swagger [get]
func (r *SwaggerController) Index(ctx http.Context) http.Response {
handler := httpSwagger.Handler()
handler.ServeHTTP(ctx.Response().Writer(), ctx.Request().Origin())
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package helpers
import (
"errors"
"github.com/goravel/framework/contracts/http"
"goravel/app/models"
)
// GetAdminFromContext 从 context 中获取 admin 对象
// 如果 context 中没有 admin 或类型不匹配,返回错误
func GetAdminFromContext(ctx http.Context) (*models.Admin, error) {
adminValue := ctx.Value("admin")
if adminValue == nil {
return nil, errors.New("admin not found in context")
}
// 尝试值类型
if admin, ok := adminValue.(models.Admin); ok {
return &admin, nil
}
// 尝试指针类型
if adminPtr, ok := adminValue.(*models.Admin); ok {
return adminPtr, nil
}
return nil, errors.New("invalid admin type in context")
}
// GetAdminIDFromContext 从 context 中获取 admin ID
// 如果 context 中没有 admin 或类型不匹配,返回 0 和错误
func GetAdminIDFromContext(ctx http.Context) (uint, error) {
admin, err := GetAdminFromContext(ctx)
if err != nil {
return 0, err
}
return admin.ID, nil
}
+79
View File
@@ -0,0 +1,79 @@
package helpers
import (
"net"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/str"
)
// GetRealIP 获取客户端真实IP地址
// 优先从以下HTTP头获取(按顺序):
// 1. CF-Connecting-IP (Cloudflare)
// 2. True-Client-IP
// 3. X-Real-IP
// 4. X-Forwarded-For (取第一个IP)
// 5. RemoteAddr
func GetRealIP(ctx http.Context) string {
// 1. Cloudflare
if ip := ctx.Request().Header("CF-Connecting-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 2. True-Client-IP
if ip := ctx.Request().Header("True-Client-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 3. X-Real-IP
if ip := ctx.Request().Header("X-Real-IP", ""); ip != "" {
if parsedIP := parseIP(ip); parsedIP != "" {
return parsedIP
}
}
// 4. X-Forwarded-For (可能包含多个IP,取第一个)
if forwardedFor := ctx.Request().Header("X-Forwarded-For", ""); !str.Of(forwardedFor).IsEmpty() {
ips := str.Of(forwardedFor).Split(",")
if len(ips) > 0 {
ip := str.Of(ips[0]).Trim().String()
if parsedIP := parseIP(ip); !str.Of(parsedIP).IsEmpty() {
return parsedIP
}
}
}
// 5. RemoteAddr
remoteAddr := ctx.Request().Ip()
if parsedIP := parseIP(remoteAddr); parsedIP != "" {
return parsedIP
}
return remoteAddr
}
// parseIP 解析并验证IP地址
func parseIP(ip string) string {
ip = str.Of(ip).Trim().String()
if str.Of(ip).IsEmpty() {
return ""
}
// 如果包含端口,去掉端口
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
// 验证是否为有效IP
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return ""
}
return ip
}
+6
View File
@@ -0,0 +1,6 @@
package helpers
// 注意:FindByID 函数已移动到 response 包中,以避免循环导入
// 请使用 response.FindByID 代替 helpers.FindByID
// 相关的 FindByIDOptions 类型也在 response 包中
+45
View File
@@ -0,0 +1,45 @@
package helpers
// PaginateSlice 对切片进行分页处理
// 返回分页后的切片和总数
func PaginateSlice[T any](slice []T, page, pageSize int) ([]T, int64) {
total := int64(len(slice))
if total == 0 {
return []T{}, 0
}
start := (page - 1) * pageSize
end := start + pageSize
// 如果起始位置超出范围,返回空切片
if start >= len(slice) {
return []T{}, total
}
// 如果结束位置超出范围,截取到末尾
if end > len(slice) {
end = len(slice)
}
return slice[start:end], total
}
// ValidatePagination 验证并规范化分页参数
// 返回规范化后的 page 和 pageSize
func ValidatePagination(page, pageSize int) (int, int) {
// 默认值
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
// 最大限制
const maxPageSize = 100
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return page, pageSize
}
+105
View File
@@ -0,0 +1,105 @@
package helpers
import (
"github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/support/str"
)
// ApplySort 应用排序到查询
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
// defaultSort: 默认排序,格式为 "field:direction",如果 orderBy 为空则使用此默认值
// 返回: 应用了排序的查询对象
func ApplySort(query orm.Query, orderBy string, defaultSort string) orm.Query {
// 如果提供了排序参数,使用它;否则使用默认排序
sortStr := orderBy
if sortStr == "" {
sortStr = defaultSort
}
// 如果排序字符串为空,返回原查询
if sortStr == "" {
return query
}
// 解析多个排序字段(逗号分隔)
sortFields := str.Of(sortStr).Split(",")
var orderClauses []string
for _, field := range sortFields {
field = str.Of(field).Trim().String()
if str.Of(field).IsEmpty() {
continue
}
// 解析字段和方向(格式: "field:direction" 或 "field"
parts := str.Of(field).Split(":")
fieldName := str.Of(parts[0]).Trim().String()
direction := "asc" // 默认升序
if len(parts) > 1 {
direction = str.Of(parts[1]).Trim().Lower().String()
}
// 验证方向
if direction != "asc" && direction != "desc" {
direction = "asc"
}
// 收集排序子句
orderClauses = append(orderClauses, fieldName+" "+direction)
}
// 如果有排序子句,组合成一个字符串并应用
if len(orderClauses) > 0 {
var orderStr string
if len(orderClauses) > 0 {
orderStr = orderClauses[0]
for i := 1; i < len(orderClauses); i++ {
orderStr = str.Of(orderStr).Append(", ").Append(orderClauses[i]).String()
}
}
query = query.Order(orderStr)
}
return query
}
// ParseSort 解析排序参数
// orderBy: 排序参数,格式为 "field:direction" 或 "field1:direction1,field2:direction2"
// 返回: 排序字段和方向的映射
func ParseSort(orderBy string) map[string]string {
result := make(map[string]string)
if orderBy == "" {
return result
}
// 解析多个排序字段(逗号分隔)
sortFields := str.Of(orderBy).Split(",")
for _, field := range sortFields {
field = str.Of(field).Trim().String()
if str.Of(field).IsEmpty() {
continue
}
// 解析字段和方向
parts := str.Of(field).Split(":")
fieldName := str.Of(parts[0]).Trim().String()
direction := "asc" // 默认升序
if len(parts) > 1 {
direction = str.Of(parts[1]).Trim().Lower().String()
}
// 验证方向
if direction != "asc" && direction != "desc" {
direction = "asc"
}
result[fieldName] = direction
}
return result
}
+282
View File
@@ -0,0 +1,282 @@
package helpers
import (
"encoding/json"
"goravel/app/utils"
"reflect"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/carbon"
)
// ConvertTimesInData 递归转换数据中的时间字段到对应时区
// 使用 JSON 序列化和反序列化来确保正确处理所有类型
func ConvertTimesInData(ctx http.Context, data any) any {
if data == nil {
return nil
}
// 获取请求的时区
timezone := GetCurrentTimezone(ctx)
// 检查是否传了时区请求头
hasTimezoneHeader := ctx.Request().Header("X-Timezone", "") != "" ||
ctx.Request().Header("Timezone", "") != "" ||
ctx.Request().Input("timezone") != ""
// 如果没有传时区请求头,且时区是 UTC,直接返回原数据(不做转换)
if !hasTimezoneHeader && (timezone == carbon.UTC || timezone == "UTC") {
return data
}
// 先序列化为 JSON
jsonData, err := json.Marshal(data)
if err != nil {
// 如果序列化失败,尝试使用反射方法
return convertTimesInValue(reflect.ValueOf(data), timezone)
}
// 反序列化为 map[string]any
var result any
if err := json.Unmarshal(jsonData, &result); err != nil {
// 如果反序列化失败,返回原数据
return data
}
// 转换时间字段
converted := convertTimesInMap(result, timezone)
return converted
}
// convertTimesInMap 递归处理 map 或 slice 中的时间字段
func convertTimesInMap(data any, timezone string) any {
if data == nil {
return nil
}
switch v := data.(type) {
case map[string]any:
result := make(map[string]any)
for key, value := range v {
// 检查是否是时间字段
if isTimeField(key) {
// 尝试解析时间字符串并转换
if timeStr, ok := value.(string); ok && timeStr != "" {
// 如果时区是 UTC,直接返回原时间字符串(不做转换)
if timezone == carbon.UTC || timezone == "UTC" {
result[key] = timeStr
continue
}
// 否则进行时区转换
converted := convertTimeString(timeStr, timezone)
if converted != nil && converted != "" {
result[key] = converted
continue
}
// 如果转换失败,保留原值
result[key] = timeStr
continue
}
}
// 递归处理嵌套数据
result[key] = convertTimesInMap(value, timezone)
}
return result
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = convertTimesInMap(item, timezone)
}
return result
default:
return data
}
}
// convertTimeString 转换时间字符串到指定时区
// 假设数据库存储的时间是 UTC 时区(如:2025-11-22 06:21:25
func convertTimeString(timeStr string, timezone string) any {
if timeStr == "" || timeStr == "null" {
return nil
}
// 如果目标时区是 UTC,直接返回原时间字符串
if timezone == carbon.UTC || timezone == "UTC" {
return timeStr
}
// 加载时区
utcLoc, _ := time.LoadLocation("UTC")
targetLoc, err := time.LoadLocation(timezone)
if err != nil {
return timeStr
}
// 解析时间字符串为 UTC(数据库存储格式)
t, err := time.ParseInLocation(utils.DateTimeFormat, timeStr, utcLoc)
if err != nil {
// 如果标准格式失败,尝试其他格式
t, err = time.Parse(time.RFC3339, timeStr)
if err != nil {
return timeStr
}
// RFC3339 格式可能带时区,转换为 UTC
t = time.Unix(t.Unix(), 0).In(utcLoc)
}
// 转换到目标时区并格式化
return t.In(targetLoc).Format(utils.DateTimeFormat)
}
// convertTimesInValue 使用反射方法处理值(作为备用方案)
func convertTimesInValue(v reflect.Value, timezone string) any {
if !v.IsValid() {
return nil
}
// 处理指针
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
return convertTimesInValue(v.Elem(), timezone)
}
// 处理时间类型
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() {
dt := v.Interface().(carbon.DateTime)
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 *carbon.DateTime
if v.Type() == reflect.TypeOf((*carbon.DateTime)(nil)) {
if v.IsNil() {
return nil
}
dt := v.Interface().(*carbon.DateTime)
if dt == nil {
return nil
}
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 time.Time
if v.Type() == reflect.TypeOf(time.Time{}) {
t := v.Interface().(time.Time)
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理 *time.Time
if v.Type() == reflect.TypeOf((*time.Time)(nil)) {
if v.IsNil() {
return nil
}
t := v.Interface().(*time.Time)
if t == nil {
return nil
}
dt := carbon.NewDateTime(carbon.Parse(t.Format(utils.DateTimeFormat)))
return dt.SetTimezone(timezone).ToDateTimeString()
}
// 处理切片
if v.Kind() == reflect.Slice {
if v.IsNil() {
return nil
}
result := make([]any, v.Len())
for i := range result {
result[i] = convertTimesInValue(v.Index(i), timezone)
}
return result
}
// 处理数组
if v.Kind() == reflect.Array {
result := make([]any, v.Len())
for i := range result {
result[i] = convertTimesInValue(v.Index(i), timezone)
}
return result
}
// 处理 map
if v.Kind() == reflect.Map {
if v.IsNil() {
return nil
}
result := make(map[string]any)
for _, key := range v.MapKeys() {
keyStr := key.String()
if key.Kind() == reflect.Interface {
keyStr = reflect.ValueOf(key.Interface()).String()
}
result[keyStr] = convertTimesInValue(v.MapIndex(key), timezone)
}
return result
}
// 处理结构体
if v.Kind() == reflect.Struct {
result := make(map[string]any)
t := v.Type()
for i := range make([]int, v.NumField()) {
field := t.Field(i)
fieldValue := v.Field(i)
// 跳过未导出字段
if !fieldValue.CanInterface() {
continue
}
fieldName := field.Name
// 检查 json tag
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
// 解析 json tag(处理 "name,omitempty" 格式)
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] != "" {
fieldName = parts[0]
}
}
// 只处理时间相关字段
if isTimeField(fieldName) || isTimeType(fieldValue.Type()) {
result[fieldName] = convertTimesInValue(fieldValue, timezone)
} else {
// 递归处理嵌套结构
if fieldValue.Kind() == reflect.Struct || fieldValue.Kind() == reflect.Ptr || fieldValue.Kind() == reflect.Slice || fieldValue.Kind() == reflect.Map {
result[fieldName] = convertTimesInValue(fieldValue, timezone)
} else {
result[fieldName] = fieldValue.Interface()
}
}
}
return result
}
// 其他类型直接返回
return v.Interface()
}
// isTimeField 检查字段名是否是时间字段
func isTimeField(fieldName string) bool {
return fieldName == "created_at" || fieldName == "updated_at" || fieldName == "deleted_at" ||
fieldName == "CreatedAt" || fieldName == "UpdatedAt" || fieldName == "DeletedAt"
}
// isTimeType 检查类型是否是时间类型
func isTimeType(t reflect.Type) bool {
if t == reflect.TypeOf((*carbon.DateTime)(nil)).Elem() ||
t == reflect.TypeOf((*carbon.DateTime)(nil)) ||
t == reflect.TypeOf(time.Time{}) ||
t == reflect.TypeOf((*time.Time)(nil)) {
return true
}
return false
}
+213
View File
@@ -0,0 +1,213 @@
package helpers
import (
"goravel/app/utils"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/carbon"
"github.com/goravel/framework/support/str"
)
// GetCurrentTimezone 获取当前请求的时区
// 优先从请求头 X-Timezone 或 Timezone 获取
// 如果请求头没有或时区无效,使用配置的默认时区
func GetCurrentTimezone(ctx http.Context) string {
// 优先从 X-Timezone 请求头获取
timezone := ctx.Request().Header("X-Timezone", "")
if timezone == "" {
// 尝试从 Timezone 请求头获取
timezone = ctx.Request().Header("Timezone", "")
}
if timezone == "" {
// 尝试从查询参数获取
timezone = ctx.Request().Input("timezone")
}
// 如果从请求中获取到了时区,规范化并返回
if timezone != "" {
return NormalizeTimezone(timezone)
}
// 如果都没有,使用配置的默认时区
defaultTimezone := facades.Config().GetString("app.timezone", carbon.UTC)
return NormalizeTimezone(defaultTimezone)
}
// isValidTimezone 验证时区是否有效
func isValidTimezone(timezone string) bool {
if timezone == "" {
return false
}
// 尝试加载时区
_, err := time.LoadLocation(timezone)
return err == nil
}
// NormalizeTimezone 规范化时区名称(处理常见别名)
func NormalizeTimezone(timezone string) string {
timezone = str.Of(timezone).Trim().String()
if str.Of(timezone).IsEmpty() {
return carbon.UTC
}
// 转换为标准时区名称
timezoneMap := map[string]string{
"UTC": "UTC",
"GMT": "UTC",
"PST": "America/Los_Angeles",
"PDT": "America/Los_Angeles",
"EST": "America/New_York",
"EDT": "America/New_York",
"CST": "America/Chicago",
"CDT": "America/Chicago",
"MST": "America/Denver",
"MDT": "America/Denver",
"Beijing": "Asia/Shanghai",
"Shanghai": "Asia/Shanghai",
"Hong Kong": "Asia/Hong_Kong",
"Tokyo": "Asia/Tokyo",
"Seoul": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"London": "Europe/London",
"Paris": "Europe/Paris",
"Berlin": "Europe/Berlin",
"Moscow": "Europe/Moscow",
"Sydney": "Australia/Sydney",
"Melbourne": "Australia/Melbourne",
}
if normalized, ok := timezoneMap[timezone]; ok {
return normalized
}
// 如果时区有效,直接返回
if isValidTimezone(timezone) {
return timezone
}
// 默认返回 UTC
return carbon.UTC
}
// ConvertTimeToTimezone 将时间字符串转换为指定时区
// 返回转换后的时间字符串
func ConvertTimeToTimezone(timeStr string, timezone string) string {
if timeStr == "" {
return ""
}
// 规范化时区
timezone = NormalizeTimezone(timezone)
// 解析时间字符串
dt := carbon.Parse(timeStr)
if dt.IsZero() {
return timeStr
}
// 转换时区并返回格式化的字符串
return dt.SetTimezone(timezone).ToDateTimeString()
}
// ConvertTimeByContext 根据请求头中的时区转换时间字符串
// 返回转换后的时间字符串
func ConvertTimeByContext(ctx http.Context, timeStr string) string {
if timeStr == "" {
return ""
}
timezone := GetCurrentTimezone(ctx)
return ConvertTimeToTimezone(timeStr, timezone)
}
// ConvertTimeToUTC 将本地时区的时间字符串转换为 UTC 时间字符串(用于数据库查询)
// timeStr: 前端传入的时间字符串(本地时区格式,如 "2025-11-25 14:00:00"
// ctx: 请求上下文,用于获取当前时区
// 返回: UTC 时间字符串(如 "2025-11-25 06:00:00"
func ConvertTimeToUTC(ctx http.Context, timeStr string) string {
if timeStr == "" {
return ""
}
// 获取当前请求的时区
timezone := GetCurrentTimezone(ctx)
// 如果已经是 UTC,直接返回
if timezone == carbon.UTC || timezone == "UTC" {
return timeStr
}
// 加载时区
targetLoc, err := time.LoadLocation(timezone)
if err != nil {
// 如果时区无效,假设是 UTC
return timeStr
}
utcLoc, _ := time.LoadLocation("UTC")
// 解析时间字符串(假设是本地时区格式)
// 尝试多种格式
formats := []string{
utils.DateTimeFormat,
utils.DateTimeFormatT,
utils.DateTimeFormatMs,
utils.DateTimeFormatTZ,
time.RFC3339,
}
var t time.Time
var parseErr error
for _, format := range formats {
t, parseErr = time.ParseInLocation(format, timeStr, targetLoc)
if parseErr == nil {
break
}
}
if parseErr != nil {
// 如果所有格式都失败,尝试使用 carbon 解析
dt := carbon.Parse(timeStr)
if dt.IsZero() {
return timeStr
}
// 假设解析的时间是本地时区,转换为 UTC
return dt.SetTimezone(carbon.UTC).ToDateTimeString()
}
// 转换为 UTC 并格式化
return t.In(utcLoc).Format(utils.DateTimeFormat)
}
// GetTimeQueryParam 获取并转换时间查询参数(统一处理时间查询)
// 自动将前端传入的本地时区时间转换为 UTC 时间用于数据库查询
// 支持常见的时间查询参数名称:start_time, end_time, created_at_start, created_at_end, updated_at_start, updated_at_end
func GetTimeQueryParam(ctx http.Context, paramName string) string {
timeStr := ctx.Request().Query(paramName, "")
if timeStr == "" {
return ""
}
return ConvertTimeToUTC(ctx, timeStr)
}
// FormatTimeWithTimezone 使用指定时区格式化 time.Time
func FormatTimeWithTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
if timezone == "" {
timezone = "UTC"
}
loc, err := time.LoadLocation(timezone)
if err != nil {
return t.Format("2006-01-02 15:04:05")
}
return t.In(loc).Format("2006-01-02 15:04:05")
}
// FormatCarbonWithTimezone 使用指定时区格式化 Carbon 时间
func FormatCarbonWithTimezone(t *carbon.DateTime, timezone string) string {
if t == nil || t.IsZero() {
return ""
}
return FormatTimeWithTimezone(t.StdTime(), timezone)
}
+58
View File
@@ -0,0 +1,58 @@
package helpers
import (
"errors"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/support/str"
"goravel/app/models"
)
// GetUserFromContext 从 context 中获取 user 对象
// 如果 context 中没有 user 或类型不匹配,返回错误
func GetUserFromContext(ctx http.Context) (*models.User, error) {
userValue := ctx.Value("user")
if userValue == nil {
return nil, errors.New("user not found in context")
}
// 尝试值类型
if user, ok := userValue.(models.User); ok {
return &user, nil
}
// 尝试指针类型
if userPtr, ok := userValue.(*models.User); ok {
return userPtr, nil
}
return nil, errors.New("invalid user type in context")
}
// GetUserIDFromContext 从 context 中获取 user ID
// 如果 context 中没有 user 或类型不匹配,返回 0 和错误
func GetUserIDFromContext(ctx http.Context) (uint, error) {
user, err := GetUserFromContext(ctx)
if err != nil {
return 0, err
}
return user.ID, nil
}
// GetTokenFromHeader 从请求头中获取token
// 支持从Authorization header或URL参数中获取
func GetTokenFromHeader(ctx http.Context) string {
token := ctx.Request().Header("Authorization", "")
// 如果 Header 中没有 token,尝试从 URL 参数中获取
if str.Of(token).IsEmpty() {
token = ctx.Request().Query("_token", "")
}
// 移除Bearer前缀(如果有)
token = str.Of(token).ChopStart("Bearer ").Trim().String()
return token
}
+175
View File
@@ -0,0 +1,175 @@
package helpers
import (
"strings"
"github.com/goravel/framework/contracts/http"
)
// ParseUserAgent 解析User-Agent字符串,返回浏览器和操作系统信息
func ParseUserAgent(userAgent string) (browser, os string) {
if userAgent == "" {
return "Unknown", "Unknown"
}
ua := strings.ToLower(userAgent)
// 解析浏览器
browser = parseBrowser(ua)
// 解析操作系统
os = parseOS(ua)
return browser, os
}
// parseBrowser 解析浏览器类型
func parseBrowser(ua string) string {
// Chrome
if strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg") && !strings.Contains(ua, "opr") {
// 提取Chrome版本
if idx := strings.Index(ua, "chrome/"); idx != -1 {
version := extractVersion(ua, idx+7)
return "Chrome " + version
}
return "Chrome"
}
// Edge
if strings.Contains(ua, "edg") {
if idx := strings.Index(ua, "edg/"); idx != -1 {
version := extractVersion(ua, idx+4)
return "Edge " + version
}
return "Edge"
}
// Firefox
if strings.Contains(ua, "firefox") {
if idx := strings.Index(ua, "firefox/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Firefox " + version
}
return "Firefox"
}
// Safari
if strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome") {
if idx := strings.Index(ua, "version/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Safari " + version
}
return "Safari"
}
// Opera
if strings.Contains(ua, "opr") || strings.Contains(ua, "opera") {
if idx := strings.Index(ua, "opr/"); idx != -1 {
version := extractVersion(ua, idx+4)
return "Opera " + version
}
if idx := strings.Index(ua, "version/"); idx != -1 {
version := extractVersion(ua, idx+8)
return "Opera " + version
}
return "Opera"
}
// IE
if strings.Contains(ua, "msie") || strings.Contains(ua, "trident") {
if idx := strings.Index(ua, "msie "); idx != -1 {
version := extractVersion(ua, idx+5)
return "IE " + version
}
return "IE"
}
return "Unknown"
}
// parseOS 解析操作系统
func parseOS(ua string) string {
// Windows
if strings.Contains(ua, "windows") {
if strings.Contains(ua, "windows nt 10.0") || strings.Contains(ua, "windows nt 6.3") {
return "Windows 10/11"
}
if strings.Contains(ua, "windows nt 6.2") {
return "Windows 8"
}
if strings.Contains(ua, "windows nt 6.1") {
return "Windows 7"
}
if strings.Contains(ua, "windows nt 6.0") {
return "Windows Vista"
}
if strings.Contains(ua, "windows nt 5.1") {
return "Windows XP"
}
return "Windows"
}
// macOS
if strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh") {
if idx := strings.Index(ua, "mac os x "); idx != -1 {
version := extractVersion(ua, idx+9)
return "macOS " + version
}
return "macOS"
}
// iOS
if strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod") {
if idx := strings.Index(ua, "os "); idx != -1 {
version := extractVersion(ua, idx+3)
version = strings.ReplaceAll(version, "_", ".")
return "iOS " + version
}
return "iOS"
}
// Android
if strings.Contains(ua, "android") {
if idx := strings.Index(ua, "android "); idx != -1 {
version := extractVersion(ua, idx+8)
return "Android " + version
}
return "Android"
}
// Linux
if strings.Contains(ua, "linux") {
return "Linux"
}
return "Unknown"
}
// extractVersion 从User-Agent字符串中提取版本号
func extractVersion(ua string, startIdx int) string {
if startIdx >= len(ua) {
return ""
}
var version strings.Builder
for i := startIdx; i < len(ua); i++ {
c := ua[i]
if (c >= '0' && c <= '9') || c == '.' || c == '_' {
version.WriteByte(c)
} else {
break
}
}
result := version.String()
if len(result) > 10 {
return result[:10]
}
return result
}
// GetBrowserAndOS 从HTTP上下文获取浏览器和操作系统信息
func GetBrowserAndOS(ctx http.Context) (browser, os string) {
userAgent := ctx.Request().Header("User-Agent", "")
return ParseUserAgent(userAgent)
}
+97
View File
@@ -0,0 +1,97 @@
package helpers
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
"github.com/goravel/framework/support/str"
"github.com/spf13/cast"
)
// GetIntQuery 获取并验证整数查询参数
// 如果参数无效或不存在,返回默认值
func GetIntQuery(ctx http.Context, key string, defaultValue int) int {
value := ctx.Request().Query(key, "")
if value == "" {
return defaultValue
}
result := cast.ToInt(value)
if result < 1 {
return defaultValue
}
return result
}
// GetUintQuery 获取并验证无符号整数查询参数
// 如果参数无效或不存在,返回默认值
func GetUintQuery(ctx http.Context, key string, defaultValue uint) uint {
value := ctx.Request().Query(key, "")
if value == "" {
return defaultValue
}
result := cast.ToUint(value)
if result == 0 {
return defaultValue
}
return result
}
// GetUintRoute 获取并验证路由中的无符号整数参数
// 如果参数无效或不存在,返回 0
func GetUintRoute(ctx http.Context, key string) uint {
value := ctx.Request().Route(key)
return cast.ToUint(value)
}
// ParseIDsFromString 从逗号分隔的字符串中解析 ID 列表
// 返回去重后的 ID 列表
func ParseIDsFromString(idStr string) []uint {
if str.Of(idStr).IsEmpty() {
return []uint{}
}
var ids []uint
idMap := make(map[uint]bool)
// 分割字符串
idStrs := str.Of(idStr).Split(",")
for _, idStr := range idStrs {
idStr = str.Of(idStr).Trim().String()
if str.Of(idStr).IsEmpty() {
continue
}
id := cast.ToUint(idStr)
if id > 0 && !idMap[id] {
idMap[id] = true
ids = append(ids, id)
}
}
return ids
}
// ConvertUintSliceToAny 将 uint 切片转换为 []any
// 用于 ORM 的 WhereIn 查询
func ConvertUintSliceToAny(ids []uint) []any {
if len(ids) == 0 {
return []any{}
}
result := make([]any, len(ids))
for i, id := range ids {
result[i] = id
}
return result
}
// PrepareNumericFieldForValidation 在 PrepareForValidation 中准备数字字段
// 将指定的数字字段转换为字符串,以便 in 规则能正确验证
// 使用 cast.ToString 自动处理所有数字类型转换(int, int8-int64, uint, uint8-uint64, float32, float64
// 用法:在 PrepareForValidation 方法中调用此函数处理需要 in 验证的数字字段
// 示例:return PrepareNumericFieldForValidation(data, "status")
func PrepareNumericFieldForValidation(data validation.Data, fieldName string) error {
if val, exist := data.Get(fieldName); exist {
return data.Set(fieldName, cast.ToString(val))
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package http
import (
"github.com/goravel/framework/contracts/http"
httpmiddleware "github.com/goravel/framework/http/middleware"
// sessionmiddleware "github.com/goravel/framework/session/middleware" // 已禁用
appmiddleware "goravel/app/http/middleware"
)
type Kernel struct {
}
// The application's global HTTP middleware stack.
// These middleware are run during every request to your application.
func (kernel Kernel) Middleware() []http.Middleware {
return []http.Middleware{
appmiddleware.Cors(), // CORS 跨域处理(需要在最前面处理预检请求)
appmiddleware.Blacklist(), // 黑名单检查
appmiddleware.Trace(),
httpmiddleware.Throttle("global"),
// sessionmiddleware.StartSession(), // 已禁用:项目使用 JWT 认证,不需要 Session
}
}
View File
+51
View File
@@ -0,0 +1,51 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/http/helpers"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/utils"
)
func Blacklist() http.Middleware {
return func(ctx http.Context) {
// 排除登录接口,避免管理员被封禁后无法登录
path := ctx.Request().Path()
if path == "/api/admin/login" || path == "/api/admin/login/captcha" {
ctx.Request().Next()
return
}
// 获取真实IP地址
realIP := helpers.GetRealIP(ctx)
// 查询所有启用的黑名单记录
var blacklists []models.Blacklist
if err := facades.Orm().Query().Where("status", 1).Get(&blacklists); err != nil {
// 如果查询失败,记录错误但继续处理请求(避免影响系统正常运行)
facades.Log().Errorf("Blacklist middleware: Failed to query blacklists: %v", err)
ctx.Request().Next()
return
}
// 检查IP是否在黑名单中
for _, blacklist := range blacklists {
if utils.IsIPInBlacklist(realIP, blacklist.IP) {
// IP在黑名单中,拒绝访问
facades.Log().Warningf("Blacklist middleware: IP %s blocked by blacklist ID %d", realIP, blacklist.ID)
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": trans.Get(ctx, "ip_blocked"),
}).Abort()
return
}
}
// IP不在黑名单中,继续处理请求
ctx.Request().Next()
}
}
+157
View File
@@ -0,0 +1,157 @@
package middleware
import (
"slices"
"strconv"
"strings"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
// Cors CORS 中间件,处理跨域请求
func Cors() http.Middleware {
return func(ctx http.Context) {
// 获取请求路径
path := ctx.Request().Path()
// 检查是否是 WebSocket 升级请求
isWebSocket := strings.ToLower(ctx.Request().Header("Upgrade", "")) == "websocket" ||
strings.ToLower(ctx.Request().Header("Connection", "")) == "upgrade"
// 获取 CORS 配置的路径列表
corsPaths := facades.Config().Get("cors.paths", []string{}).([]string)
// 检查当前路径是否需要 CORS 处理
needCors := false
if len(corsPaths) == 0 {
// 如果没有配置路径,默认对所有路径启用
needCors = true
} else {
for _, corsPath := range corsPaths {
// 支持通配符匹配
if strings.HasSuffix(corsPath, "*") {
prefix := strings.TrimSuffix(corsPath, "*")
if strings.HasPrefix(path, prefix) {
needCors = true
break
}
} else if path == corsPath {
needCors = true
break
}
}
}
// WebSocket 请求直接放行,不需要 CORS 处理(WebSocket 有自己的协议)
if isWebSocket {
ctx.Request().Next()
return
}
if !needCors {
ctx.Request().Next()
return
}
// 获取 CORS 配置
allowedOrigins := facades.Config().Get("cors.allowed_origins", []string{"*"}).([]string)
allowedMethods := facades.Config().Get("cors.allowed_methods", []string{"*"}).([]string)
allowedHeaders := facades.Config().Get("cors.allowed_headers", []string{"*"}).([]string)
exposedHeaders := facades.Config().Get("cors.exposed_headers", []string{}).([]string)
maxAge := facades.Config().GetInt("cors.max_age", 0)
supportsCredentials := facades.Config().GetBool("cors.supports_credentials", false)
// 获取请求的 Origin
origin := ctx.Request().Header("Origin", "")
// 检查是否允许该 Origin
allowed := false
var allowedOrigin string
if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
// 允许所有源
allowed = true
allowedOrigin = "*"
} else if origin != "" {
// 检查是否在允许列表中
if slices.Contains(allowedOrigins, origin) {
allowed = true
allowedOrigin = origin
}
}
// 处理预检请求 (OPTIONS) - 必须在设置其他头之前处理
if ctx.Request().Method() == "OPTIONS" {
// 对于预检请求,必须设置 CORS 头
// 即使 origin 不在允许列表中,也要返回 CORS 头(只是不设置 Access-Control-Allow-Origin
// 这样浏览器才能正确判断,而不是因为状态码问题而失败
// 创建响应对象并设置所有 CORS 头
response := ctx.Response()
if allowed && origin != "" {
// Origin 在允许列表中
response.Header("Access-Control-Allow-Origin", allowedOrigin)
if supportsCredentials && allowedOrigin != "*" {
response.Header("Access-Control-Allow-Credentials", "true")
}
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" {
// 配置允许所有源
response.Header("Access-Control-Allow-Origin", "*")
} else if origin != "" {
// Origin 不在允许列表中,不设置 Access-Control-Allow-Origin
// 浏览器会拒绝请求,但至少不会因为状态码问题而失败
}
// 设置允许的方法(对于预检请求,这些头必须设置)
methodsStr := "*"
if len(allowedMethods) > 0 && allowedMethods[0] != "*" {
methodsStr = strings.Join(allowedMethods, ", ")
}
response.Header("Access-Control-Allow-Methods", methodsStr)
// 设置允许的请求头
headersStr := "*"
if len(allowedHeaders) > 0 && allowedHeaders[0] != "*" {
headersStr = strings.Join(allowedHeaders, ", ")
}
response.Header("Access-Control-Allow-Headers", headersStr)
// 设置暴露的响应头
if len(exposedHeaders) > 0 {
response.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
}
// 设置预检请求的缓存时间
if maxAge > 0 {
response.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
}
// 返回 204 No Content 并终止请求处理
// 对于 OPTIONS 请求,返回空响应体,状态码为 204
// 使用 Json 方法返回空对象,然后调用 Abort() 终止请求
_ = response.Json(http.StatusNoContent, http.Json{}).Abort()
return
}
// 对于非预检请求,设置 CORS 响应头
if allowed && origin != "" {
ctx.Response().Header("Access-Control-Allow-Origin", allowedOrigin)
if supportsCredentials && allowedOrigin != "*" {
ctx.Response().Header("Access-Control-Allow-Credentials", "true")
}
} else if len(allowedOrigins) > 0 && allowedOrigins[0] == "*" && origin != "" {
// 如果配置允许所有源,且请求有 origin,设置 CORS 头
ctx.Response().Header("Access-Control-Allow-Origin", "*")
}
// 设置暴露的响应头(非预检请求)
if len(exposedHeaders) > 0 {
ctx.Response().Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
}
// 继续处理请求
ctx.Request().Next()
}
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
func DevelopmentOnly() http.Middleware {
return func(ctx http.Context) {
env := facades.Config().Get("app.env", "production")
if env != "local" && env != "development" {
ctx.Response().Json(http.StatusForbidden, map[string]any{
"code": 403,
"message": "This feature is only available in development mode",
})
ctx.Request().Abort()
return
}
ctx.Request().Next()
}
}
+211
View File
@@ -0,0 +1,211 @@
package middleware
import (
"net"
"strings"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
// Domain 域名验证中间件
func Domain(configValueOrDomains ...any) http.Middleware {
return func(ctx http.Context) {
var domains []string
// 如果没有参数,不验证(允许所有域名)
if len(configValueOrDomains) == 0 {
ctx.Request().Next()
return
}
// 解析配置值的辅助函数
parseConfigValue := func(value any) []string {
if value == nil {
return nil
}
switch v := value.(type) {
case []string:
return v
case string:
if v == "" {
return nil
}
// 分割逗号分隔的域名
domainsList := strings.Split(v, ",")
result := make([]string, 0, len(domainsList))
for _, d := range domainsList {
d = strings.TrimSpace(d)
if d != "" {
result = append(result, d)
}
}
return result
case []any:
result := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
result = append(result, str)
}
}
return result
default:
return nil
}
}
// 处理参数
for _, param := range configValueOrDomains {
if param == nil {
continue
}
switch v := param.(type) {
case []string:
// 如果是字符串数组,直接使用
if len(v) > 0 {
domains = append(domains, v...)
}
case string:
// 如果是字符串,可能是单个域名或配置键
if v != "" {
// 先尝试作为配置键读取
configValue := facades.Config().Get(v, nil)
if configValue != nil {
// 如果配置存在,解析配置值
parsedDomains := parseConfigValue(configValue)
if len(parsedDomains) > 0 {
domains = append(domains, parsedDomains...)
continue
}
}
// 否则当作域名
domains = append(domains, v)
}
case []any:
// 如果是 any 数组,递归处理
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
domains = append(domains, str)
}
}
default:
// 其他类型,尝试解析为配置值
parsedDomains := parseConfigValue(v)
if len(parsedDomains) > 0 {
domains = append(domains, parsedDomains...)
}
}
}
// 如果没有指定允许的域名,允许所有域名访问(直接放行)
if len(domains) == 0 {
ctx.Request().Next()
return
}
// 获取请求的 Host
// 优先从 X-Forwarded-Host 获取(适用于反向代理场景)
host := ctx.Request().Header("X-Forwarded-Host", "")
if host == "" {
// 使用框架提供的 Host() 方法获取(推荐方式)
host = ctx.Request().Host()
}
// 如果 X-Forwarded-Host 包含多个值(逗号分隔),取第一个
if host != "" && strings.Contains(host, ",") {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
// 调试日志:记录获取到的 Host 值
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Host detection - X-Forwarded-Host: %s, Host(): %s, Final host: %s",
ctx.Request().Header("X-Forwarded-Host", ""),
ctx.Request().Host(),
host)
}
// 规范化 Host(移除端口号,转换为小写)
normalizedHost := normalizeHost(host)
// 调试日志:记录规范化后的 Host 和配置的域名
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Normalized host: %s, Configured domains: %v", normalizedHost, domains)
}
// 检查是否在允许的域名列表中
allowed := false
var matchedDomain string
for _, allowedDomain := range domains {
normalizedAllowed := normalizeHost(allowedDomain)
// 支持精确匹配和通配符匹配
if normalizedHost == normalizedAllowed || matchDomain(normalizedHost, normalizedAllowed) {
allowed = true
matchedDomain = allowedDomain
break
}
}
if !allowed {
// 域名不在允许列表中,拒绝访问
// facades.Log().Warningf("Domain middleware: Access denied. Request host: %s (normalized: %s), Allowed domains: %v", host, normalizedHost, domains)
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": "Access denied: domain not allowed",
}).Abort()
return
}
// 记录匹配的域名(仅在调试模式下)
if facades.Config().GetBool("app.debug", false) {
facades.Log().Debugf("Domain middleware: Access allowed. Request host: %s, Matched domain: %s", normalizedHost, matchedDomain)
}
// 域名验证通过,继续处理请求
ctx.Request().Next()
}
}
// normalizeHost 规范化域名(移除端口号,转换为小写,去除前后空格)
func normalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return ""
}
// 移除端口号
if hostname, _, err := net.SplitHostPort(host); err == nil {
host = hostname
}
return host
}
// matchDomain 域名匹配,支持通配符
// 例如:*.example.com 可以匹配 a.example.com, b.example.com 等
func matchDomain(host, pattern string) bool {
if pattern == "" {
return false
}
// 如果模式以 * 开头,进行通配符匹配
if after, ok := strings.CutPrefix(pattern, "*."); ok {
// 移除 *.
suffix := after
// 检查 host 是否以 .suffix 结尾
if strings.HasSuffix(host, "."+suffix) || host == suffix {
return true
}
}
// 如果模式以 * 结尾,进行前缀匹配
if before, ok := strings.CutSuffix(pattern, ".*"); ok {
prefix := before
if strings.HasPrefix(host, prefix+".") || host == prefix {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
package middleware
import (
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/logger"
)
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
func Jwt() http.Middleware {
return func(ctx http.Context) {
// 如果路径是api/admin前缀,使用admin guard
path := ctx.Request().Path()
pathStr := str.Of(path)
if pathStr.IsEmpty() || (!pathStr.StartsWith("/api/admin") && !pathStr.StartsWith("/admin")) {
ctx.Request().Next()
return
}
token := ctx.Request().Header("Authorization", "")
// 如果 Header 中没有 token,尝试从 URL 参数中获取(用于 SSE 等不支持自定义 headers 的场景)
if str.Of(token).IsEmpty() {
token = ctx.Request().Query("_token", "")
}
if str.Of(token).IsEmpty() {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 移除Bearer前缀(如果有)
token = str.Of(token).ChopStart("Bearer ").Trim().String()
if token == "" {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 从数据库查找token
tokenService := services.NewTokenServiceImpl()
accessToken, err := tokenService.FindToken(token)
if err != nil {
// token查找失败或已过期
logger.ErrorfHTTP(ctx, "JWT middleware: FindToken error: %v, token prefix: %s", err, token[:min(20, len(token))])
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
if accessToken == nil {
logger.ErrorfHTTP(ctx, "JWT middleware: accessToken is nil, token prefix: %s", token[:min(20, len(token))])
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
// 检查token类型
if accessToken.TokenableType != "admin" {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
// 查询用户信息
var admin models.Admin
if err := facades.Orm().Query().Where("id", accessToken.TokenableID).First(&admin); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
// 更新最后使用时间
_ = tokenService.UpdateLastUsedAt(token)
// 滑动过期:如果token有过期时间,每次请求时自动延长过期时间
if accessToken.ExpiresAt != nil {
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
if ttl > 0 {
newExpiresAt := time.Now().Add(time.Duration(ttl) * time.Minute)
// 更新token的过期时间
_, _ = facades.Orm().Query().
Model(&models.PersonalAccessToken{}).
Where("id", accessToken.ID).
Update("expires_at", newExpiresAt)
}
}
// 将用户信息存储到context中,供后续中间件使用
ctx.WithValue("admin", admin)
ctx.WithValue("token", accessToken)
// facades.Log().Debugf("JWT middleware: admin set in context, ID: %d, Username: %s", admin.ID, admin.Username)
ctx.Request().Next()
}
}
+18
View File
@@ -0,0 +1,18 @@
package middleware
import (
httpcontract "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/utils"
)
// Lang 多语言中间件,从请求头获取语言
func Lang() httpcontract.Middleware {
return func(ctx httpcontract.Context) {
// 使用通用工具函数获取语言
lang := utils.GetCurrentLanguage(ctx)
facades.App().SetLocale(ctx, lang)
ctx.Request().Next()
}
}
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"context"
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
"github.com/goravel/framework/contracts/http"
)
const (
OpentracingTracer = "opentracing_tracer"
OpentracingCtx = "opentracing_ctx"
)
func Opentracing(tracer opentracing.Tracer) http.Middleware {
return func(ctx http.Context) {
var parentSpan opentracing.Span
spCtx, err := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(ctx.Request().Headers()))
if err != nil {
parentSpan = tracer.StartSpan(ctx.Request().Path())
defer parentSpan.Finish()
} else {
parentSpan = opentracing.StartSpan(
ctx.Request().Path(),
opentracing.ChildOf(spCtx),
opentracing.Tag{Key: string(ext.Component), Value: "HTTP"},
ext.SpanKindRPCServer,
)
defer parentSpan.Finish()
}
ctx.WithValue(OpentracingTracer, tracer)
ctx.WithValue(OpentracingCtx, opentracing.ContextWithSpan(context.Background(), parentSpan))
ctx.Request().Next()
}
}
+139
View File
@@ -0,0 +1,139 @@
package middleware
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils"
"goravel/app/utils/logger"
"goravel/app/utils/traceid"
)
// OperationLog 操作日志中间件
func OperationLog() http.Middleware {
return func(ctx http.Context) {
systemLogService := services.NewSystemLogService()
startTime := time.Now()
// 获取请求信息
method := ctx.Request().Method()
path := ctx.Request().Path()
ip := ctx.Request().Ip()
userAgent := ctx.Request().Header("User-Agent", "")
// 获取请求参数(排除敏感信息)
var requestBody string
if method == "POST" || method == "PUT" || method == "PATCH" {
// 获取所有输入参数
inputs := make(map[string]any)
// 记录所有非敏感参数
allInputs := ctx.Request().All()
for key, value := range allInputs {
// 使用工具函数检查是否是敏感字段
if utils.IsSensitiveField(key) {
inputs[key] = "***"
} else {
inputs[key] = value
}
}
if data, err := json.Marshal(inputs); err == nil {
requestBody = string(data)
}
}
// 获取管理员ID(从JWT中间件设置的context中获取)
var adminID uint
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
adminID = admin.ID
}
// 继续处理请求
ctx.Request().Next()
// 计算耗时
duration := int(time.Since(startTime).Milliseconds())
// 只记录新增、修改、删除操作(POST、PUT、PATCH、DELETE),排除 GET 请求
// 同时排除登录和info接口,以及分片上传的进度查询(GET请求)
// 排除代码生成器相关操作
// 对于分片上传,只记录 merge 操作(最终完成上传),排除 init 和 upload 操作
if (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE") &&
path != "/api/admin/login" && path != "/api/admin/info" &&
!strings.HasPrefix(path, "/api/admin/code-generator/") {
// 排除分片上传的中间操作(init 和 upload),只记录 merge(最终完成上传)
if path == "/api/admin/attachments/chunk" {
action := ctx.Request().Input("action", "")
if action == "" {
action = ctx.Request().Query("action", "")
}
// 只记录 merge 操作,排除 init、upload 和 progress
if action != "merge" {
return
}
}
// 在请求处理后再获取一次管理员ID(确保JWT中间件已执行)
// 如果之前没有获取到,再次尝试从context获取
if adminID == 0 {
if admin, err := helpers.GetAdminFromContext(ctx); err == nil {
adminID = admin.ID
}
}
// 默认状态为成功
status := uint8(1)
var errorMsg string
// 在goroutine之前保存所有需要的数据,避免context问题
savedAdminID := adminID
savedMethod := method
savedPath := path
savedIP := ip
savedUserAgent := userAgent
savedRequestBody := requestBody
savedDuration := duration
// 提前获取 traceCtx,用于日志记录
traceCtx := traceid.DeriveContextFromHTTP(ctx)
// 生成操作标题(只使用权限标识)
title := utils.GetOperationTitleFromContext(ctx)
if title == "operation.unknown" {
// 如果无法生成标题,记录调试日志
logger.ErrorfContext(traceCtx, "Failed to generate operation title, method: %s, path: %s", savedMethod, savedPath)
}
operationLog := models.OperationLog{
AdminID: savedAdminID,
Method: savedMethod,
Path: savedPath,
Title: title,
IP: savedIP,
UserAgent: savedUserAgent,
Request: savedRequestBody,
Status: status,
ErrorMsg: errorMsg,
Duration: savedDuration,
}
// 异步记录日志,避免影响响应速度
go func(ctx context.Context) {
if err := facades.Orm().Query().Create(&operationLog); err != nil {
_ = systemLogService.Record(ctx, "error", "operation-log", "failed to persist operation log", map[string]any{
"error": err.Error(),
"path": savedPath,
})
logger.ErrorfContext(ctx, "Failed to create operation log: %v", err)
}
}(traceCtx)
}
}
}
+256
View File
@@ -0,0 +1,256 @@
package middleware
import (
"strings"
"github.com/goravel/framework/contracts/http"
"goravel/app/http/trans"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
"goravel/app/utils/logger"
)
// Permission 权限验证中间件
func Permission() http.Middleware {
return func(ctx http.Context) {
// 从context中获取admin信息(由JWT中间件设置)
adminValue := ctx.Value("admin")
if adminValue == nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": 401,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
admin, ok := adminValue.(models.Admin)
if !ok {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": 401,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
// 加载管理员的角色、权限等关联数据
adminService := services.NewAdminServiceImpl()
if err := adminService.LoadRelationsWithPermissions(&admin); err != nil {
logger.ErrorfHTTP(ctx, "permission middleware load relations failed: %v", err)
errorlog.RecordHTTP(ctx, "permission", "Failed to load admin relations with permissions", map[string]any{
"error": err.Error(),
"admin_id": admin.ID,
"path": ctx.Request().Path(),
}, "Load admin relations failed: %v", err)
_ = ctx.Response().Json(http.StatusInternalServerError, http.Json{
"code": 500,
"message": trans.Get(ctx, "load_permissions_failed"),
}).Abort()
return
}
// 检查是否是超级管理员
// 拥有 super-admin 角色的管理员(包括超级管理员和开发者管理员)都跳过权限“拦截”,但仍然参与权限匹配,用于生成操作标题
isSuperAdmin := false
for _, role := range admin.Roles {
if role.Slug == "super-admin" && role.Status == 1 {
isSuperAdmin = true
break
}
}
// 获取当前请求的方法和路径
method := ctx.Request().Method()
path := ctx.Request().Path()
// 收集所有角色的权限(已通过预加载获取)
var allPermissions []models.Permission
for _, role := range admin.Roles {
if role.Status == 1 {
allPermissions = append(allPermissions, role.Permissions...)
}
}
// 检查是否有权限,并记录匹配的权限标识
hasPermission := false
var matchedPermissionSlug string
var menuDisabled bool
for _, perm := range allPermissions {
if perm.Status == 1 {
// 检查方法匹配
if perm.Method == "" || perm.Method == method {
// 检查路径匹配(支持通配符)
if perm.Path == "" || perm.Path == path || matchPath(perm.Path, path) {
// 检查关联菜单的状态(如果权限关联了菜单)
// 如果权限没有关联菜单(MenuID = 0),则允许访问
// 如果权限关联了菜单,需要检查菜单状态是否为启用(status = 1)
if perm.MenuID == 0 {
// 权限没有关联菜单,允许访问
hasPermission = true
matchedPermissionSlug = perm.Slug
break
} else if perm.Menu.ID > 0 {
// 权限关联了菜单,检查菜单状态
if perm.Menu.Status == 1 {
// 菜单状态为启用,允许访问
hasPermission = true
matchedPermissionSlug = perm.Slug
break
} else {
// 菜单状态为关闭,记录但继续查找其他权限
menuDisabled = true
}
}
// 如果菜单没有加载(perm.Menu.ID == 0),为了安全起见,不允许访问
// 这种情况应该很少发生,因为我们已经预加载了菜单
}
}
}
}
// 非超级管理员且无匹配权限时拦截;超级管理员即使无匹配权限也放行
if !hasPermission && !isSuperAdmin {
// 如果是因为菜单状态为关闭而禁止访问,返回更具体的错误信息
if menuDisabled {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": 403,
"message": trans.Get(ctx, "menu_disabled"),
}).Abort()
} else {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": 403,
"message": trans.Get(ctx, "no_permission"),
}).Abort()
}
return
}
// 将匹配的权限标识存储到 context 中,供操作日志使用
if matchedPermissionSlug != "" {
ctx.WithValue("permission_slug", matchedPermissionSlug)
}
ctx.Request().Next()
}
}
// matchPath 路径匹配,支持通配符
// 支持的模式:
// 1. 精确匹配:/api/admin/roles 匹配 /api/admin/roles
// 2. 末尾通配符:/api/admin/roles/* 匹配 /api/admin/roles/1
// 3. 中间通配符:/api/admin/attachments/*/display-name 匹配 /api/admin/attachments/1/display-name
func matchPath(pattern, path string) bool {
if pattern == path {
return true
}
// 如果模式不包含通配符,直接返回 false
if !contains(pattern, '*') {
return false
}
// 将模式按 * 分割成多个部分
parts := splitPattern(pattern)
if len(parts) == 0 {
return false
}
// 如果模式以 * 开头,需要特殊处理
if pattern[0] == '*' {
// 检查路径是否以模式的剩余部分结尾
if len(parts) > 1 {
suffix := parts[1]
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
}
return true
}
// 如果模式以 * 结尾
if pattern[len(pattern)-1] == '*' {
prefix := pattern[:len(pattern)-1]
if len(path) >= len(prefix) {
pathPrefix := path[:len(prefix)]
if pathPrefix == prefix {
// 如果前缀以 / 结尾,路径必须比前缀长(即后面还有内容)
if len(prefix) > 0 && prefix[len(prefix)-1] == '/' {
return len(path) > len(prefix)
}
// 如果前缀不以 / 结尾,路径可以等于或长于前缀
return true
}
}
return false
}
// 处理中间有通配符的情况,如 /api/admin/attachments/*/display-name
// 将模式按 * 分割
patternParts := splitPattern(pattern)
if len(patternParts) < 2 {
return false
}
// 过滤掉 "*" 标记,只保留实际的部分
var actualParts []string
for _, part := range patternParts {
if part != "*" {
actualParts = append(actualParts, part)
}
}
if len(actualParts) < 2 {
return false
}
// 检查路径是否以第一部分开头
firstPart := actualParts[0]
if len(path) < len(firstPart) || path[:len(firstPart)] != firstPart {
return false
}
// 检查路径是否以最后一部分结尾
lastPart := actualParts[len(actualParts)-1]
if len(path) < len(lastPart) || path[len(path)-len(lastPart):] != lastPart {
return false
}
// 检查中间部分是否存在(通配符匹配任意内容)
// 路径应该是:firstPart + 任意内容 + lastPart
remainingPath := path[len(firstPart) : len(path)-len(lastPart)]
// 确保中间部分不为空(至少有一个字符,通常是数字ID)
return len(remainingPath) > 0
}
// contains 检查字符串是否包含指定字符
func contains(s string, c byte) bool {
for i := range s {
if s[i] == c {
return true
}
}
return false
}
// splitPattern 按 * 分割模式字符串
func splitPattern(pattern string) []string {
var parts []string
var current strings.Builder
for i := range pattern {
if pattern[i] == '*' {
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
parts = append(parts, "*")
} else {
current.WriteByte(pattern[i])
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
+40
View File
@@ -0,0 +1,40 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/http/helpers"
)
// TimezoneQuery 时区查询参数转换中间件
// 自动将查询参数中的时间字段从本地时区转换为 UTC 时间用于数据库查询
func TimezoneQuery() http.Middleware {
return func(ctx http.Context) {
// 定义需要转换的时间查询参数名称
timeParams := []string{
"start_time",
"end_time",
"created_at_start",
"created_at_end",
"updated_at_start",
"updated_at_end",
"deleted_at_start",
"deleted_at_end",
}
// 转换每个时间参数
for _, paramName := range timeParams {
timeStr := ctx.Request().Query(paramName, "")
if timeStr != "" {
// 转换为 UTC 时间并存储在 context 中
utcTime := helpers.ConvertTimeToUTC(ctx, timeStr)
// 将转换后的时间存储在 context 中,供控制器使用
ctx.WithValue("timezone_query_"+paramName, utcTime)
}
}
// 继续处理请求
ctx.Request().Next()
}
}
+17
View File
@@ -0,0 +1,17 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"goravel/app/utils/traceid"
)
// Trace middleware ensures every request carries a trace id and mirrors it in response headers.
func Trace() http.Middleware {
return func(ctx http.Context) {
traceID := traceid.EnsureHTTPContext(ctx, "")
ctx.Response().Header(traceid.HeaderName(), traceID)
ctx.Request().Next()
}
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
"github.com/goravel/framework/support/str"
"goravel/app/http/trans"
"goravel/app/models"
)
// UserJwt C端用户JWT认证中间件(使用Goravel标准Auth
func UserJwt() http.Middleware {
return func(ctx http.Context) {
// 如果路径是api/user前缀,使用user guard
path := ctx.Request().Path()
pathStr := str.Of(path)
if pathStr.IsEmpty() || !pathStr.StartsWith("/api/user") {
ctx.Request().Next()
return
}
// 使用Goravel标准Auth解析token
if _, err := facades.Auth(ctx).Guard("user").Parse(ctx.Request().Header("Authorization", "")); err != nil {
// 如果Header中没有token,尝试从URL参数中获取
if token := ctx.Request().Query("_token", ""); token != "" {
if _, err := facades.Auth(ctx).Guard("user").Parse(token); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "invalid_token"),
}).Abort()
return
}
} else {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "not_logged_in"),
}).Abort()
return
}
}
// 获取用户信息
var user models.User
if err := facades.Auth(ctx).Guard("user").User(&user); err != nil {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
if user.ID == 0 {
_ = ctx.Response().Json(http.StatusUnauthorized, http.Json{
"code": http.StatusUnauthorized,
"message": trans.Get(ctx, "user_not_found"),
}).Abort()
return
}
// 检查用户状态
if user.Status == 0 {
_ = ctx.Response().Json(http.StatusForbidden, http.Json{
"code": http.StatusForbidden,
"message": trans.Get(ctx, "account_disabled"),
}).Abort()
return
}
// 将用户信息存储到context中,供后续中间件使用
ctx.WithValue("user", user)
ctx.Request().Next()
}
}
+67
View File
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type AdminCreate struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
DepartmentID uint `form:"department_id" json:"department_id"`
Status uint8 `form:"status" json:"status"`
RoleIDs []uint `form:"role_ids" json:"role_ids"`
}
func (r *AdminCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *AdminCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required|min_len:3|max_len:50",
"password": "required|min_len:6|max_len:50",
"nickname": "max_len:50",
"email": "email|max_len:100",
"phone": "max_len:20",
"status": "in:0,1",
}
}
func (r *AdminCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"username.min_len": trans.Get(ctx, "validation_username_min"),
"username.max_len": trans.Get(ctx, "validation_username_max"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *AdminCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "validation_username"),
"password": trans.Get(ctx, "validation_password"),
"nickname": trans.Get(ctx, "validation_nickname"),
"email": trans.Get(ctx, "validation_email"),
"phone": trans.Get(ctx, "validation_phone"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *AdminCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将 status 字段转换为字符串,以便 in 规则能正确验证
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+60
View File
@@ -0,0 +1,60 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type AdminUpdate struct {
Nickname string `form:"nickname" json:"nickname"`
Email string `form:"email" json:"email"`
Phone string `form:"phone" json:"phone"`
Password string `form:"password" json:"password"`
DepartmentID uint `form:"department_id" json:"department_id"`
Status uint8 `form:"status" json:"status"`
RoleIDs []uint `form:"role_ids" json:"role_ids"`
}
func (r *AdminUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *AdminUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"nickname": "max_len:50",
"email": "email|max_len:100",
"phone": "max_len:20",
"password": "min_len:6|max_len:50",
"status": "in:0,1",
}
}
func (r *AdminUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"nickname.max_len": trans.Get(ctx, "validation_nickname_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
"password.max_len": trans.Get(ctx, "validation_password_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *AdminUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"nickname": trans.Get(ctx, "validation_nickname"),
"email": trans.Get(ctx, "validation_email"),
"phone": trans.Get(ctx, "validation_phone"),
"password": trans.Get(ctx, "validation_password"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *AdminUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将 status 字段转换为字符串,以便 in 规则能正确验证
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ArticleCreate struct {
Name string `form:"name" json:"name"`
Status string `form:"status" json:"status"`
}
func (r *ArticleCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *ArticleCreate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"name": "required",
"status": "",
}
return rules
}
func (r *ArticleCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"status.required": trans.Get(ctx, "validation_status_required"),
}
}
func (r *ArticleCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"status": trans.Get(ctx, "validation_status"),
}
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ArticleUpdate struct {
Name *string `form:"name" json:"name"`
Status *string `form:"status" json:"status"`
}
func (r *ArticleUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *ArticleUpdate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"name": "required",
"status": "",
}
return rules
}
func (r *ArticleUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"status.required": trans.Get(ctx, "validation_status_required"),
}
}
func (r *ArticleUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"status": trans.Get(ctx, "validation_status"),
}
}
@@ -0,0 +1,45 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type BlacklistCreate struct {
IP string `form:"ip" json:"ip"`
Remark string `form:"remark" json:"remark"`
Status uint8 `form:"status" json:"status"`
}
func (r *BlacklistCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *BlacklistCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"ip": "required",
"status": "in:0,1",
}
}
func (r *BlacklistCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"ip.required": trans.Get(ctx, "ip_address_required"),
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *BlacklistCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"ip": trans.Get(ctx, "validation_ip"),
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *BlacklistCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,42 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type BlacklistUpdate struct {
IP string `form:"ip" json:"ip"`
Remark string `form:"remark" json:"remark"`
Status uint8 `form:"status" json:"status"`
}
func (r *BlacklistUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *BlacklistUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"status": "in:0,1",
}
}
func (r *BlacklistUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"status.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *BlacklistUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"status": trans.Get(ctx, "validation_status"),
}
}
func (r *BlacklistUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,68 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DepartmentCreate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Leader string `form:"leader" json:"leader"`
Phone string `form:"phone" json:"phone"`
Email string `form:"email" json:"email"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DepartmentCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *DepartmentCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"code": "max_len:50",
"leader": "max_len:50",
"phone": "max_len:20",
"email": "email|max_len:100",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DepartmentCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "department_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DepartmentCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"leader": trans.Get(ctx, "validation_leader"),
"phone": trans.Get(ctx, "validation_phone"),
"email": trans.Get(ctx, "validation_email"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DepartmentCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DepartmentUpdate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Leader string `form:"leader" json:"leader"`
Phone string `form:"phone" json:"phone"`
Email string `form:"email" json:"email"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DepartmentUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *DepartmentUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "max_len:50",
"code": "max_len:50",
"leader": "max_len:50",
"phone": "max_len:20",
"email": "email|max_len:100",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DepartmentUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"leader.max_len": trans.Get(ctx, "validation_leader_max"),
"phone.max_len": trans.Get(ctx, "validation_phone_max"),
"email.email": trans.Get(ctx, "validation_email_format"),
"email.max_len": trans.Get(ctx, "validation_email_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DepartmentUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"leader": trans.Get(ctx, "validation_leader"),
"phone": trans.Get(ctx, "validation_phone"),
"email": trans.Get(ctx, "validation_email"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DepartmentUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,67 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DictionaryCreate struct {
Type string `form:"type" json:"type"`
Label string `form:"label" json:"label"`
Value string `form:"value" json:"value"`
TranslationKey string `form:"translation_key" json:"translation_key"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DictionaryCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *DictionaryCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"type": "required|max_len:50",
"label": "required|max_len:50",
"value": "required|max_len:100",
"translation_key": "max_len:255",
"description": "max_len:255",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DictionaryCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"type.required": trans.Get(ctx, "dictionary_type_required"),
"type.max_len": trans.Get(ctx, "validation_type_max"),
"label.required": trans.Get(ctx, "validation_label_required"),
"label.max_len": trans.Get(ctx, "validation_label_max"),
"value.required": trans.Get(ctx, "validation_value_required"),
"value.max_len": trans.Get(ctx, "validation_value_max"),
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DictionaryCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"type": trans.Get(ctx, "validation_type"),
"label": trans.Get(ctx, "validation_label"),
"value": trans.Get(ctx, "validation_value"),
"translation_key": trans.Get(ctx, "validation_translation_key"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DictionaryCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
@@ -0,0 +1,64 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type DictionaryUpdate struct {
Type string `form:"type" json:"type"`
Label string `form:"label" json:"label"`
Value string `form:"value" json:"value"`
TranslationKey string `form:"translation_key" json:"translation_key"`
Description string `form:"description" json:"description"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
Remark string `form:"remark" json:"remark"`
}
func (r *DictionaryUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *DictionaryUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"type": "max_len:50",
"label": "max_len:50",
"value": "max_len:100",
"translation_key": "max_len:255",
"description": "max_len:255",
"status": "in:0,1",
"remark": "max_len:500",
}
}
func (r *DictionaryUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"type.max_len": trans.Get(ctx, "validation_type_max"),
"label.max_len": trans.Get(ctx, "validation_label_max"),
"value.max_len": trans.Get(ctx, "validation_value_max"),
"translation_key.max_len": trans.Get(ctx, "validation_translation_key_max"),
"description.max_len": trans.Get(ctx, "validation_description_max"),
"status.in": trans.Get(ctx, "validation_status_in"),
"remark.max_len": trans.Get(ctx, "validation_remark_max"),
}
}
func (r *DictionaryUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"type": trans.Get(ctx, "validation_type"),
"label": trans.Get(ctx, "validation_label"),
"value": trans.Get(ctx, "validation_value"),
"translation_key": trans.Get(ctx, "validation_translation_key"),
"description": trans.Get(ctx, "validation_description"),
"status": trans.Get(ctx, "validation_status"),
"remark": trans.Get(ctx, "validation_remark"),
}
}
func (r *DictionaryUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
return helpers.PrepareNumericFieldForValidation(data, "status")
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type Login struct {
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
CaptchaID string `form:"captcha_id" json:"captcha_id"`
CaptchaAnswer string `form:"captcha_answer" json:"captcha_answer"`
GoogleCode string `form:"google_code" json:"google_code"` // 谷歌验证码
}
func (r *Login) Authorize(ctx http.Context) error {
return nil
}
func (r *Login) Rules(ctx http.Context) map[string]string {
return map[string]string{
"username": "required",
"password": "required|min_len:6",
}
}
func (r *Login) Messages(ctx http.Context) map[string]string {
return map[string]string{
"username.required": trans.Get(ctx, "validation_username_required"),
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
}
}
func (r *Login) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"username": trans.Get(ctx, "validation_username"),
"password": trans.Get(ctx, "validation_password"),
}
}
+110
View File
@@ -0,0 +1,110 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type MenuCreate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Title string `form:"title" json:"title"`
Slug string `form:"slug" json:"slug"`
Icon string `form:"icon" json:"icon"`
Path string `form:"path" json:"path"`
Component string `form:"component" json:"component"`
Permission string `form:"permission" json:"permission"`
Type uint8 `form:"type" json:"type"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
LinkType uint8 `form:"link_type" json:"link_type"`
OpenType uint8 `form:"open_type" json:"open_type"`
}
func (r *MenuCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *MenuCreate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"title": "required|max_len:50",
"slug": "required|max_len:50",
"icon": "max_len:50",
"component": "max_len:255",
"permission": "max_len:100",
"type": "in:1,2,3",
"status": "in:0,1",
"is_hidden": "in:0,1",
"link_type": "in:1,2",
"open_type": "in:1,2",
}
// 根据 link_type 动态设置 path 的验证规则
linkType := ctx.Request().Input("link_type")
if linkType == "2" {
// 外部链接:需要验证为完整的 URL
rules["path"] = "required|max_len:1000|full_url"
} else {
// 内部页面:只需要必填和长度验证
rules["path"] = "required|max_len:1000"
// 内部页面不验证 open_type
delete(rules, "open_type")
}
return rules
}
func (r *MenuCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"title.required": trans.Get(ctx, "menu_title_required"),
"title.max_len": trans.Get(ctx, "validation_title_max"),
"slug.required": trans.Get(ctx, "menu_slug_required"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
"path.required": trans.Get(ctx, "menu_path_required"),
"path.max_len": trans.Get(ctx, "validation_path_max"),
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
"component.max_len": trans.Get(ctx, "validation_component_max"),
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
"type.in": trans.Get(ctx, "validation_menu_type_in"),
"status.in": trans.Get(ctx, "validation_status_in"),
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *MenuCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"title": trans.Get(ctx, "validation_title"),
"slug": trans.Get(ctx, "validation_slug"),
"icon": trans.Get(ctx, "validation_icon"),
"path": trans.Get(ctx, "validation_path"),
"component": trans.Get(ctx, "validation_component"),
"permission": trans.Get(ctx, "validation_permission"),
"type": trans.Get(ctx, "validation_type"),
"status": trans.Get(ctx, "validation_status"),
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
}
}
func (r *MenuCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将数字字段转换为字符串,以便 in 规则能正确验证
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
return err
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package admin
import (
"goravel/app/http/helpers"
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/validation"
)
type MenuUpdate struct {
ParentID uint `form:"parent_id" json:"parent_id"`
Title string `form:"title" json:"title"`
Slug string `form:"slug" json:"slug"`
Icon string `form:"icon" json:"icon"`
Path string `form:"path" json:"path"`
Component string `form:"component" json:"component"`
Permission string `form:"permission" json:"permission"`
Type uint8 `form:"type" json:"type"`
Status uint8 `form:"status" json:"status"`
Sort int `form:"sort" json:"sort"`
IsHidden uint8 `form:"is_hidden" json:"is_hidden"`
LinkType uint8 `form:"link_type" json:"link_type"`
OpenType uint8 `form:"open_type" json:"open_type"`
}
func (r *MenuUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *MenuUpdate) Rules(ctx http.Context) map[string]string {
rules := map[string]string{
"title": "max_len:50",
"slug": "max_len:50",
"icon": "max_len:50",
"component": "max_len:255",
"permission": "max_len:100",
"type": "in:1,2,3",
"status": "in:0,1",
"is_hidden": "in:0,1",
"link_type": "in:1,2",
"open_type": "in:1,2",
}
// 根据 link_type 动态设置 path 的验证规则
linkType := ctx.Request().Input("link_type")
if linkType == "2" {
// 外部链接:需要验证为完整的 URL
rules["path"] = "max_len:1000|full_url"
} else {
// 内部页面:只需要长度验证
rules["path"] = "max_len:1000"
// 内部页面不验证 open_type
delete(rules, "open_type")
}
return rules
}
func (r *MenuUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"title.max_len": trans.Get(ctx, "validation_title_max"),
"slug.max_len": trans.Get(ctx, "validation_slug_max"),
"icon.max_len": trans.Get(ctx, "validation_icon_max"),
"path.max_len": trans.Get(ctx, "validation_path_max"),
"path.full_url": trans.Get(ctx, "validation_path_url_invalid"),
"component.max_len": trans.Get(ctx, "validation_component_max"),
"permission.max_len": trans.Get(ctx, "validation_permission_max"),
"type.in": trans.Get(ctx, "validation_menu_type_in"),
"status.in": trans.Get(ctx, "validation_status_in"),
"is_hidden.in": trans.Get(ctx, "validation_status_in"),
}
}
func (r *MenuUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"title": trans.Get(ctx, "validation_title"),
"slug": trans.Get(ctx, "validation_slug"),
"icon": trans.Get(ctx, "validation_icon"),
"path": trans.Get(ctx, "validation_path"),
"component": trans.Get(ctx, "validation_component"),
"permission": trans.Get(ctx, "validation_permission"),
"type": trans.Get(ctx, "validation_type"),
"status": trans.Get(ctx, "validation_status"),
"is_hidden": trans.Get(ctx, "validation_is_hidden"),
}
}
func (r *MenuUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// 将数字字段转换为字符串,以便 in 规则能正确验证
if err := helpers.PrepareNumericFieldForValidation(data, "type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "status"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "is_hidden"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "link_type"); err != nil {
return err
}
if err := helpers.PrepareNumericFieldForValidation(data, "open_type"); err != nil {
return err
}
return nil
}
@@ -0,0 +1,66 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type PaymentMethodCreate struct {
Name string `form:"name" json:"name"`
Code string `form:"code" json:"code"`
Type string `form:"type" json:"type"`
Config map[string]any `form:"config" json:"config"`
IsActive bool `form:"is_active" json:"is_active"`
Sort int `form:"sort" json:"sort"`
Description string `form:"description" json:"description"`
}
func (r *PaymentMethodCreate) Authorize(ctx http.Context) error {
return nil
}
func (r *PaymentMethodCreate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"code": "required|max_len:20",
"type": "required|max_len:20",
"config": "required",
"is_active": "boolean",
"sort": "min:0",
}
}
func (r *PaymentMethodCreate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"code.required": trans.Get(ctx, "validation_code_required"),
"code.max_len": trans.Get(ctx, "validation_code_max"),
"type.required": trans.Get(ctx, "validation_type_required"),
"type.max_len": trans.Get(ctx, "validation_type_max"),
"config.required": trans.Get(ctx, "validation_config_required"),
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
"sort.min": trans.Get(ctx, "validation_min"),
}
}
func (r *PaymentMethodCreate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"code": trans.Get(ctx, "validation_code"),
"type": trans.Get(ctx, "validation_type"),
"config": trans.Get(ctx, "validation_config"),
"is_active": trans.Get(ctx, "validation_is_active"),
"sort": trans.Get(ctx, "validation_sort"),
}
}
// func (r *PaymentMethodCreate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// // sort 字段使用 integer|min:0 规则,不需要转换为字符串
// // 如果 sort 为空或不存在,设置为默认值 0
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
// return data.Set("sort", 0)
// }
// return nil
// }
@@ -0,0 +1,52 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type PaymentMethodUpdate struct {
Name string `form:"name" json:"name"`
Config map[string]any `form:"config" json:"config"`
IsActive bool `form:"is_active" json:"is_active"`
Sort int `form:"sort" json:"sort"`
Description string `form:"description" json:"description"`
}
func (r *PaymentMethodUpdate) Authorize(ctx http.Context) error {
return nil
}
func (r *PaymentMethodUpdate) Rules(ctx http.Context) map[string]string {
return map[string]string{
"name": "required|max_len:50",
"is_active": "boolean",
"sort": "min:0",
}
}
func (r *PaymentMethodUpdate) Messages(ctx http.Context) map[string]string {
return map[string]string{
"name.required": trans.Get(ctx, "validation_name_required"),
"name.max_len": trans.Get(ctx, "validation_name_max"),
"is_active.boolean": trans.Get(ctx, "validation_boolean"),
"sort.min": trans.Get(ctx, "validation_min"),
}
}
func (r *PaymentMethodUpdate) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"name": trans.Get(ctx, "validation_name"),
"is_active": trans.Get(ctx, "validation_is_active"),
"sort": trans.Get(ctx, "validation_sort"),
}
}
// func (r *PaymentMethodUpdate) PrepareForValidation(ctx http.Context, data validation.Data) error {
// // return helpers.PrepareNumericFieldForValidation(data, "sort")
// if val, exist := data.Get("sort"); !exist || val == nil || val == "" {
// return data.Set("sort", 0)
// }
// return nil
// }
+34
View File
@@ -0,0 +1,34 @@
package admin
import (
"goravel/app/http/trans"
"github.com/goravel/framework/contracts/http"
)
type ResetPassword struct {
Password string `form:"password" json:"password"`
}
func (r *ResetPassword) Authorize(ctx http.Context) error {
return nil
}
func (r *ResetPassword) Rules(ctx http.Context) map[string]string {
return map[string]string{
"password": "required|min_len:6",
}
}
func (r *ResetPassword) Messages(ctx http.Context) map[string]string {
return map[string]string{
"password.required": trans.Get(ctx, "validation_password_required"),
"password.min_len": trans.Get(ctx, "validation_password_min"),
}
}
func (r *ResetPassword) Attributes(ctx http.Context) map[string]string {
return map[string]string{
"password": trans.Get(ctx, "validation_password"),
}
}

Some files were not shown because too many files have changed in this diff Show More