init
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
感谢您的贡献!🎉
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
@@ -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.
|
||||
@@ -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
@@ -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/
|
||||
|
||||
### 二进制压缩
|
||||
|
||||
为了减小二进制文件大小,可以使用 UPX(Ultimate Packer for eXecutables)压缩编译后的可执行文件:
|
||||
|
||||
**Windows:**
|
||||
|
||||
1. 下载 UPX(Windows 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) 下发布的开源软件。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 connection(default)
|
||||
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)
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package constants
|
||||
|
||||
// UserBalanceLogsShards 用户余额变动记录表的分表数量
|
||||
// 建议设置为 2 的幂次(如 4, 8, 16, 32, 64, 128 等)
|
||||
// 修改此值即可,所有相关代码会自动使用此常量
|
||||
const UserBalanceLogsShards = 4
|
||||
@@ -0,0 +1,12 @@
|
||||
package constants
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// OnlineAdminThreshold 在线管理员判断阈值
|
||||
// 如果管理员的 last_used_at 在这个时间范围内,则认为管理员在线
|
||||
OnlineAdminThreshold = 15 * time.Minute
|
||||
|
||||
// DefaultCleanLogDays 默认清理日志的天数
|
||||
DefaultCleanLogDays = 30
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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(®isterRequest)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package helpers
|
||||
|
||||
// 注意:FindByID 函数已移动到 response 包中,以避免循环导入
|
||||
// 请使用 response.FindByID 代替 helpers.FindByID
|
||||
// 相关的 FindByIDOptions 类型也在 response 包中
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
@@ -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
Reference in New Issue
Block a user