feat: init
This commit is contained in:
commit
7800b7786d
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*/dist/
|
||||
build/
|
||||
*/build/
|
||||
|
||||
# pnpm
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*/.env
|
||||
*/.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Prisma
|
||||
backend/prisma/migrations/
|
||||
|
||||
8
.npmrc
Normal file
8
.npmrc
Normal file
@ -0,0 +1,8 @@
|
||||
# pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
|
||||
# 使用国内镜像(可选,根据需要取消注释)
|
||||
# registry=https://registry.npmmirror.com
|
||||
|
||||
280
DYNAMIC_MENU_GUIDE.md
Normal file
280
DYNAMIC_MENU_GUIDE.md
Normal file
@ -0,0 +1,280 @@
|
||||
# 动态菜单系统实现指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
已成功将菜单管理模块扩展为**完全动态化的菜单生成系统**。系统现在支持:
|
||||
|
||||
- ✅ 从数据库动态加载菜单
|
||||
- ✅ 根据用户权限自动过滤菜单
|
||||
- ✅ 动态生成路由配置
|
||||
- ✅ 动态生成侧边栏菜单
|
||||
- ✅ 支持多级菜单结构
|
||||
- ✅ 菜单级别的权限控制
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 后端架构
|
||||
|
||||
```
|
||||
数据库 (Menu表)
|
||||
↓
|
||||
MenusService.findUserMenus()
|
||||
↓
|
||||
根据用户权限过滤菜单
|
||||
↓
|
||||
返回树形菜单结构
|
||||
```
|
||||
|
||||
### 前端架构
|
||||
|
||||
```
|
||||
用户登录
|
||||
↓
|
||||
获取用户菜单 (API)
|
||||
↓
|
||||
存储到 Auth Store
|
||||
↓
|
||||
转换为路由配置 (convertMenusToRoutes)
|
||||
↓
|
||||
动态注册路由 (router.addRoute)
|
||||
↓
|
||||
转换为菜单项 (convertMenusToMenuItems)
|
||||
↓
|
||||
渲染到侧边栏
|
||||
```
|
||||
|
||||
## 🔧 实现细节
|
||||
|
||||
### 1. 数据库 Schema 更新
|
||||
|
||||
在 `Menu` 模型中添加了 `permission` 字段:
|
||||
|
||||
```prisma
|
||||
model Menu {
|
||||
// ... 其他字段
|
||||
permission String? /// 权限编码(用于控制菜单显示,如:menu:read)
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
**需要运行数据库迁移:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma migrate dev --name add_menu_permission
|
||||
```
|
||||
|
||||
### 2. 后端 API
|
||||
|
||||
#### 新增接口:`GET /menus/user-menus`
|
||||
|
||||
获取当前用户的菜单(根据权限自动过滤)
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "系统管理",
|
||||
"path": "/system",
|
||||
"icon": "SettingOutlined",
|
||||
"permission": null,
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "用户管理",
|
||||
"path": "/system/users",
|
||||
"component": "system/users/Index",
|
||||
"permission": "user:read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 前端工具函数
|
||||
|
||||
创建了 `frontend/src/utils/menu.ts`,包含:
|
||||
|
||||
- `convertMenusToMenuItems()`: 将数据库菜单转换为 Ant Design Vue Menu 格式
|
||||
- `convertMenusToRoutes()`: 将数据库菜单转换为 Vue Router 路由配置
|
||||
- `getIconComponent()`: 动态加载图标组件
|
||||
- `flattenMenus()`: 扁平化菜单树
|
||||
|
||||
### 4. Auth Store 扩展
|
||||
|
||||
在 `auth.ts` 中添加了:
|
||||
|
||||
- `menus`: 存储用户菜单数据
|
||||
- `fetchUserMenus()`: 获取用户菜单
|
||||
- 登录时自动获取菜单
|
||||
- 登出时清空菜单
|
||||
|
||||
### 5. 路由动态注册
|
||||
|
||||
在 `router/index.ts` 中:
|
||||
|
||||
- 基础路由(登录页、404等)保持静态
|
||||
- 业务路由从数据库动态加载
|
||||
- 登录后自动注册动态路由
|
||||
- 支持路由权限检查
|
||||
|
||||
### 6. 布局组件更新
|
||||
|
||||
`BasicLayout.vue` 现在:
|
||||
|
||||
- 从 `authStore.menus` 读取菜单数据
|
||||
- 使用 `convertMenusToMenuItems()` 转换菜单
|
||||
- 自动展开包含当前路径的父菜单
|
||||
|
||||
## 📝 使用方法
|
||||
|
||||
### 1. 创建菜单
|
||||
|
||||
在菜单管理界面创建菜单时,需要填写:
|
||||
|
||||
- **菜单名称**: 显示名称
|
||||
- **路由路径**: 如 `/system/users`
|
||||
- **图标**: Ant Design Icons 名称,如 `UserOutlined`
|
||||
- **组件路径**: Vue 组件路径,如 `system/users/Index`(相对于 `@/views/`)
|
||||
- **权限编码**: 可选,如 `user:read`(留空则所有用户可见)
|
||||
- **父菜单**: 可选,用于创建多级菜单
|
||||
- **排序**: 数字,控制显示顺序
|
||||
|
||||
### 2. 权限控制
|
||||
|
||||
#### 菜单级别权限
|
||||
|
||||
在菜单的"权限编码"字段设置权限码,如:
|
||||
|
||||
- `user:read` - 需要用户查看权限
|
||||
- `role:create` - 需要角色创建权限
|
||||
- 留空 - 所有用户可见
|
||||
|
||||
#### 路由级别权限
|
||||
|
||||
菜单的权限会自动添加到路由的 `meta.permissions` 中,路由守卫会自动检查。
|
||||
|
||||
### 3. 组件路径规则
|
||||
|
||||
组件路径应该相对于 `frontend/src/views/` 目录:
|
||||
|
||||
- ✅ `system/users/Index` → `@/views/system/users/Index.vue`
|
||||
- ✅ `dashboard/Index` → `@/views/dashboard/Index.vue`
|
||||
- ❌ `@/views/system/users/Index` (不需要 `@/views/` 前缀)
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
### 用户登录流程
|
||||
|
||||
```
|
||||
1. 用户输入账号密码
|
||||
↓
|
||||
2. 调用 login API
|
||||
↓
|
||||
3. 获取 token 和用户信息
|
||||
↓
|
||||
4. 调用 fetchUserMenus() 获取菜单
|
||||
↓
|
||||
5. 菜单数据存储到 authStore.menus
|
||||
↓
|
||||
6. 动态路由注册 (addDynamicRoutes)
|
||||
↓
|
||||
7. 跳转到首页
|
||||
```
|
||||
|
||||
### 页面访问流程
|
||||
|
||||
```
|
||||
1. 用户访问 /system/users
|
||||
↓
|
||||
2. 路由守卫检查认证
|
||||
↓
|
||||
3. 检查路由权限 (meta.permissions)
|
||||
↓
|
||||
4. 如果通过,渲染组件
|
||||
↓
|
||||
5. BasicLayout 显示侧边栏菜单
|
||||
```
|
||||
|
||||
## 🎯 示例场景
|
||||
|
||||
### 场景1: 创建带权限的菜单
|
||||
|
||||
1. 进入"菜单管理"
|
||||
2. 点击"新增菜单"
|
||||
3. 填写:
|
||||
- 名称: 用户管理
|
||||
- 路径: `/system/users`
|
||||
- 图标: `UserOutlined`
|
||||
- 组件路径: `system/users/Index`
|
||||
- 权限编码: `user:read`
|
||||
4. 保存
|
||||
|
||||
结果:只有拥有 `user:read` 权限的用户才能看到此菜单。
|
||||
|
||||
### 场景2: 创建多级菜单
|
||||
|
||||
1. 创建父菜单:
|
||||
- 名称: 系统管理
|
||||
- 路径: `/system`
|
||||
- 图标: `SettingOutlined`
|
||||
- (不填组件路径和权限编码)
|
||||
|
||||
2. 创建子菜单:
|
||||
- 名称: 用户管理
|
||||
- 路径: `/system/users`
|
||||
- 父菜单: 选择"系统管理"
|
||||
- 组件路径: `system/users/Index`
|
||||
|
||||
结果:侧边栏显示为:
|
||||
|
||||
```
|
||||
系统管理
|
||||
└─ 用户管理
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **数据库迁移**: 添加 `permission` 字段后,需要运行 Prisma 迁移
|
||||
2. **组件路径**: 确保组件文件存在,否则路由会失败
|
||||
3. **权限编码**: 必须与权限系统中定义的权限码一致
|
||||
4. **路由名称**: 自动生成,基于路径(如 `/system/users` → `SystemUsers`)
|
||||
5. **动态路由**: 只在登录后添加一次,刷新页面不会重复添加
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 菜单不显示
|
||||
|
||||
1. 检查菜单的 `validState` 是否为 1(有效)
|
||||
2. 检查用户是否有菜单对应的权限
|
||||
3. 检查浏览器控制台是否有错误
|
||||
|
||||
### 路由404
|
||||
|
||||
1. 检查组件路径是否正确
|
||||
2. 检查组件文件是否存在
|
||||
3. 检查路由是否已动态注册(查看 Vue DevTools)
|
||||
|
||||
### 图标不显示
|
||||
|
||||
1. 检查图标名称是否正确(Ant Design Icons)
|
||||
2. 检查图标是否已导入到项目中
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- 后端菜单服务: `backend/src/menus/menus.service.ts`
|
||||
- 后端菜单控制器: `backend/src/menus/menus.controller.ts`
|
||||
- 前端菜单工具: `frontend/src/utils/menu.ts`
|
||||
- 前端路由配置: `frontend/src/router/index.ts`
|
||||
- 前端布局组件: `frontend/src/layouts/BasicLayout.vue`
|
||||
- 前端菜单管理: `frontend/src/views/system/menus/Index.vue`
|
||||
|
||||
## 🚀 下一步优化建议
|
||||
|
||||
1. **菜单缓存**: 可以缓存菜单数据,减少API调用
|
||||
2. **菜单刷新**: 提供手动刷新菜单的功能
|
||||
3. **菜单搜索**: 在侧边栏添加菜单搜索功能
|
||||
4. **菜单收藏**: 允许用户收藏常用菜单
|
||||
5. **菜单拖拽排序**: 在管理界面支持拖拽排序
|
||||
303
README.md
Normal file
303
README.md
Normal file
@ -0,0 +1,303 @@
|
||||
# 比赛管理系统
|
||||
|
||||
一个基于 Vue 3 + NestJS 的现代化比赛管理系统,支持用户管理、角色权限、菜单管理、数据字典、系统配置和日志记录等核心功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- **框架**: Vue 3 + TypeScript
|
||||
- **构建工具**: Vite
|
||||
- **UI 组件库**: Ant Design Vue
|
||||
- **样式方案**: Tailwind CSS + SCSS + CSS Modules
|
||||
- **状态管理**: Pinia
|
||||
- **路由**: Vue Router 4
|
||||
- **HTTP 客户端**: Axios
|
||||
- **表单验证**: VeeValidate + Zod
|
||||
|
||||
### 后端
|
||||
- **框架**: NestJS + TypeScript
|
||||
- **数据库**: MySQL 8.0
|
||||
- **ORM**: Prisma
|
||||
- **认证授权**: JWT + RBAC (基于角色的访问控制)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
competition-management-system/
|
||||
├── frontend/ # 前端项目
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 接口
|
||||
│ │ ├── assets/ # 静态资源
|
||||
│ │ ├── components/# 公共组件
|
||||
│ │ ├── layouts/ # 布局组件
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── styles/ # 样式文件
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ └── views/ # 页面组件
|
||||
│ └── package.json
|
||||
│
|
||||
└── backend/ # 后端项目
|
||||
├── prisma/ # Prisma 配置
|
||||
│ └── schema.prisma
|
||||
├── src/
|
||||
│ ├── auth/ # 认证模块
|
||||
│ ├── users/ # 用户管理
|
||||
│ ├── roles/ # 角色管理
|
||||
│ ├── menus/ # 菜单管理
|
||||
│ ├── dict/ # 数据字典
|
||||
│ ├── config/ # 系统配置
|
||||
│ ├── logs/ # 日志记录
|
||||
│ └── prisma/ # Prisma 服务
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- pnpm >= 8.0.0
|
||||
- MySQL >= 8.0
|
||||
|
||||
### 安装 pnpm
|
||||
|
||||
如果还没有安装 pnpm,可以通过以下方式安装:
|
||||
|
||||
```bash
|
||||
# 使用 npm 安装
|
||||
npm install -g pnpm
|
||||
|
||||
# 或使用 corepack(Node.js 16.13+)
|
||||
corepack enable
|
||||
corepack prepare pnpm@latest --activate
|
||||
```
|
||||
|
||||
### 快速安装(推荐)
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# 安装所有依赖(前端 + 后端)
|
||||
pnpm install
|
||||
|
||||
# 或分别安装
|
||||
pnpm --filter frontend install
|
||||
pnpm --filter backend install
|
||||
```
|
||||
|
||||
### 后端设置
|
||||
|
||||
1. 进入后端目录:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. 安装依赖(如果未在根目录安装):
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 配置环境变量,创建 `.env` 文件:
|
||||
```env
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/competition_management?schema=public"
|
||||
JWT_SECRET="your-secret-key-change-in-production"
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
4. 初始化数据库:
|
||||
```bash
|
||||
# 生成 Prisma Client
|
||||
pnpm prisma:generate
|
||||
|
||||
# 运行数据库迁移
|
||||
pnpm prisma:migrate
|
||||
```
|
||||
|
||||
5. 启动开发服务器:
|
||||
```bash
|
||||
# 方式1:在后端目录
|
||||
pnpm start:dev
|
||||
|
||||
# 方式2:在根目录
|
||||
pnpm dev:backend
|
||||
```
|
||||
|
||||
后端服务将在 `http://localhost:3001` 启动。
|
||||
|
||||
### 前端设置
|
||||
|
||||
1. 进入前端目录:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. 安装依赖(如果未在根目录安装):
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 启动开发服务器:
|
||||
```bash
|
||||
# 方式1:在前端目录
|
||||
pnpm dev
|
||||
|
||||
# 方式2:在根目录
|
||||
pnpm dev:frontend
|
||||
```
|
||||
|
||||
前端应用将在 `http://localhost:3000` 启动。
|
||||
|
||||
### 同时启动前后端
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
这将同时启动前端和后端开发服务器。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 用户管理
|
||||
- 用户列表查询(分页)
|
||||
- 用户创建、编辑、删除
|
||||
- 用户角色分配
|
||||
|
||||
### 2. 角色权限 (RBAC)
|
||||
- 角色管理(创建、编辑、删除)
|
||||
- 权限分配
|
||||
- 基于角色的访问控制
|
||||
|
||||
### 3. 菜单管理
|
||||
- 菜单树形结构管理
|
||||
- 菜单权限配置
|
||||
- 动态路由生成
|
||||
|
||||
### 4. 数据字典
|
||||
- 字典类型管理
|
||||
- 字典项管理
|
||||
- 字典数据查询
|
||||
|
||||
### 5. 系统配置
|
||||
- 系统参数配置
|
||||
- 配置项管理
|
||||
|
||||
### 6. 日志记录
|
||||
- 操作日志记录
|
||||
- 日志查询和统计
|
||||
|
||||
## API 文档
|
||||
|
||||
### 认证接口
|
||||
|
||||
- `POST /api/auth/login` - 用户登录
|
||||
- `GET /api/auth/user-info` - 获取当前用户信息
|
||||
- `POST /api/auth/logout` - 用户登出
|
||||
|
||||
### 用户管理
|
||||
|
||||
- `GET /api/users` - 获取用户列表
|
||||
- `GET /api/users/:id` - 获取用户详情
|
||||
- `POST /api/users` - 创建用户
|
||||
- `PATCH /api/users/:id` - 更新用户
|
||||
- `DELETE /api/users/:id` - 删除用户
|
||||
|
||||
### 角色管理
|
||||
|
||||
- `GET /api/roles` - 获取角色列表
|
||||
- `GET /api/roles/:id` - 获取角色详情
|
||||
- `POST /api/roles` - 创建角色
|
||||
- `PATCH /api/roles/:id` - 更新角色
|
||||
- `DELETE /api/roles/:id` - 删除角色
|
||||
|
||||
### 菜单管理
|
||||
|
||||
- `GET /api/menus` - 获取菜单列表(树形结构)
|
||||
- `GET /api/menus/:id` - 获取菜单详情
|
||||
- `POST /api/menus` - 创建菜单
|
||||
- `PATCH /api/menus/:id` - 更新菜单
|
||||
- `DELETE /api/menus/:id` - 删除菜单
|
||||
|
||||
### 数据字典
|
||||
|
||||
- `GET /api/dict` - 获取字典列表
|
||||
- `GET /api/dict/code/:code` - 根据代码获取字典
|
||||
- `GET /api/dict/:id` - 获取字典详情
|
||||
- `POST /api/dict` - 创建字典
|
||||
- `PATCH /api/dict/:id` - 更新字典
|
||||
- `DELETE /api/dict/:id` - 删除字典
|
||||
|
||||
### 系统配置
|
||||
|
||||
- `GET /api/config` - 获取配置列表
|
||||
- `GET /api/config/key/:key` - 根据键获取配置
|
||||
- `GET /api/config/:id` - 获取配置详情
|
||||
- `POST /api/config` - 创建配置
|
||||
- `PATCH /api/config/:id` - 更新配置
|
||||
- `DELETE /api/config/:id` - 删除配置
|
||||
|
||||
### 日志记录
|
||||
|
||||
- `GET /api/logs` - 获取日志列表
|
||||
- `GET /api/logs/:id` - 获取日志详情
|
||||
- `POST /api/logs` - 创建日志
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码风格
|
||||
- 使用 ESLint 和 Prettier 进行代码格式化
|
||||
- 遵循 TypeScript 严格模式
|
||||
- 使用语义化的提交信息
|
||||
|
||||
### 提交规范
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 bug
|
||||
- `docs`: 文档更新
|
||||
- `style`: 代码格式调整
|
||||
- `refactor`: 代码重构
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具相关
|
||||
|
||||
## 部署
|
||||
|
||||
### 前端构建
|
||||
```bash
|
||||
# 方式1:在前端目录
|
||||
cd frontend
|
||||
pnpm build
|
||||
|
||||
# 方式2:在根目录
|
||||
pnpm build:frontend
|
||||
```
|
||||
|
||||
构建产物在 `frontend/dist` 目录。
|
||||
|
||||
### 后端构建
|
||||
```bash
|
||||
# 方式1:在后端目录
|
||||
cd backend
|
||||
pnpm build
|
||||
pnpm start:prod
|
||||
|
||||
# 方式2:在根目录
|
||||
pnpm build:backend
|
||||
cd backend
|
||||
pnpm start:prod
|
||||
```
|
||||
|
||||
### 同时构建前后端
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
222
TENANT_IMPLEMENTATION.md
Normal file
222
TENANT_IMPLEMENTATION.md
Normal file
@ -0,0 +1,222 @@
|
||||
# 多租户系统实现方案
|
||||
|
||||
## 实现概述
|
||||
|
||||
已成功实现完整的多租户系统,包括以下核心功能:
|
||||
|
||||
1. ✅ **租户管理模块**:创建、查看、更新、删除租户
|
||||
2. ✅ **数据隔离**:用户、角色、权限、菜单等数据按租户隔离
|
||||
3. ✅ **租户识别**:支持多种方式识别租户(请求头、子域名、JWT Token)
|
||||
4. ✅ **超级租户**:可以创建租户并分配菜单
|
||||
5. ✅ **菜单分配**:超级租户可以为租户分配菜单
|
||||
|
||||
## 核心变更
|
||||
|
||||
### 1. 数据库Schema变更
|
||||
|
||||
- 新增 `Tenant` 表(租户表)
|
||||
- 新增 `TenantMenu` 表(租户菜单关联表)
|
||||
- 在以下表添加 `tenantId` 字段:
|
||||
- `User`
|
||||
- `Role`
|
||||
- `Permission`
|
||||
- `Dict`
|
||||
- `Config`
|
||||
- 调整唯一性约束:从全局唯一改为租户内唯一
|
||||
|
||||
### 2. 新增模块
|
||||
|
||||
- **TenantsModule**: 租户管理模块
|
||||
- `TenantsController`: 租户CRUD接口
|
||||
- `TenantsService`: 租户业务逻辑
|
||||
- `TenantGuard`: 租户识别守卫(可选,当前未全局启用)
|
||||
- `Tenant`/`TenantId` 装饰器:获取当前租户信息
|
||||
|
||||
### 3. 修改的模块
|
||||
|
||||
- **AuthModule**:
|
||||
- 登录时支持租户识别
|
||||
- JWT Token中包含租户ID
|
||||
- 用户验证时检查租户匹配
|
||||
|
||||
- **UsersModule**:
|
||||
- 所有操作自动添加租户过滤
|
||||
- 创建用户时自动关联租户
|
||||
|
||||
- **RolesModule**:
|
||||
- 所有操作自动添加租户过滤
|
||||
- 创建角色时自动关联租户
|
||||
|
||||
- **PermissionsModule**:
|
||||
- 所有操作自动添加租户过滤
|
||||
- 创建权限时自动关联租户
|
||||
|
||||
- **MenusModule**:
|
||||
- 用户菜单查询基于租户分配的菜单
|
||||
- 菜单管理仍为全局(超级租户管理)
|
||||
|
||||
## 使用步骤
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run prisma:migrate:dev -- --name add_tenant_support
|
||||
```
|
||||
|
||||
### 2. 初始化超级租户
|
||||
|
||||
```bash
|
||||
npm run init:super-tenant
|
||||
```
|
||||
|
||||
这将创建:
|
||||
- 超级租户(code: `super`)
|
||||
- 超级管理员(username: `admin`, password: `admin123`)
|
||||
- 基础权限
|
||||
|
||||
### 3. 创建租户
|
||||
|
||||
使用超级管理员登录后,通过API创建租户:
|
||||
|
||||
```bash
|
||||
POST /api/tenants
|
||||
Headers:
|
||||
Authorization: Bearer <token>
|
||||
X-Tenant-Code: super
|
||||
Body:
|
||||
{
|
||||
"name": "租户A",
|
||||
"code": "tenant-a",
|
||||
"menuIds": [1, 2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 租户用户登录
|
||||
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Headers:
|
||||
X-Tenant-Code: tenant-a
|
||||
Body:
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
## 租户识别方式
|
||||
|
||||
系统支持以下方式识别租户(按优先级):
|
||||
|
||||
1. **请求头 `X-Tenant-Id`**: 直接指定租户ID
|
||||
2. **请求头 `X-Tenant-Code`**: 通过租户编码识别
|
||||
3. **子域名**: 从Host头提取子域名匹配
|
||||
4. **JWT Token**: Token中包含的tenantId
|
||||
|
||||
## 数据隔离机制
|
||||
|
||||
所有数据查询都会自动添加租户过滤条件:
|
||||
|
||||
```typescript
|
||||
// 示例:查询用户
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
const users = await prisma.user.findMany({ where });
|
||||
```
|
||||
|
||||
确保:
|
||||
- 每个租户只能看到自己的数据
|
||||
- 不同租户的数据完全隔离
|
||||
- 超级租户可以管理所有租户
|
||||
|
||||
## 菜单分配机制
|
||||
|
||||
- 菜单是全局的(由超级租户管理)
|
||||
- 通过 `TenantMenu` 表关联租户和菜单
|
||||
- 用户只能看到分配给其租户的菜单
|
||||
- 超级租户可以为租户分配/取消分配菜单
|
||||
|
||||
## API接口
|
||||
|
||||
### 租户管理
|
||||
|
||||
- `POST /api/tenants` - 创建租户
|
||||
- `GET /api/tenants` - 获取租户列表
|
||||
- `GET /api/tenants/:id` - 获取租户详情
|
||||
- `PATCH /api/tenants/:id` - 更新租户(包括菜单分配)
|
||||
- `DELETE /api/tenants/:id` - 删除租户
|
||||
- `GET /api/tenants/:id/menus` - 获取租户菜单树
|
||||
|
||||
### 其他接口
|
||||
|
||||
所有其他接口(用户、角色、权限等)都支持租户隔离,会自动根据请求中的租户信息过滤数据。
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 1. 请求拦截器添加租户信息
|
||||
|
||||
```typescript
|
||||
service.interceptors.request.use((config) => {
|
||||
const tenantCode = localStorage.getItem('tenantCode');
|
||||
if (tenantCode) {
|
||||
config.headers['X-Tenant-Code'] = tenantCode;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 登录后保存租户信息
|
||||
|
||||
```typescript
|
||||
// 登录成功后
|
||||
localStorage.setItem('tenantCode', response.data.user.tenantCode);
|
||||
localStorage.setItem('tenantId', response.data.user.tenantId);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据迁移**: 如果现有系统已有数据,需要将现有数据关联到超级租户
|
||||
2. **唯一性**: 用户名、邮箱等在租户内唯一,不同租户可以有相同的用户名
|
||||
3. **超级租户**: 超级租户不能被删除,且拥有所有权限
|
||||
4. **菜单管理**: 菜单是全局的,但通过分配机制实现租户级别的菜单显示
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `backend/src/tenants/` - 租户模块
|
||||
- `tenants.controller.ts`
|
||||
- `tenants.service.ts`
|
||||
- `tenants.module.ts`
|
||||
- `dto/create-tenant.dto.ts`
|
||||
- `dto/update-tenant.dto.ts`
|
||||
- `guards/tenant.guard.ts`
|
||||
- `decorators/tenant.decorator.ts`
|
||||
- `backend/scripts/init-super-tenant.ts` - 初始化超级租户脚本
|
||||
- `backend/docs/TENANT_GUIDE.md` - 详细使用指南
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `backend/prisma/schema.prisma` - 数据库Schema
|
||||
- `backend/src/app.module.ts` - 添加TenantsModule
|
||||
- `backend/src/auth/` - 认证相关修改
|
||||
- `backend/src/users/` - 用户服务修改
|
||||
- `backend/src/roles/` - 角色服务修改
|
||||
- `backend/src/permissions/` - 权限服务修改
|
||||
- `backend/src/menus/` - 菜单服务修改
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **租户守卫**: 可以全局启用TenantGuard,自动识别租户
|
||||
2. **租户配置**: 支持租户级别的系统配置
|
||||
3. **租户统计**: 添加租户使用统计功能
|
||||
4. **数据导出**: 支持租户数据导出和备份
|
||||
5. **租户主题**: 支持租户级别的UI主题定制
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. 测试租户数据隔离:确保不同租户的数据不会互相访问
|
||||
2. 测试菜单分配:验证租户只能看到分配的菜单
|
||||
3. 测试超级租户权限:验证超级租户可以管理所有租户
|
||||
4. 测试租户识别:验证各种租户识别方式都能正常工作
|
||||
|
||||
26
backend/.eslintrc.js
Normal file
26
backend/.eslintrc.js
Normal file
@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
47
backend/.gitignore
vendored
Normal file
47
backend/.gitignore
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
.env.staging
|
||||
# 保留示例文件
|
||||
!.env*.example
|
||||
|
||||
4
backend/.npmrc
Normal file
4
backend/.npmrc
Normal file
@ -0,0 +1,4 @@
|
||||
# 后端 pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
5
backend/.prettierrc
Normal file
5
backend/.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
184
backend/docs/ADMIN_ACCOUNT.md
Normal file
184
backend/docs/ADMIN_ACCOUNT.md
Normal file
@ -0,0 +1,184 @@
|
||||
# 超级管理员账号说明
|
||||
|
||||
## 📋 账号信息
|
||||
|
||||
### 登录凭据
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `cms@admin`
|
||||
- **昵称**: 超级管理员
|
||||
- **邮箱**: admin@example.com
|
||||
- **角色**: super_admin (超级管理员)
|
||||
|
||||
## 🔐 权限说明
|
||||
|
||||
超级管理员拥有系统所有权限,共 **27 个权限**:
|
||||
|
||||
### 用户管理权限
|
||||
|
||||
- `user:create` - 创建用户
|
||||
- `user:read` - 查看用户
|
||||
- `user:update` - 更新用户
|
||||
- `user:delete` - 删除用户
|
||||
|
||||
### 角色管理权限
|
||||
|
||||
- `role:create` - 创建角色
|
||||
- `role:read` - 查看角色
|
||||
- `role:update` - 更新角色
|
||||
- `role:delete` - 删除角色
|
||||
- `role:assign` - 分配角色
|
||||
|
||||
### 权限管理权限
|
||||
|
||||
- `permission:create` - 创建权限
|
||||
- `permission:read` - 查看权限
|
||||
- `permission:update` - 更新权限
|
||||
- `permission:delete` - 删除权限
|
||||
|
||||
### 菜单管理权限
|
||||
|
||||
- `menu:create` - 创建菜单
|
||||
- `menu:read` - 查看菜单
|
||||
- `menu:update` - 更新菜单
|
||||
- `menu:delete` - 删除菜单
|
||||
|
||||
### 数据字典权限
|
||||
|
||||
- `dict:create` - 创建字典
|
||||
- `dict:read` - 查看字典
|
||||
- `dict:update` - 更新字典
|
||||
- `dict:delete` - 删除字典
|
||||
|
||||
### 系统配置权限
|
||||
|
||||
- `config:create` - 创建配置
|
||||
- `config:read` - 查看配置
|
||||
- `config:update` - 更新配置
|
||||
- `config:delete` - 删除配置
|
||||
|
||||
### 日志管理权限
|
||||
|
||||
- `log:read` - 查看日志
|
||||
- `log:delete` - 删除日志
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 1. 登录系统
|
||||
|
||||
使用以下 API 登录:
|
||||
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "cms@admin"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"nickname": "超级管理员",
|
||||
"email": "admin@example.com",
|
||||
"avatar": null,
|
||||
"roles": ["super_admin"],
|
||||
"permissions": [
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete"
|
||||
// ... 所有 27 个权限
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用 Token 访问 API
|
||||
|
||||
```bash
|
||||
GET /api/users
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## 🔄 重新初始化
|
||||
|
||||
如果需要重新初始化超级管理员账号,可以运行:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pnpm init:admin
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- ✅ 创建/更新所有基础权限(27个)
|
||||
- ✅ 创建/更新超级管理员角色
|
||||
- ✅ 创建/更新 admin 用户
|
||||
- ✅ 分配角色给用户
|
||||
|
||||
**注意**: 如果用户已存在,密码会被重置为 `cms@admin`
|
||||
|
||||
## 🔍 验证账号
|
||||
|
||||
验证超级管理员账号是否创建成功:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node scripts/verify-admin.js
|
||||
```
|
||||
|
||||
## ⚠️ 安全建议
|
||||
|
||||
1. **首次登录后立即修改密码**
|
||||
2. **生产环境使用强密码**
|
||||
3. **定期更换密码**
|
||||
4. **不要将密码提交到版本控制**
|
||||
|
||||
## 📝 修改密码
|
||||
|
||||
可以通过以下方式修改密码:
|
||||
|
||||
### 方式一:通过 API
|
||||
|
||||
```bash
|
||||
PATCH /api/users/1
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"password": "new_strong_password"
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:通过数据库
|
||||
|
||||
```sql
|
||||
-- 需要先使用 bcrypt 加密密码
|
||||
UPDATE users
|
||||
SET password = '<bcrypt_hashed_password>'
|
||||
WHERE username = 'admin';
|
||||
```
|
||||
|
||||
### 方式三:通过脚本
|
||||
|
||||
可以修改 `scripts/init-admin.ts` 中的密码,然后重新运行脚本。
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
1. ✅ 使用 admin 账号登录系统
|
||||
2. ✅ 创建其他角色(如:编辑、查看者等)
|
||||
3. ✅ 创建其他用户并分配角色
|
||||
4. ✅ 配置菜单权限
|
||||
5. ✅ 开始使用系统
|
||||
183
backend/docs/DATABASE_SETUP.md
Normal file
183
backend/docs/DATABASE_SETUP.md
Normal file
@ -0,0 +1,183 @@
|
||||
# 数据库配置指南
|
||||
|
||||
## 1. 创建数据库
|
||||
|
||||
首先需要在 MySQL 中创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE db_competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## 2. 配置环境变量
|
||||
|
||||
### 方式一:复制示例文件
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 方式二:手动创建 .env 文件
|
||||
|
||||
在 `backend` 目录下创建 `.env` 文件,内容如下:
|
||||
|
||||
```env
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public"
|
||||
JWT_SECRET="your-secret-key-change-in-production"
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## 3. 配置说明
|
||||
|
||||
### DATABASE_URL 格式
|
||||
|
||||
```
|
||||
mysql://用户名:密码@主机:端口/数据库名?参数
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
- 本地 MySQL,默认端口:
|
||||
|
||||
```
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public"
|
||||
```
|
||||
|
||||
- 远程 MySQL:
|
||||
|
||||
```
|
||||
DATABASE_URL="mysql://user:password@192.168.1.100:3306/competition_management?schema=public"
|
||||
```
|
||||
|
||||
- 使用 SSL:
|
||||
|
||||
```
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/competition_management?schema=public&sslmode=require"
|
||||
```
|
||||
|
||||
- 包含特殊字符的密码(需要 URL 编码):
|
||||
```
|
||||
DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/competition_management?schema=public"
|
||||
```
|
||||
|
||||
### JWT_SECRET
|
||||
|
||||
用于 JWT token 签名的密钥,生产环境必须使用强随机字符串。
|
||||
|
||||
**生成方式:**
|
||||
|
||||
```bash
|
||||
# 使用 Node.js
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# 或使用 openssl
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 4. 初始化数据库
|
||||
|
||||
配置好 `.env` 文件后,执行以下命令初始化数据库:
|
||||
|
||||
```bash
|
||||
# 生成 Prisma Client
|
||||
pnpm prisma:generate
|
||||
|
||||
# 运行数据库迁移(创建表结构)
|
||||
pnpm prisma:migrate
|
||||
|
||||
# 或使用开发模式(会提示输入迁移名称)
|
||||
pnpm prisma:migrate dev
|
||||
```
|
||||
|
||||
## 5. 验证连接
|
||||
|
||||
### 方式一:使用 Prisma Studio
|
||||
|
||||
```bash
|
||||
pnpm prisma:studio
|
||||
```
|
||||
|
||||
这会打开一个可视化界面,可以在浏览器中查看和管理数据库。
|
||||
|
||||
### 方式二:测试连接
|
||||
|
||||
启动后端服务:
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
如果连接成功,服务会正常启动;如果失败,会显示具体的错误信息。
|
||||
|
||||
## 6. 常见问题
|
||||
|
||||
### 问题 1: 连接被拒绝
|
||||
|
||||
**错误信息:** `Can't reach database server`
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查 MySQL 服务是否启动
|
||||
- 检查主机和端口是否正确
|
||||
- 检查防火墙设置
|
||||
|
||||
### 问题 2: 认证失败
|
||||
|
||||
**错误信息:** `Access denied for user`
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查用户名和密码是否正确
|
||||
- 确认用户有访问该数据库的权限
|
||||
- 如果密码包含特殊字符,需要进行 URL 编码
|
||||
|
||||
### 问题 3: 数据库不存在
|
||||
|
||||
**错误信息:** `Unknown database`
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 先创建数据库(见步骤 1)
|
||||
- 检查数据库名称是否正确
|
||||
|
||||
### 问题 4: 字符集问题
|
||||
|
||||
**解决方案:**
|
||||
创建数据库时指定字符集:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## 7. 生产环境配置
|
||||
|
||||
生产环境建议:
|
||||
|
||||
1. **使用环境变量管理工具**(如 AWS Secrets Manager、Azure Key Vault)
|
||||
2. **使用连接池**(Prisma 默认已配置)
|
||||
3. **启用 SSL 连接**
|
||||
4. **定期备份数据库**
|
||||
5. **使用强密码和 JWT_SECRET**
|
||||
|
||||
## 8. 数据库迁移
|
||||
|
||||
### 创建新迁移
|
||||
|
||||
```bash
|
||||
pnpm prisma:migrate dev --name migration_name
|
||||
```
|
||||
|
||||
### 应用迁移(生产环境)
|
||||
|
||||
```bash
|
||||
pnpm prisma:migrate deploy
|
||||
```
|
||||
|
||||
### 重置数据库(开发环境)
|
||||
|
||||
```bash
|
||||
pnpm prisma:migrate reset
|
||||
```
|
||||
|
||||
**注意:** 这会删除所有数据,仅用于开发环境!
|
||||
165
backend/docs/DATABASE_URL_SOURCE.md
Normal file
165
backend/docs/DATABASE_URL_SOURCE.md
Normal file
@ -0,0 +1,165 @@
|
||||
# DATABASE_URL 来源说明
|
||||
|
||||
## 📍 定义位置
|
||||
|
||||
`DATABASE_URL` 在 `schema.prisma` 中定义:
|
||||
|
||||
```prisma
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL") // ← 从这里读取环境变量
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 加载流程
|
||||
|
||||
### 1. 配置文件定义
|
||||
|
||||
`DATABASE_URL` 定义在环境配置文件中:
|
||||
|
||||
**当前配置**:`.development.env` 文件
|
||||
```env
|
||||
DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public"
|
||||
```
|
||||
|
||||
### 2. NestJS ConfigModule 加载
|
||||
|
||||
在 `app.module.ts` 中配置:
|
||||
|
||||
```typescript
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.development.env'], // ← 从这里加载环境变量
|
||||
})
|
||||
```
|
||||
|
||||
**加载顺序**:
|
||||
1. NestJS ConfigModule 读取 `.development.env` 文件
|
||||
2. 将文件中的 `DATABASE_URL` 加载到 `process.env.DATABASE_URL`
|
||||
3. 应用启动时,所有模块都可以通过 `ConfigService` 访问
|
||||
|
||||
### 3. Prisma 读取
|
||||
|
||||
Prisma 在以下时机读取 `DATABASE_URL`:
|
||||
|
||||
1. **生成 Prisma Client 时**:
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
- 读取 `process.env.DATABASE_URL`
|
||||
- 生成类型定义(不连接数据库)
|
||||
|
||||
2. **运行迁移时**:
|
||||
```bash
|
||||
npx prisma migrate dev
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
- 读取 `process.env.DATABASE_URL`
|
||||
- 连接到数据库执行迁移
|
||||
|
||||
3. **应用运行时**:
|
||||
- `PrismaService` 初始化时读取 `process.env.DATABASE_URL`
|
||||
- 建立数据库连接
|
||||
|
||||
## 📂 配置文件优先级
|
||||
|
||||
根据 `app.module.ts` 的配置:
|
||||
|
||||
```typescript
|
||||
envFilePath: ['.development.env']
|
||||
```
|
||||
|
||||
**当前配置**:
|
||||
- ✅ 优先加载:`.development.env`
|
||||
- ⚠️ 注意:如果设置了 `ignoreEnvFile: true`,则不会加载文件,只使用系统环境变量
|
||||
|
||||
## 🔍 验证 DATABASE_URL 来源
|
||||
|
||||
### 方法 1:查看环境变量(应用运行时)
|
||||
|
||||
```bash
|
||||
# 启动应用后,访问配置验证接口
|
||||
curl http://localhost:3001/api/config-verification/env-info
|
||||
```
|
||||
|
||||
### 方法 2:查看启动日志
|
||||
|
||||
应用启动时会在控制台显示:
|
||||
```
|
||||
=== 环境配置验证 ===
|
||||
DATABASE_URL: 已设置 mysql://root:woshimima@localhost:3306/db_competition_management?schema=public
|
||||
```
|
||||
|
||||
### 方法 3:检查配置文件
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cat .development.env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
### 方法 4:在代码中验证
|
||||
|
||||
```typescript
|
||||
// 在任何服务中
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
const dbUrl = this.configService.get('DATABASE_URL');
|
||||
console.log('DATABASE_URL:', dbUrl);
|
||||
```
|
||||
|
||||
## 🔐 环境变量来源优先级
|
||||
|
||||
Prisma 读取 `DATABASE_URL` 的优先级:
|
||||
|
||||
1. **系统环境变量**(最高优先级)
|
||||
```bash
|
||||
export DATABASE_URL="mysql://..."
|
||||
```
|
||||
|
||||
2. **.env 文件**(通过 ConfigModule 加载)
|
||||
- `.development.env`
|
||||
- `.env`
|
||||
|
||||
3. **默认值**(如果都没有设置,Prisma 会报错)
|
||||
|
||||
## 📝 DATABASE_URL 格式
|
||||
|
||||
```
|
||||
mysql://用户名:密码@主机:端口/数据库名?参数
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```env
|
||||
# 本地数据库
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/db_competition_management?schema=public"
|
||||
|
||||
# 远程数据库
|
||||
DATABASE_URL="mysql://user:pass@192.168.1.100:3306/db_name?schema=public"
|
||||
|
||||
# 带 SSL
|
||||
DATABASE_URL="mysql://user:pass@host:3306/db_name?schema=public&sslmode=require"
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **密码包含特殊字符**:需要进行 URL 编码
|
||||
```env
|
||||
# 密码: p@ssw0rd
|
||||
DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/db"
|
||||
```
|
||||
|
||||
2. **配置文件安全**:
|
||||
- `.development.env` 不应提交到 Git
|
||||
- 生产环境使用环境变量或密钥管理服务
|
||||
|
||||
3. **Prisma 读取时机**:
|
||||
- Prisma 直接读取 `process.env.DATABASE_URL`
|
||||
- 不依赖 NestJS ConfigModule(但 ConfigModule 会将文件内容加载到 `process.env`)
|
||||
|
||||
## 🔧 当前配置总结
|
||||
|
||||
- **配置文件**:`.development.env`
|
||||
- **配置项**:`DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public"`
|
||||
- **加载方式**:NestJS ConfigModule → `process.env` → Prisma
|
||||
- **验证方式**:启动日志或 `/api/config-verification/env-info` 接口
|
||||
|
||||
290
backend/docs/ENVIRONMENT_CONFIG.md
Normal file
290
backend/docs/ENVIRONMENT_CONFIG.md
Normal file
@ -0,0 +1,290 @@
|
||||
# 环境配置指南
|
||||
|
||||
## 环境区分方案
|
||||
|
||||
项目支持通过 `NODE_ENV` 环境变量和不同的 `.env` 文件来区分开发和生产环境。
|
||||
|
||||
## 配置文件结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── .env # 默认配置(可选,作为后备)
|
||||
├── .env.development # 开发环境配置
|
||||
├── .env.production # 生产环境配置
|
||||
└── .env.test # 测试环境配置(可选)
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
配置文件按以下优先级加载:
|
||||
|
||||
1. `.env.${NODE_ENV}` - 根据当前环境加载(最高优先级)
|
||||
2. `.env` - 默认配置文件(后备)
|
||||
|
||||
例如:
|
||||
- `NODE_ENV=development` → 加载 `.env.development`
|
||||
- `NODE_ENV=production` → 加载 `.env.production`
|
||||
- 未设置 `NODE_ENV` → 默认加载 `.env.development`,然后 `.env`
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
### 创建 `.env.development` 文件
|
||||
|
||||
```env
|
||||
# 开发环境配置
|
||||
NODE_ENV=development
|
||||
|
||||
# 开发数据库(本地数据库)
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
||||
|
||||
# JWT 密钥(开发环境可以使用简单密钥)
|
||||
JWT_SECRET="dev-secret-key-not-for-production"
|
||||
|
||||
# 服务器端口
|
||||
PORT=3001
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# CORS 配置(开发环境允许所有来源)
|
||||
CORS_ORIGIN=*
|
||||
```
|
||||
|
||||
### 开发环境数据库命名建议
|
||||
|
||||
- 数据库名:`competition_management_dev`
|
||||
- 便于区分:开发和生产使用不同的数据库
|
||||
- 安全:避免误操作生产数据
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
### 创建 `.env.production` 文件
|
||||
|
||||
```env
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
|
||||
# 生产数据库(远程或云数据库)
|
||||
DATABASE_URL="mysql://prod_user:strong_password@prod-db-host:3306/competition_management?schema=public&sslmode=require"
|
||||
|
||||
# JWT 密钥(必须使用强随机字符串)
|
||||
# 生成方式: openssl rand -hex 32
|
||||
JWT_SECRET="your-production-secret-key-must-be-strong-and-random-64-chars"
|
||||
|
||||
# 服务器端口
|
||||
PORT=3001
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=error
|
||||
|
||||
# CORS 配置(生产环境指定具体域名)
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
|
||||
# 数据库连接池配置
|
||||
DB_POOL_MIN=2
|
||||
DB_POOL_MAX=10
|
||||
|
||||
# SSL/TLS 配置
|
||||
SSL_ENABLED=true
|
||||
```
|
||||
|
||||
### 生产环境数据库配置要点
|
||||
|
||||
1. **使用独立的数据库服务器**
|
||||
2. **启用 SSL 连接**(`sslmode=require`)
|
||||
3. **使用强密码**
|
||||
4. **限制数据库用户权限**(最小权限原则)
|
||||
5. **定期备份**
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 方式 1: 设置环境变量后启动
|
||||
NODE_ENV=development pnpm start:dev
|
||||
|
||||
# 方式 2: 在 package.json 中配置(推荐)
|
||||
# 已自动配置,直接运行:
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```bash
|
||||
# 方式 1: 设置环境变量后启动
|
||||
NODE_ENV=production pnpm start:prod
|
||||
|
||||
# 方式 2: 在部署脚本中设置
|
||||
export NODE_ENV=production
|
||||
pnpm start:prod
|
||||
```
|
||||
|
||||
### 测试环境(可选)
|
||||
|
||||
```bash
|
||||
# 创建 .env.test 文件
|
||||
NODE_ENV=test
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_test?schema=public"
|
||||
JWT_SECRET="test-secret-key"
|
||||
PORT=3002
|
||||
|
||||
# 运行测试
|
||||
NODE_ENV=test pnpm test
|
||||
```
|
||||
|
||||
## 数据库命名规范
|
||||
|
||||
建议使用以下命名规范来区分不同环境的数据库:
|
||||
|
||||
| 环境 | 数据库名 | 说明 |
|
||||
|------|---------|------|
|
||||
| 开发 | `competition_management_dev` | 开发环境数据库 |
|
||||
| 测试 | `competition_management_test` | 测试环境数据库 |
|
||||
| 生产 | `competition_management` | 生产环境数据库 |
|
||||
| 预发布 | `competition_management_staging` | 预发布环境数据库 |
|
||||
|
||||
## 创建不同环境的数据库
|
||||
|
||||
### 开发环境数据库
|
||||
|
||||
```sql
|
||||
CREATE DATABASE competition_management_dev
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 生产环境数据库
|
||||
|
||||
```sql
|
||||
CREATE DATABASE competition_management
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## 环境变量管理最佳实践
|
||||
|
||||
### 1. 使用 .gitignore
|
||||
|
||||
确保 `.env*` 文件不被提交到版本控制:
|
||||
|
||||
```gitignore
|
||||
# .env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
```
|
||||
|
||||
### 2. 提供示例文件
|
||||
|
||||
创建 `.env.example` 或 `.env.*.example` 文件作为模板:
|
||||
|
||||
```bash
|
||||
# 开发环境示例
|
||||
cp .env.development.example .env.development
|
||||
|
||||
# 生产环境示例
|
||||
cp .env.production.example .env.production
|
||||
```
|
||||
|
||||
### 3. 使用环境变量管理工具(生产环境)
|
||||
|
||||
- **Docker**: 使用 `docker-compose.yml` 中的 `env_file`
|
||||
- **Kubernetes**: 使用 `ConfigMap` 和 `Secret`
|
||||
- **云平台**:
|
||||
- AWS: Secrets Manager
|
||||
- Azure: Key Vault
|
||||
- GCP: Secret Manager
|
||||
|
||||
### 4. 验证配置
|
||||
|
||||
在应用启动时验证必要的环境变量:
|
||||
|
||||
```typescript
|
||||
// 可以在 main.ts 中添加验证
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建开发环境配置
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建开发环境配置文件
|
||||
cat > .env.development << EOF
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
||||
JWT_SECRET="dev-secret-key"
|
||||
PORT=3001
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. 创建生产环境配置
|
||||
|
||||
```bash
|
||||
# 创建生产环境配置文件(不要提交到 Git)
|
||||
cat > .env.production << EOF
|
||||
NODE_ENV=production
|
||||
DATABASE_URL="mysql://prod_user:password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
||||
JWT_SECRET="$(openssl rand -hex 32)"
|
||||
PORT=3001
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
NODE_ENV=development pnpm prisma:migrate dev
|
||||
|
||||
# 生产环境(部署时)
|
||||
NODE_ENV=production pnpm prisma:migrate deploy
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何确保使用正确的环境配置?
|
||||
|
||||
A: 在启动应用前检查 `NODE_ENV` 环境变量:
|
||||
```bash
|
||||
echo $NODE_ENV # 应该显示 development 或 production
|
||||
```
|
||||
|
||||
### Q: 生产环境配置应该存储在哪里?
|
||||
|
||||
A:
|
||||
- **不要提交到 Git**
|
||||
- 使用环境变量管理工具(如 Docker secrets、K8s secrets)
|
||||
- 或使用云平台提供的密钥管理服务
|
||||
|
||||
### Q: 如何在不同环境间切换?
|
||||
|
||||
A: 通过设置 `NODE_ENV` 环境变量:
|
||||
```bash
|
||||
# 开发环境
|
||||
export NODE_ENV=development
|
||||
pnpm start:dev
|
||||
|
||||
# 生产环境
|
||||
export NODE_ENV=production
|
||||
pnpm start:prod
|
||||
```
|
||||
|
||||
### Q: 数据库迁移如何区分环境?
|
||||
|
||||
A: Prisma 会根据 `DATABASE_URL` 环境变量自动使用对应的数据库:
|
||||
```bash
|
||||
# 开发环境迁移
|
||||
NODE_ENV=development pnpm prisma:migrate dev
|
||||
|
||||
# 生产环境迁移
|
||||
NODE_ENV=production pnpm prisma:migrate deploy
|
||||
```
|
||||
|
||||
254
backend/docs/ENV_CHANGE_GUIDE.md
Normal file
254
backend/docs/ENV_CHANGE_GUIDE.md
Normal file
@ -0,0 +1,254 @@
|
||||
# 修改 DATABASE_URL 后的操作指南
|
||||
|
||||
## 📋 操作决策树
|
||||
|
||||
```
|
||||
修改 DATABASE_URL
|
||||
│
|
||||
├─ 只改了连接信息(地址/端口/用户名/密码/数据库名)
|
||||
│ └─ schema.prisma 未修改
|
||||
│ ├─ 目标数据库已有表结构 → ✅ 只需重启应用
|
||||
│ └─ 目标数据库是空的 → ⚠️ 需要运行迁移
|
||||
│
|
||||
└─ 同时修改了 schema.prisma
|
||||
└─ ✅ 必须执行:生成 Client + 运行迁移
|
||||
```
|
||||
|
||||
## 🔄 场景 1:只修改连接信息(最常见)
|
||||
|
||||
### 情况 A:目标数据库已有表结构
|
||||
|
||||
**示例**:从本地数据库切换到远程数据库,但表结构已存在
|
||||
|
||||
```bash
|
||||
# 1. 修改 .development.env 文件
|
||||
DATABASE_URL="mysql://user:pass@new-host:3306/db_name?schema=public"
|
||||
|
||||
# 2. 重启应用即可(无需执行 Prisma 命令)
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
**原因**:
|
||||
|
||||
- Prisma Client 在应用启动时读取 `process.env.DATABASE_URL`
|
||||
- 如果目标数据库已有表结构,直接连接即可
|
||||
- 不需要重新生成 Client(类型定义没变)
|
||||
- 不需要运行迁移(表结构没变)
|
||||
|
||||
---
|
||||
|
||||
### 情况 B:目标数据库是空的(新数据库)
|
||||
|
||||
**示例**:切换到全新的数据库,还没有表结构
|
||||
|
||||
```bash
|
||||
# 1. 修改 .development.env 文件
|
||||
DATABASE_URL="mysql://user:pass@new-host:3306/new_db?schema=public"
|
||||
|
||||
# 2. 运行迁移创建表结构
|
||||
npm run prisma:migrate
|
||||
|
||||
# 或使用部署模式(生产环境)
|
||||
npm run prisma:migrate:deploy
|
||||
|
||||
# 3. 重启应用
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
**原因**:
|
||||
|
||||
- 新数据库没有表结构
|
||||
- 需要运行迁移来创建表
|
||||
- 迁移会读取 `process.env.DATABASE_URL` 连接到新数据库
|
||||
|
||||
---
|
||||
|
||||
## 🔄 场景 2:同时修改了 schema.prisma
|
||||
|
||||
**示例**:修改了数据库模型(添加/删除字段、表等)
|
||||
|
||||
```bash
|
||||
# 1. 修改 schema.prisma(添加字段、表等)
|
||||
|
||||
# 2. 生成 Prisma Client(必须)
|
||||
npm run prisma:generate
|
||||
|
||||
# 3. 创建并运行迁移(必须)
|
||||
npm run prisma:migrate
|
||||
# 会提示输入迁移名称,如:add_user_email_field
|
||||
|
||||
# 4. 重启应用
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
**原因**:
|
||||
|
||||
- schema.prisma 改变 → TypeScript 类型定义改变 → 需要重新生成 Client
|
||||
- 数据库结构改变 → 需要创建迁移并应用到数据库
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整操作流程
|
||||
|
||||
### 开发环境(推荐流程)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 1. 修改 .development.env 中的 DATABASE_URL
|
||||
vim .development.env
|
||||
|
||||
# 2. 检查目标数据库是否有表结构
|
||||
# 方式 A:使用 Prisma Studio 查看
|
||||
npm run prisma:studio
|
||||
|
||||
# 方式 B:直接连接数据库查看
|
||||
mysql -h host -u user -p database -e "SHOW TABLES;"
|
||||
|
||||
# 3. 根据情况选择操作:
|
||||
|
||||
# 情况 1:数据库已有表结构 → 只需重启
|
||||
npm run start:dev
|
||||
|
||||
# 情况 2:数据库是空的 → 运行迁移
|
||||
npm run prisma:migrate
|
||||
npm run start:dev
|
||||
|
||||
# 情况 3:修改了 schema.prisma → 生成 + 迁移
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
### 生产环境(部署流程)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 1. 修改生产环境配置文件或环境变量
|
||||
# 注意:生产环境通常使用环境变量,而不是文件
|
||||
|
||||
# 2. 生成 Prisma Client
|
||||
npm run prisma:generate
|
||||
|
||||
# 3. 运行迁移(生产环境使用 deploy,不会创建新迁移)
|
||||
NODE_ENV=production npm run prisma:migrate:deploy
|
||||
|
||||
# 4. 重启应用
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 快速检查清单
|
||||
|
||||
修改 `DATABASE_URL` 后,按以下顺序检查:
|
||||
|
||||
- [ ] **只改了连接信息?**
|
||||
- [ ] 目标数据库有表 → ✅ 重启应用
|
||||
- [ ] 目标数据库为空 → ⚠️ 运行迁移
|
||||
|
||||
- [ ] **修改了 schema.prisma?**
|
||||
- [ ] 是 → ✅ 生成 Client + 运行迁移
|
||||
- [ ] 否 → 跳过
|
||||
|
||||
- [ ] **应用启动后验证**
|
||||
- [ ] 检查启动日志中的 DATABASE_URL
|
||||
- [ ] 访问 `/api/config-verification/env-info` 验证
|
||||
- [ ] 测试数据库操作是否正常
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证方法
|
||||
|
||||
### 1. 验证 DATABASE_URL 是否生效
|
||||
|
||||
```bash
|
||||
# 启动应用后查看日志
|
||||
npm run start:dev
|
||||
|
||||
# 应该看到:
|
||||
# DATABASE_URL: 已设置 mysql://...
|
||||
```
|
||||
|
||||
### 2. 验证数据库连接
|
||||
|
||||
```bash
|
||||
# 使用 Prisma Studio 连接
|
||||
npm run prisma:studio
|
||||
|
||||
# 如果能打开并看到表,说明连接成功
|
||||
```
|
||||
|
||||
### 3. 验证表结构
|
||||
|
||||
```bash
|
||||
# 检查迁移状态
|
||||
npx prisma migrate status
|
||||
|
||||
# 应该显示:All migrations have been successfully applied
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见错误
|
||||
|
||||
### 错误 1:连接失败
|
||||
|
||||
```
|
||||
Error: Can't reach database server
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 检查 DATABASE_URL 格式是否正确
|
||||
- 检查数据库服务是否运行
|
||||
- 检查网络连接和防火墙
|
||||
|
||||
### 错误 2:表不存在
|
||||
|
||||
```
|
||||
Error: Table 'xxx' doesn't exist
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 运行迁移:`npm run prisma:migrate`
|
||||
- 或使用:`npx prisma db push`(仅开发环境)
|
||||
|
||||
### 错误 3:迁移状态不一致
|
||||
|
||||
```
|
||||
Error: The migration failed to apply
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- 检查迁移历史:`npx prisma migrate status`
|
||||
- 重置数据库(仅开发环境):`npx prisma migrate reset`
|
||||
- 或手动修复迁移文件
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关命令速查
|
||||
|
||||
| 操作 | 命令 | 说明 |
|
||||
| ----------- | ------------------------------- | ------------------------- |
|
||||
| 生成 Client | `npm run prisma:generate` | 根据 schema 生成类型 |
|
||||
| 创建迁移 | `npm run prisma:migrate` | 开发环境,会创建新迁移 |
|
||||
| 应用迁移 | `npm run prisma:migrate:deploy` | 生产环境,只应用已有迁移 |
|
||||
| 查看状态 | `npx prisma migrate status` | 查看迁移状态 |
|
||||
| 打开 Studio | `npm run prisma:studio` | 可视化数据库 |
|
||||
| 推送结构 | `npx prisma db push` | 直接同步 schema(仅开发) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
**修改 DATABASE_URL 后的最小操作**:
|
||||
|
||||
1. **只改连接信息 + 数据库有表** → ✅ **重启应用**
|
||||
2. **只改连接信息 + 数据库为空** → ⚠️ **运行迁移**
|
||||
3. **修改了 schema.prisma** → ✅ **生成 Client + 运行迁移**
|
||||
|
||||
**记住**:Prisma Client 在应用启动时读取 `process.env.DATABASE_URL`,所以修改后必须重启应用才能生效!
|
||||
219
backend/docs/MENU_INIT.md
Normal file
219
backend/docs/MENU_INIT.md
Normal file
@ -0,0 +1,219 @@
|
||||
# 菜单初始化指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
菜单初始化脚本会根据项目的前端路由配置,自动创建菜单数据到数据库中。脚本会创建树形结构的菜单,包括顶级菜单和子菜单。
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 1. 执行初始化脚本
|
||||
|
||||
在 `backend` 目录下执行:
|
||||
|
||||
```bash
|
||||
pnpm init:menus
|
||||
```
|
||||
|
||||
或者使用 npm:
|
||||
|
||||
```bash
|
||||
npm run init:menus
|
||||
```
|
||||
|
||||
### 2. 脚本功能
|
||||
|
||||
脚本会根据 `frontend/src/router/index.ts` 中的路由配置,自动创建以下菜单结构:
|
||||
|
||||
```
|
||||
仪表盘 (/dashboard)
|
||||
系统管理 (/system)
|
||||
├── 用户管理 (/system/users)
|
||||
├── 角色管理 (/system/roles)
|
||||
├── 菜单管理 (/system/menus)
|
||||
├── 数据字典 (/system/dict)
|
||||
├── 系统配置 (/system/config)
|
||||
└── 日志记录 (/system/logs)
|
||||
```
|
||||
|
||||
## 📝 菜单数据结构
|
||||
|
||||
### 顶级菜单
|
||||
|
||||
1. **仪表盘**
|
||||
- 路径: `/dashboard`
|
||||
- 图标: `DashboardOutlined`
|
||||
- 组件: `dashboard/Index`
|
||||
- 排序: 1
|
||||
|
||||
2. **系统管理**
|
||||
- 路径: `/system`
|
||||
- 图标: `SettingOutlined`
|
||||
- 组件: `null` (父菜单)
|
||||
- 排序: 10
|
||||
|
||||
### 系统管理子菜单
|
||||
|
||||
1. **用户管理**
|
||||
- 路径: `/system/users`
|
||||
- 图标: `UserOutlined`
|
||||
- 组件: `system/users/Index`
|
||||
- 排序: 1
|
||||
|
||||
2. **角色管理**
|
||||
- 路径: `/system/roles`
|
||||
- 图标: `TeamOutlined`
|
||||
- 组件: `system/roles/Index`
|
||||
- 排序: 2
|
||||
|
||||
3. **菜单管理**
|
||||
- 路径: `/system/menus`
|
||||
- 图标: `MenuOutlined`
|
||||
- 组件: `system/menus/Index`
|
||||
- 排序: 3
|
||||
|
||||
4. **数据字典**
|
||||
- 路径: `/system/dict`
|
||||
- 图标: `BookOutlined`
|
||||
- 组件: `system/dict/Index`
|
||||
- 排序: 4
|
||||
|
||||
5. **系统配置**
|
||||
- 路径: `/system/config`
|
||||
- 图标: `ToolOutlined`
|
||||
- 组件: `system/config/Index`
|
||||
- 排序: 5
|
||||
|
||||
6. **日志记录**
|
||||
- 路径: `/system/logs`
|
||||
- 图标: `FileTextOutlined`
|
||||
- 组件: `system/logs/Index`
|
||||
- 排序: 6
|
||||
|
||||
## 🔄 脚本特性
|
||||
|
||||
### 1. 幂等性
|
||||
|
||||
- 脚本支持重复执行
|
||||
- 如果菜单已存在(相同名称和父菜单),会更新现有菜单
|
||||
- 如果菜单不存在,会创建新菜单
|
||||
|
||||
### 2. 树形结构
|
||||
|
||||
- 自动处理父子菜单关系
|
||||
- 递归创建子菜单
|
||||
- 保持菜单层级结构
|
||||
|
||||
### 3. 数据更新
|
||||
|
||||
- 如果菜单已存在,会更新以下字段:
|
||||
- 路径 (path)
|
||||
- 图标 (icon)
|
||||
- 组件路径 (component)
|
||||
- 排序 (sort)
|
||||
- 有效状态 (validState)
|
||||
|
||||
## ⚙️ 自定义菜单数据
|
||||
|
||||
如果需要修改菜单数据,可以编辑 `backend/scripts/init-menus.ts` 文件中的 `menus` 数组:
|
||||
|
||||
```typescript
|
||||
const menus = [
|
||||
{
|
||||
name: '菜单名称',
|
||||
path: '/路由路径',
|
||||
icon: 'IconOutlined', // Ant Design Icons 图标名称
|
||||
component: '组件路径', // 相对于 views 目录的路径
|
||||
parentId: null, // null 表示顶级菜单
|
||||
sort: 1, // 排序值,越小越靠前
|
||||
children: [
|
||||
// 子菜单数组(可选)
|
||||
// ...
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 🗑️ 清空现有菜单(可选)
|
||||
|
||||
如果需要清空所有现有菜单后重新创建,可以取消注释脚本中的以下代码:
|
||||
|
||||
```typescript
|
||||
// 清空现有菜单
|
||||
console.log('🗑️ 清空现有菜单...');
|
||||
await prisma.menu.deleteMany({});
|
||||
console.log('✅ 已清空现有菜单\n');
|
||||
```
|
||||
|
||||
**注意**: 清空菜单会删除所有现有菜单数据,请谨慎操作!
|
||||
|
||||
## 📊 执行结果示例
|
||||
|
||||
脚本执行成功后会显示:
|
||||
|
||||
```
|
||||
🚀 开始初始化菜单数据...
|
||||
|
||||
📝 创建菜单...
|
||||
|
||||
✓ 仪表盘 (/dashboard)
|
||||
✓ 系统管理 (/system)
|
||||
✓ 用户管理 (/system/users)
|
||||
✓ 角色管理 (/system/roles)
|
||||
✓ 菜单管理 (/system/menus)
|
||||
✓ 数据字典 (/system/dict)
|
||||
✓ 系统配置 (/system/config)
|
||||
✓ 日志记录 (/system/logs)
|
||||
|
||||
🔍 验证结果...
|
||||
|
||||
📊 初始化结果:
|
||||
顶级菜单数量: 2
|
||||
总菜单数量: 8
|
||||
|
||||
📋 菜单结构:
|
||||
├─ 仪表盘 (/dashboard)
|
||||
├─ 系统管理 (/system)
|
||||
│ ├─ 用户管理 (/system/users)
|
||||
│ ├─ 角色管理 (/system/roles)
|
||||
│ ├─ 菜单管理 (/system/menus)
|
||||
│ ├─ 数据字典 (/system/dict)
|
||||
│ ├─ 系统配置 (/system/config)
|
||||
│ └─ 日志记录 (/system/logs)
|
||||
|
||||
✅ 菜单初始化完成!
|
||||
|
||||
🎉 菜单初始化脚本执行完成!
|
||||
```
|
||||
|
||||
## 🔍 验证菜单数据
|
||||
|
||||
初始化完成后,可以通过以下方式验证:
|
||||
|
||||
### 方式一:使用 Prisma Studio
|
||||
|
||||
```bash
|
||||
pnpm prisma:studio
|
||||
```
|
||||
|
||||
在浏览器中打开 Prisma Studio,查看 `menus` 表的数据。
|
||||
|
||||
### 方式二:通过菜单管理页面
|
||||
|
||||
1. 登录系统
|
||||
2. 访问"系统管理" -> "菜单管理"
|
||||
3. 查看菜单列表,确认菜单已正确创建
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **数据库连接**: 确保 `.env` 文件中的 `DATABASE_URL` 配置正确
|
||||
2. **Prisma Client**: 确保已运行 `pnpm prisma:generate` 生成 Prisma Client
|
||||
3. **数据库迁移**: 确保已运行 `pnpm prisma:migrate` 创建数据库表结构
|
||||
4. **图标名称**: 图标名称必须是有效的 Ant Design Icons 组件名称
|
||||
5. **路径格式**: 路由路径必须以 `/` 开头
|
||||
6. **组件路径**: 组件路径是相对于 `frontend/src/views/` 目录的路径
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [数据库配置指南](./DATABASE_SETUP.md)
|
||||
- [管理员账户初始化](./ADMIN_ACCOUNT.md)
|
||||
- [路由配置说明](../frontend/src/router/index.ts)
|
||||
312
backend/docs/MIGRATION_INCREMENTAL_GUIDE.md
Normal file
312
backend/docs/MIGRATION_INCREMENTAL_GUIDE.md
Normal file
@ -0,0 +1,312 @@
|
||||
# Prisma 增量迁移指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
Prisma 的迁移机制**已经内置了增量执行功能**。当你运行迁移命令时,Prisma 会自动:
|
||||
|
||||
- ✅ 只执行**新增的、未应用的**迁移
|
||||
- ✅ **跳过**已经执行过的迁移
|
||||
- ✅ 通过 `_prisma_migrations` 表跟踪迁移状态
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Prisma 如何跟踪迁移状态
|
||||
|
||||
Prisma 在数据库中维护一个特殊的表 `_prisma_migrations`,用于记录:
|
||||
|
||||
- 迁移名称(migration_name)
|
||||
- 应用时间(applied_at)
|
||||
- 迁移文件内容(checksum)
|
||||
- 其他元数据
|
||||
|
||||
每次迁移执行后,Prisma 会在这个表中记录一条记录,确保不会重复执行。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 迁移命令对比
|
||||
|
||||
### 1. `prisma migrate deploy`(生产环境推荐)
|
||||
|
||||
**特点**:
|
||||
|
||||
- ✅ **只执行未应用的迁移**
|
||||
- ✅ 不会创建新迁移
|
||||
- ✅ 不会重置数据库
|
||||
- ✅ 适合生产环境
|
||||
|
||||
**使用场景**:
|
||||
|
||||
- 生产环境部署
|
||||
- CI/CD 流程
|
||||
- 多环境同步
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 生产环境
|
||||
npm run prisma:migrate:deploy
|
||||
|
||||
# 或直接使用
|
||||
NODE_ENV=production prisma migrate deploy
|
||||
```
|
||||
|
||||
**执行逻辑**:
|
||||
|
||||
1. 读取 `prisma/migrations` 目录中的所有迁移文件
|
||||
2. 查询数据库中的 `_prisma_migrations` 表
|
||||
3. 对比找出未应用的迁移
|
||||
4. **只执行未应用的迁移**
|
||||
5. 在 `_prisma_migrations` 表中记录新应用的迁移
|
||||
|
||||
---
|
||||
|
||||
### 2. `prisma migrate dev`(开发环境推荐)
|
||||
|
||||
**特点**:
|
||||
|
||||
- ✅ 创建新迁移(如果有 schema 变更)
|
||||
- ✅ **只执行未应用的迁移**
|
||||
- ✅ 可能会重置开发数据库(如果使用 shadow database)
|
||||
- ✅ 适合开发环境
|
||||
|
||||
**使用场景**:
|
||||
|
||||
- 本地开发
|
||||
- Schema 变更后创建迁移
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
npm run prisma:migrate
|
||||
|
||||
# 或直接使用
|
||||
prisma migrate dev
|
||||
```
|
||||
|
||||
**执行逻辑**:
|
||||
|
||||
1. 检查 schema.prisma 是否有变更
|
||||
2. 如果有变更,创建新迁移文件
|
||||
3. 查询 `_prisma_migrations` 表找出未应用的迁移
|
||||
4. **只执行未应用的迁移**(包括新创建的)
|
||||
5. 记录到 `_prisma_migrations` 表
|
||||
|
||||
---
|
||||
|
||||
## 📊 查看迁移状态
|
||||
|
||||
### 检查哪些迁移已应用
|
||||
|
||||
```bash
|
||||
# 查看迁移状态
|
||||
npx prisma migrate status
|
||||
|
||||
# 输出示例:
|
||||
# ✅ Database schema is up to date!
|
||||
#
|
||||
# The following migrations have been applied:
|
||||
# - 20251118035205_init
|
||||
# - 20251118041000_add_comments
|
||||
# - 20251118211424_change_log_content_to_text
|
||||
```
|
||||
|
||||
### 直接查询数据库
|
||||
|
||||
```sql
|
||||
-- 查看所有已应用的迁移
|
||||
SELECT * FROM _prisma_migrations ORDER BY applied_at DESC;
|
||||
|
||||
-- 查看迁移名称和状态
|
||||
SELECT migration_name, applied_at, finished_at
|
||||
FROM _prisma_migrations
|
||||
ORDER BY applied_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实际使用场景
|
||||
|
||||
### 场景 1:生产环境部署
|
||||
|
||||
**情况**:生产数据库已经有部分迁移,现在要部署新版本
|
||||
|
||||
```bash
|
||||
# 1. 部署新代码(包含新的迁移文件)
|
||||
|
||||
# 2. 运行迁移(只会执行新增的迁移)
|
||||
npm run prisma:migrate:deploy
|
||||
|
||||
# Prisma 会自动:
|
||||
# - 检查 _prisma_migrations 表
|
||||
# - 找出未应用的迁移(如:20251120000000_new_feature)
|
||||
# - 只执行这个新迁移
|
||||
# - 跳过已执行的迁移(如:20251118035205_init)
|
||||
```
|
||||
|
||||
**结果**:
|
||||
|
||||
- ✅ 已执行的迁移不会重复执行
|
||||
- ✅ 只执行新增的迁移
|
||||
- ✅ 数据库结构同步到最新状态
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:多环境同步
|
||||
|
||||
**情况**:开发环境有 3 个迁移,生产环境只有 2 个
|
||||
|
||||
```bash
|
||||
# 开发环境迁移:
|
||||
# - 20251118035205_init ✅
|
||||
# - 20251118041000_add_comments ✅
|
||||
# - 20251118211424_change_log_content_to_text ✅
|
||||
|
||||
# 生产环境迁移:
|
||||
# - 20251118035205_init ✅
|
||||
# - 20251118041000_add_comments ✅
|
||||
# - 20251118211424_change_log_content_to_text ❌(未应用)
|
||||
|
||||
# 在生产环境运行:
|
||||
npm run prisma:migrate:deploy
|
||||
|
||||
# Prisma 会:
|
||||
# - 跳过前两个已应用的迁移
|
||||
# - 只执行最后一个未应用的迁移
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:回滚和修复
|
||||
|
||||
**情况**:某个迁移执行失败,需要修复
|
||||
|
||||
```bash
|
||||
# 1. 检查迁移状态
|
||||
npx prisma migrate status
|
||||
|
||||
# 2. 如果迁移失败,_prisma_migrations 表中不会有记录
|
||||
# 3. 修复迁移文件后,重新运行
|
||||
npm run prisma:migrate:deploy
|
||||
|
||||
# Prisma 会:
|
||||
# - 检查失败的迁移是否已记录
|
||||
# - 如果没有记录,会重新执行
|
||||
# - 如果已记录,会跳过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 不要手动修改 `_prisma_migrations` 表
|
||||
|
||||
这个表由 Prisma 自动管理,手动修改可能导致迁移状态不一致。
|
||||
|
||||
### 2. 迁移文件不要删除
|
||||
|
||||
即使迁移已执行,也不要删除 `prisma/migrations` 目录中的迁移文件。这些文件是迁移历史的一部分。
|
||||
|
||||
### 3. 生产环境使用 `migrate deploy`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:生产环境
|
||||
prisma migrate deploy
|
||||
|
||||
# ❌ 错误:生产环境不要使用
|
||||
prisma migrate dev # 可能会重置数据库
|
||||
```
|
||||
|
||||
### 4. 迁移文件顺序很重要
|
||||
|
||||
Prisma 按照迁移文件名(时间戳)的顺序执行迁移。确保迁移文件名的时间戳顺序正确。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排查
|
||||
|
||||
### 问题 1:迁移状态不一致
|
||||
|
||||
**症状**:`prisma migrate status` 显示状态不一致
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
# 1. 检查 _prisma_migrations 表
|
||||
SELECT * FROM _prisma_migrations;
|
||||
|
||||
# 2. 检查迁移文件
|
||||
ls -la prisma/migrations/
|
||||
|
||||
# 3. 如果迁移文件存在但未记录,手动标记(谨慎操作)
|
||||
# 或者重新运行迁移
|
||||
prisma migrate deploy
|
||||
```
|
||||
|
||||
### 问题 2:迁移重复执行
|
||||
|
||||
**症状**:迁移被重复执行
|
||||
|
||||
**原因**:`_prisma_migrations` 表中没有记录
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
# 检查迁移记录
|
||||
npx prisma migrate status
|
||||
|
||||
# 如果显示迁移未应用,但数据库结构已存在
|
||||
# 可能需要手动标记迁移为已应用(谨慎操作)
|
||||
```
|
||||
|
||||
### 问题 3:迁移文件丢失
|
||||
|
||||
**症状**:迁移文件被删除,但数据库中有记录
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
# 1. 从版本控制恢复迁移文件
|
||||
git checkout prisma/migrations/
|
||||
|
||||
# 2. 重新运行迁移检查
|
||||
npx prisma migrate status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关命令速查
|
||||
|
||||
| 命令 | 说明 | 使用场景 |
|
||||
| ----------------------- | ---------------------- | -------- |
|
||||
| `prisma migrate deploy` | 只执行未应用的迁移 | 生产环境 |
|
||||
| `prisma migrate dev` | 创建并执行迁移 | 开发环境 |
|
||||
| `prisma migrate status` | 查看迁移状态 | 所有环境 |
|
||||
| `prisma migrate reset` | 重置数据库(开发环境) | 开发环境 |
|
||||
| `prisma db push` | 直接同步 schema | 快速原型 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**Prisma 迁移机制的核心特点**:
|
||||
|
||||
1. ✅ **自动增量执行**:只执行未应用的迁移
|
||||
2. ✅ **状态跟踪**:通过 `_prisma_migrations` 表跟踪
|
||||
3. ✅ **安全可靠**:不会重复执行已应用的迁移
|
||||
4. ✅ **环境区分**:`migrate deploy` 用于生产,`migrate dev` 用于开发
|
||||
|
||||
**最佳实践**:
|
||||
|
||||
- 🎯 生产环境:使用 `prisma migrate deploy`
|
||||
- 🎯 开发环境:使用 `prisma migrate dev`
|
||||
- 🎯 定期检查:使用 `prisma migrate status` 查看状态
|
||||
- 🎯 版本控制:提交所有迁移文件到 Git
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [Prisma 官方迁移文档](https://www.prisma.io/docs/concepts/components/prisma-migrate)
|
||||
- [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) - Schema 修改指南
|
||||
- [DATABASE_SETUP.md](./DATABASE_SETUP.md) - 数据库设置指南
|
||||
131
backend/docs/QUICK_START_ENV.md
Normal file
131
backend/docs/QUICK_START_ENV.md
Normal file
@ -0,0 +1,131 @@
|
||||
# 环境配置快速参考
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 创建开发环境配置
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建开发环境配置文件
|
||||
cat > .env.development << 'EOF'
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
||||
JWT_SECRET="dev-secret-key"
|
||||
PORT=3001
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. 创建生产环境配置
|
||||
|
||||
```bash
|
||||
# 创建生产环境配置文件(不要提交到 Git)
|
||||
cat > .env.production << 'EOF'
|
||||
NODE_ENV=production
|
||||
DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
||||
JWT_SECRET="$(openssl rand -hex 32)"
|
||||
PORT=3001
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3. 创建数据库
|
||||
|
||||
```sql
|
||||
-- 开发环境数据库
|
||||
CREATE DATABASE competition_management_dev
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 生产环境数据库
|
||||
CREATE DATABASE competition_management
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
pnpm prisma:generate
|
||||
pnpm prisma:migrate
|
||||
|
||||
# 生产环境(部署时)
|
||||
NODE_ENV=production pnpm prisma:migrate:deploy
|
||||
```
|
||||
|
||||
## 📋 环境区分总结
|
||||
|
||||
| 项目 | 开发环境 | 生产环境 |
|
||||
|------|---------|---------|
|
||||
| **配置文件** | `.env.development` | `.env.production` |
|
||||
| **数据库名** | `competition_management_dev` | `competition_management` |
|
||||
| **启动命令** | `pnpm start:dev` | `pnpm start:prod` |
|
||||
| **迁移命令** | `pnpm prisma:migrate` | `pnpm prisma:migrate:deploy` |
|
||||
| **Prisma Studio** | `pnpm prisma:studio:dev` | `pnpm prisma:studio:prod` |
|
||||
| **日志级别** | `debug` | `error` |
|
||||
| **CORS** | `*` (所有来源) | 指定域名 |
|
||||
| **SSL** | 可选 | 必须启用 |
|
||||
|
||||
## 🔑 关键区别
|
||||
|
||||
### 开发环境
|
||||
- ✅ 使用本地数据库
|
||||
- ✅ 简单的 JWT 密钥(便于开发)
|
||||
- ✅ 详细的日志输出
|
||||
- ✅ 允许所有 CORS 来源
|
||||
- ✅ 热重载支持
|
||||
|
||||
### 生产环境
|
||||
- ✅ 独立的数据库服务器
|
||||
- ✅ 强随机 JWT 密钥
|
||||
- ✅ 最小化日志输出
|
||||
- ✅ 限制 CORS 来源
|
||||
- ✅ 启用 SSL/TLS
|
||||
- ✅ 连接池优化
|
||||
|
||||
## 📝 配置文件示例
|
||||
|
||||
### `.env.development`
|
||||
```env
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
||||
JWT_SECRET="dev-secret-key"
|
||||
PORT=3001
|
||||
LOG_LEVEL=debug
|
||||
CORS_ORIGIN=*
|
||||
```
|
||||
|
||||
### `.env.production`
|
||||
```env
|
||||
NODE_ENV=production
|
||||
DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
||||
JWT_SECRET="your-production-secret-key-must-be-strong-and-random"
|
||||
PORT=3001
|
||||
LOG_LEVEL=error
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
SSL_ENABLED=true
|
||||
DB_POOL_MIN=2
|
||||
DB_POOL_MAX=10
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **不要提交 `.env` 文件到 Git**
|
||||
2. **生产环境必须使用强密码和 JWT_SECRET**
|
||||
3. **生产环境建议启用 SSL 连接**
|
||||
4. **定期备份生产数据库**
|
||||
5. **使用不同的数据库名称区分环境**
|
||||
|
||||
## 🔍 验证配置
|
||||
|
||||
```bash
|
||||
# 检查当前环境
|
||||
echo $NODE_ENV
|
||||
|
||||
# 验证数据库连接(开发环境)
|
||||
NODE_ENV=development pnpm prisma:studio
|
||||
|
||||
# 验证数据库连接(生产环境)
|
||||
NODE_ENV=production pnpm prisma:studio:prod
|
||||
```
|
||||
|
||||
更多详细信息请查看 [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)
|
||||
|
||||
444
backend/docs/RBAC_EXAMPLES.md
Normal file
444
backend/docs/RBAC_EXAMPLES.md
Normal file
@ -0,0 +1,444 @@
|
||||
# RBAC 权限控制使用示例
|
||||
|
||||
## 📋 目录
|
||||
1. [基础使用](#基础使用)
|
||||
2. [角色控制示例](#角色控制示例)
|
||||
3. [权限控制示例](#权限控制示例)
|
||||
4. [完整示例](#完整示例)
|
||||
|
||||
## 🔧 基础使用
|
||||
|
||||
### 1. 创建权限
|
||||
|
||||
```typescript
|
||||
// 在数据库中创建权限
|
||||
const permissions = [
|
||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户' },
|
||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户' },
|
||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户' },
|
||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户' },
|
||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色' },
|
||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色' },
|
||||
];
|
||||
|
||||
for (const perm of permissions) {
|
||||
await prisma.permission.create({ data: perm });
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建角色并分配权限
|
||||
|
||||
```typescript
|
||||
// 创建管理员角色
|
||||
const adminRole = await prisma.role.create({
|
||||
data: {
|
||||
name: '管理员',
|
||||
code: 'admin',
|
||||
permissions: {
|
||||
create: [
|
||||
{ permission: { connect: { code: 'user:create' } } },
|
||||
{ permission: { connect: { code: 'user:read' } } },
|
||||
{ permission: { connect: { code: 'user:update' } } },
|
||||
{ permission: { connect: { code: 'user:delete' } } },
|
||||
{ permission: { connect: { code: 'role:create' } } },
|
||||
{ permission: { connect: { code: 'role:read' } } },
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建编辑角色(只有查看和更新权限)
|
||||
const editorRole = await prisma.role.create({
|
||||
data: {
|
||||
name: '编辑',
|
||||
code: 'editor',
|
||||
permissions: {
|
||||
create: [
|
||||
{ permission: { connect: { code: 'user:read' } } },
|
||||
{ permission: { connect: { code: 'user:update' } } },
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 给用户分配角色
|
||||
|
||||
```typescript
|
||||
// 给用户分配管理员角色
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
user: { connect: { id: 1 } },
|
||||
role: { connect: { code: 'admin' } }
|
||||
}
|
||||
});
|
||||
|
||||
// 用户可以有多个角色
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
user: { connect: { id: 1 } },
|
||||
role: { connect: { code: 'editor' } }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🎯 角色控制示例
|
||||
|
||||
### 在控制器中使用角色装饰器
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard) // 先验证 JWT,再验证角色
|
||||
export class UsersController {
|
||||
|
||||
// 所有已登录用户都可以查看
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
// 只有管理员和编辑可以创建用户
|
||||
@Post()
|
||||
@Roles('admin', 'editor')
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
// 只有管理员可以删除用户
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 权限控制示例
|
||||
|
||||
### 创建权限守卫(可选扩展)
|
||||
|
||||
```typescript
|
||||
// src/auth/guards/permissions.guard.ts
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
'permissions',
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions) {
|
||||
return true; // 没有权限要求,允许访问
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
const userPermissions = user.permissions || [];
|
||||
|
||||
// 检查用户是否拥有任一所需权限
|
||||
return requiredPermissions.some((permission) =>
|
||||
userPermissions.includes(permission),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建权限装饰器
|
||||
|
||||
```typescript
|
||||
// src/auth/decorators/permissions.decorator.ts
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const Permissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
```
|
||||
|
||||
### 使用权限控制
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common';
|
||||
import { Permissions } from '../auth/decorators/permissions.decorator';
|
||||
import { PermissionsGuard } from '../auth/guards/permissions.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class UsersController {
|
||||
|
||||
@Get()
|
||||
@Permissions('user:read') // 需要 user:read 权限
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Permissions('user:create') // 需要 user:create 权限
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Permissions('user:delete') // 需要 user:delete 权限
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 完整示例
|
||||
|
||||
### 完整的用户管理控制器
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard) // 所有接口都需要登录
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
// 查看用户列表 - 所有已登录用户都可以访问
|
||||
@Get()
|
||||
findAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.usersService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
);
|
||||
}
|
||||
|
||||
// 查看用户详情 - 所有已登录用户都可以访问
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(+id);
|
||||
}
|
||||
|
||||
// 创建用户 - 需要 admin 或 editor 角色
|
||||
@Post()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin', 'editor')
|
||||
create(@Body() createUserDto: CreateUserDto, @Request() req) {
|
||||
// req.user 包含当前用户信息(从 JWT 中提取)
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
// 更新用户 - 需要 admin 角色,或者用户自己更新自己
|
||||
@Patch(':id')
|
||||
@UseGuards(RolesGuard)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const userId = parseInt(id);
|
||||
const currentUserId = req.user.userId;
|
||||
|
||||
// 管理员可以更新任何人,普通用户只能更新自己
|
||||
if (req.user.roles?.includes('admin') || userId === currentUserId) {
|
||||
return this.usersService.update(userId, updateUserDto);
|
||||
}
|
||||
|
||||
throw new ForbiddenException('无权更新此用户');
|
||||
}
|
||||
|
||||
// 删除用户 - 只有管理员可以删除
|
||||
@Delete(':id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 权限检查流程
|
||||
|
||||
### 1. 用户登录
|
||||
|
||||
```typescript
|
||||
// POST /api/auth/login
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
// 返回
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"nickname": "管理员",
|
||||
"roles": ["admin"], // 用户的角色列表
|
||||
"permissions": [ // 用户的所有权限(从角色中聚合)
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete",
|
||||
"role:create",
|
||||
"role:read"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 访问受保护的接口
|
||||
|
||||
```typescript
|
||||
// 请求头
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
// 流程
|
||||
1. JwtAuthGuard 验证 Token
|
||||
└─> 提取用户信息,添加到 req.user
|
||||
|
||||
2. RolesGuard 检查角色
|
||||
└─> 从 req.user.roles 中检查是否包含所需角色
|
||||
└─> 如果包含,允许访问;否则返回 403 Forbidden
|
||||
```
|
||||
|
||||
## 🎨 前端权限控制示例
|
||||
|
||||
### Vue 3 中使用权限
|
||||
|
||||
```typescript
|
||||
// stores/auth.ts
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null);
|
||||
|
||||
// 检查是否有指定角色
|
||||
const hasRole = (role: string) => {
|
||||
return user.value?.roles?.includes(role) ?? false;
|
||||
};
|
||||
|
||||
// 检查是否有指定权限
|
||||
const hasPermission = (permission: string) => {
|
||||
return user.value?.permissions?.includes(permission) ?? false;
|
||||
};
|
||||
|
||||
// 检查是否有任一角色
|
||||
const hasAnyRole = (roles: string[]) => {
|
||||
return roles.some(role => hasRole(role));
|
||||
};
|
||||
|
||||
// 检查是否有任一权限
|
||||
const hasAnyPermission = (permissions: string[]) => {
|
||||
return permissions.some(perm => hasPermission(perm));
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
hasRole,
|
||||
hasPermission,
|
||||
hasAnyRole,
|
||||
hasAnyPermission,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 在组件中使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- 根据角色显示按钮 -->
|
||||
<a-button v-if="authStore.hasRole('admin')" @click="deleteUser">
|
||||
删除用户
|
||||
</a-button>
|
||||
|
||||
<!-- 根据权限显示按钮 -->
|
||||
<a-button v-if="authStore.hasPermission('user:create')" @click="createUser">
|
||||
创建用户
|
||||
</a-button>
|
||||
|
||||
<!-- 根据角色或权限显示 -->
|
||||
<a-button
|
||||
v-if="authStore.hasAnyRole(['admin', 'editor']) || authStore.hasPermission('user:update')"
|
||||
@click="editUser"
|
||||
>
|
||||
编辑用户
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next({ name: 'Login' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (to.meta.roles && !authStore.hasAnyRole(to.meta.roles)) {
|
||||
next({ name: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (to.meta.permissions && !authStore.hasAnyPermission(to.meta.permissions)) {
|
||||
next({ name: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
## 📊 权限矩阵示例
|
||||
|
||||
| 角色 | user:create | user:read | user:update | user:delete | role:create | role:read |
|
||||
|------|-------------|-----------|-------------|------------|-------------|-----------|
|
||||
| admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| editor | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| viewer | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
RBAC 权限控制的核心是:
|
||||
|
||||
1. **用户** ←→ **角色** ←→ **权限**
|
||||
2. 通过 `@Roles()` 装饰器控制接口访问
|
||||
3. 前端根据返回的 `roles` 和 `permissions` 控制 UI 显示
|
||||
4. 权限由 `resource:action` 组成,如 `user:create`
|
||||
|
||||
这样的设计既保证了安全性,又提供了良好的灵活性和可维护性!
|
||||
|
||||
397
backend/docs/RBAC_GUIDE.md
Normal file
397
backend/docs/RBAC_GUIDE.md
Normal file
@ -0,0 +1,397 @@
|
||||
# RBAC 权限控制详解
|
||||
|
||||
## 📚 什么是 RBAC?
|
||||
|
||||
**RBAC(Role-Based Access Control)** 即**基于角色的访问控制**,是一种权限管理模型。它的核心思想是:
|
||||
|
||||
> **用户 → 角色 → 权限**
|
||||
|
||||
通过给用户分配角色,角色拥有权限,从而间接地给用户授予权限。
|
||||
|
||||
## 🎯 RBAC 的核心概念
|
||||
|
||||
### 1. **用户(User)**
|
||||
|
||||
系统中的实际使用者,如:张三、李四
|
||||
|
||||
### 2. **角色(Role)**
|
||||
|
||||
一组权限的集合,如:管理员、编辑、访客
|
||||
|
||||
### 3. **权限(Permission)**
|
||||
|
||||
对资源的操作能力,如:创建用户、删除文章、查看报表
|
||||
|
||||
### 4. **资源(Resource)**
|
||||
|
||||
系统中的实体对象,如:用户、文章、订单
|
||||
|
||||
### 5. **操作(Action)**
|
||||
|
||||
对资源的操作类型,如:create(创建)、read(查看)、update(更新)、delete(删除)
|
||||
|
||||
## 🏗️ 项目中的 RBAC 架构
|
||||
|
||||
### 数据模型关系
|
||||
|
||||
```
|
||||
User (用户)
|
||||
↓ (多对多)
|
||||
UserRole (用户角色关联)
|
||||
↓
|
||||
Role (角色)
|
||||
↓ (多对多)
|
||||
RolePermission (角色权限关联)
|
||||
↓
|
||||
Permission (权限)
|
||||
├─ resource: 资源名称 (如: user, role, menu)
|
||||
└─ action: 操作类型 (如: create, read, update, delete)
|
||||
```
|
||||
|
||||
### 数据库表结构
|
||||
|
||||
#### 1. **users** - 用户表
|
||||
|
||||
存储系统用户的基本信息
|
||||
|
||||
#### 2. **roles** - 角色表
|
||||
|
||||
存储角色信息,如:
|
||||
|
||||
- `admin` - 管理员
|
||||
- `editor` - 编辑
|
||||
- `viewer` - 查看者
|
||||
|
||||
#### 3. **permissions** - 权限表
|
||||
|
||||
存储权限信息,权限由 `resource` + `action` 组成,如:
|
||||
|
||||
- `user:create` - 创建用户
|
||||
- `user:read` - 查看用户
|
||||
- `user:update` - 更新用户
|
||||
- `user:delete` - 删除用户
|
||||
- `role:create` - 创建角色
|
||||
- `menu:read` - 查看菜单
|
||||
|
||||
#### 4. **user_roles** - 用户角色关联表
|
||||
|
||||
用户和角色的多对多关系
|
||||
|
||||
#### 5. **role_permissions** - 角色权限关联表
|
||||
|
||||
角色和权限的多对多关系
|
||||
|
||||
## 🔄 RBAC 工作流程
|
||||
|
||||
### 1. **权限分配流程**
|
||||
|
||||
```
|
||||
1. 创建权限
|
||||
└─> 定义资源(resource)和操作(action)
|
||||
└─> 例如:user:create, user:read
|
||||
|
||||
2. 创建角色
|
||||
└─> 给角色分配权限
|
||||
└─> 例如:管理员角色 = [user:create, user:read, user:update, user:delete]
|
||||
|
||||
3. 给用户分配角色
|
||||
└─> 用户继承角色的所有权限
|
||||
└─> 例如:张三 = 管理员角色
|
||||
```
|
||||
|
||||
### 2. **权限验证流程**
|
||||
|
||||
```
|
||||
用户请求 API
|
||||
↓
|
||||
JWT 认证(验证用户身份)
|
||||
↓
|
||||
提取用户信息(包含 roles 和 permissions)
|
||||
↓
|
||||
RolesGuard 检查(检查用户是否有指定角色)
|
||||
↓
|
||||
PermissionGuard 检查(检查用户是否有指定权限)
|
||||
↓
|
||||
允许/拒绝访问
|
||||
```
|
||||
|
||||
## 💻 代码实现示例
|
||||
|
||||
### 1. **定义权限**
|
||||
|
||||
权限由 `resource` + `action` 组成:
|
||||
|
||||
```typescript
|
||||
// 权限示例
|
||||
{
|
||||
code: 'user:create', // 权限编码
|
||||
resource: 'user', // 资源:用户
|
||||
action: 'create', // 操作:创建
|
||||
name: '创建用户',
|
||||
description: '允许创建新用户'
|
||||
}
|
||||
|
||||
{
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
name: '查看用户',
|
||||
description: '允许查看用户列表和详情'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **创建角色并分配权限**
|
||||
|
||||
```typescript
|
||||
// 创建管理员角色
|
||||
const adminRole = await prisma.role.create({
|
||||
data: {
|
||||
name: '管理员',
|
||||
code: 'admin',
|
||||
permissions: {
|
||||
create: [
|
||||
{ permission: { connect: { code: 'user:create' } } },
|
||||
{ permission: { connect: { code: 'user:read' } } },
|
||||
{ permission: { connect: { code: 'user:update' } } },
|
||||
{ permission: { connect: { code: 'user:delete' } } },
|
||||
{ permission: { connect: { code: 'role:create' } } },
|
||||
// ... 更多权限
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **给用户分配角色**
|
||||
|
||||
```typescript
|
||||
// 给用户分配管理员角色
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
user: { connect: { id: userId } },
|
||||
role: { connect: { code: 'admin' } },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. **在控制器中使用权限控制**
|
||||
|
||||
#### 方式一:使用角色装饰器
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(RolesGuard)
|
||||
export class UsersController {
|
||||
@Get()
|
||||
@Roles('admin', 'editor') // 需要 admin 或 editor 角色
|
||||
findAll() {
|
||||
// 只有拥有 admin 或 editor 角色的用户才能访问
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin') // 只有 admin 角色可以删除
|
||||
remove() {
|
||||
// 只有管理员可以删除用户
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 方式二:使用权限装饰器(可扩展)
|
||||
|
||||
```typescript
|
||||
// 可以创建 PermissionGuard 和 @Permissions() 装饰器
|
||||
@Get()
|
||||
@Permissions('user:read') // 需要 user:read 权限
|
||||
findAll() {
|
||||
// 只有拥有 user:read 权限的用户才能访问
|
||||
}
|
||||
```
|
||||
|
||||
### 5. **获取用户权限**
|
||||
|
||||
```typescript
|
||||
// 在 AuthService 中
|
||||
private async getUserPermissions(userId: number): Promise<string[]> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) return [];
|
||||
|
||||
const permissions = new Set<string>();
|
||||
|
||||
// 遍历用户的所有角色
|
||||
user.roles?.forEach((ur: any) => {
|
||||
// 遍历角色的所有权限
|
||||
ur.role.permissions?.forEach((rp: any) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(permissions);
|
||||
// 返回: ['user:create', 'user:read', 'user:update', 'role:create', ...]
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 RBAC 的优势
|
||||
|
||||
### 1. **灵活性**
|
||||
|
||||
- ✅ 一个用户可以有多个角色
|
||||
- ✅ 一个角色可以有多个权限
|
||||
- ✅ 权限可以动态分配和回收
|
||||
|
||||
### 2. **可维护性**
|
||||
|
||||
- ✅ 权限变更只需修改角色,不需要逐个修改用户
|
||||
- ✅ 角色可以复用,减少重复配置
|
||||
|
||||
### 3. **可扩展性**
|
||||
|
||||
- ✅ 新增资源只需添加新的权限
|
||||
- ✅ 新增角色只需组合现有权限
|
||||
|
||||
### 4. **安全性**
|
||||
|
||||
- ✅ 最小权限原则:用户只获得必要的权限
|
||||
- ✅ 权限集中管理,便于审计
|
||||
|
||||
## 🎨 实际应用场景
|
||||
|
||||
### 场景 1:内容管理系统
|
||||
|
||||
```
|
||||
角色定义:
|
||||
- 超级管理员:所有权限
|
||||
- 内容管理员:文章 CRUD、评论管理
|
||||
- 编辑:文章创建、编辑
|
||||
- 作者:文章创建
|
||||
- 访客:文章查看
|
||||
|
||||
权限示例:
|
||||
- article:create
|
||||
- article:read
|
||||
- article:update
|
||||
- article:delete
|
||||
- comment:moderate
|
||||
```
|
||||
|
||||
### 场景 2:电商系统
|
||||
|
||||
```
|
||||
角色定义:
|
||||
- 平台管理员:所有权限
|
||||
- 店铺管理员:店铺管理、订单管理
|
||||
- 客服:订单查看、退款处理
|
||||
- 财务:订单查看、财务报表
|
||||
|
||||
权限示例:
|
||||
- order:create
|
||||
- order:read
|
||||
- order:update
|
||||
- order:refund
|
||||
- report:financial
|
||||
```
|
||||
|
||||
## 🔐 项目中的权限控制实现
|
||||
|
||||
### 1. **JWT 认证**
|
||||
|
||||
用户登录后获得 JWT Token,Token 中包含用户 ID
|
||||
|
||||
### 2. **JwtAuthGuard**
|
||||
|
||||
验证 JWT Token,提取用户信息
|
||||
|
||||
### 3. **RolesGuard**
|
||||
|
||||
检查用户是否拥有指定的角色
|
||||
|
||||
### 4. **权限获取**
|
||||
|
||||
登录时,系统会:
|
||||
|
||||
1. 查询用户的所有角色
|
||||
2. 查询角色关联的所有权限
|
||||
3. 合并所有权限并返回给前端
|
||||
|
||||
### 5. **前端权限控制**
|
||||
|
||||
前端可以根据返回的 `roles` 和 `permissions` 数组:
|
||||
|
||||
- 控制菜单显示
|
||||
- 控制按钮显示
|
||||
- 控制路由访问
|
||||
|
||||
## 📝 最佳实践
|
||||
|
||||
### 1. **权限命名规范**
|
||||
|
||||
```
|
||||
格式:resource:action
|
||||
示例:
|
||||
- user:create
|
||||
- user:read
|
||||
- user:update
|
||||
- user:delete
|
||||
- role:assign
|
||||
- menu:manage
|
||||
```
|
||||
|
||||
### 2. **角色命名规范**
|
||||
|
||||
```
|
||||
使用有意义的英文代码:
|
||||
- admin: 管理员
|
||||
- editor: 编辑
|
||||
- viewer: 查看者
|
||||
- guest: 访客
|
||||
```
|
||||
|
||||
### 3. **权限粒度**
|
||||
|
||||
- ✅ 不要过粗:避免一个权限包含太多操作
|
||||
- ✅ 不要过细:避免权限过多难以管理
|
||||
- ✅ 按业务模块划分:user、role、menu、dict 等
|
||||
|
||||
### 4. **默认角色**
|
||||
|
||||
建议创建以下默认角色:
|
||||
|
||||
- **超级管理员**:拥有所有权限
|
||||
- **普通用户**:基础查看权限
|
||||
- **访客**:只读权限
|
||||
|
||||
## 🚀 扩展功能
|
||||
|
||||
### 1. **权限继承**
|
||||
|
||||
可以实现角色继承,子角色继承父角色的权限
|
||||
|
||||
### 2. **动态权限**
|
||||
|
||||
可以根据数据范围动态控制权限,如:
|
||||
|
||||
- 用户只能管理自己创建的订单
|
||||
- 部门管理员只能管理本部门的用户
|
||||
|
||||
### 3. **权限缓存**
|
||||
|
||||
将用户权限缓存到 Redis,提高性能
|
||||
|
||||
### 4. **权限审计**
|
||||
|
||||
记录权限变更日志,便于追溯
|
||||
|
||||
## 📖 总结
|
||||
|
||||
RBAC 权限控制通过 **用户 → 角色 → 权限** 的三层关系,实现了灵活、可维护的权限管理系统。在你的项目中:
|
||||
|
||||
1. ✅ **用户** 通过 `user_roles` 表关联 **角色**
|
||||
2. ✅ **角色** 通过 `role_permissions` 表关联 **权限**
|
||||
3. ✅ **权限** 由 `resource` + `action` 组成
|
||||
4. ✅ 使用 `@Roles()` 装饰器控制接口访问
|
||||
5. ✅ 登录时返回用户的角色和权限列表
|
||||
|
||||
这样的设计既保证了安全性,又提供了良好的扩展性和可维护性!
|
||||
105
backend/docs/README.md
Normal file
105
backend/docs/README.md
Normal file
@ -0,0 +1,105 @@
|
||||
# 项目文档索引
|
||||
|
||||
本目录包含项目后端的所有指南和文档。
|
||||
|
||||
## 📚 文档分类
|
||||
|
||||
### 🚀 快速开始
|
||||
|
||||
- **[QUICK_START_ENV.md](./QUICK_START_ENV.md)** - 环境配置快速参考
|
||||
- 快速创建开发和生产环境配置
|
||||
- 环境区分总结表
|
||||
- 关键区别说明
|
||||
|
||||
### 🗄️ 数据库相关
|
||||
|
||||
- **[DATABASE_SETUP.md](./DATABASE_SETUP.md)** - 数据库配置指南
|
||||
- 创建数据库
|
||||
- DATABASE_URL 格式说明
|
||||
- 初始化数据库步骤
|
||||
- 验证连接方法
|
||||
|
||||
- **[DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md)** - DATABASE_URL 来源说明
|
||||
- DATABASE_URL 的定义位置
|
||||
- 加载流程详解
|
||||
- 配置文件优先级
|
||||
- 验证方法
|
||||
|
||||
- **[SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)** - Prisma Schema 修改指南
|
||||
- 修改 schema.prisma 后的操作步骤
|
||||
- 生成 Prisma Client
|
||||
- 应用数据库迁移
|
||||
- 验证迁移是否成功
|
||||
|
||||
- **[ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md)** - 修改 DATABASE_URL 后的操作指南
|
||||
- 操作决策树
|
||||
- 不同场景的处理方法
|
||||
- 完整操作流程
|
||||
- 常见错误解决
|
||||
|
||||
### ⚙️ 环境配置
|
||||
|
||||
- **[ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)** - 环境配置指南
|
||||
- 环境区分方案
|
||||
- 配置文件结构
|
||||
- 配置优先级
|
||||
- 开发/生产环境配置示例
|
||||
- 安全注意事项
|
||||
|
||||
### 🔐 权限管理
|
||||
|
||||
- **[RBAC_GUIDE.md](./RBAC_GUIDE.md)** - RBAC 权限系统指南
|
||||
- 权限系统架构
|
||||
- 权限模型说明
|
||||
- 使用示例
|
||||
- 最佳实践
|
||||
|
||||
- **[RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md)** - RBAC 使用示例
|
||||
- 完整的权限配置示例
|
||||
- 常见场景实现
|
||||
- 代码示例
|
||||
|
||||
### 👤 账户管理
|
||||
|
||||
- **[ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md)** - 管理员账户指南
|
||||
- 初始化管理员账户
|
||||
- 验证管理员账户
|
||||
- 账户管理说明
|
||||
|
||||
## 📖 文档使用建议
|
||||
|
||||
### 新项目设置流程
|
||||
|
||||
1. **环境配置** → [QUICK_START_ENV.md](./QUICK_START_ENV.md)
|
||||
2. **数据库设置** → [DATABASE_SETUP.md](./DATABASE_SETUP.md)
|
||||
3. **初始化管理员** → [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md)
|
||||
4. **权限配置** → [RBAC_GUIDE.md](./RBAC_GUIDE.md)
|
||||
|
||||
### 日常开发流程
|
||||
|
||||
- **修改数据库结构** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)
|
||||
- **修改环境变量** → [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md)
|
||||
- **配置权限** → [RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md)
|
||||
|
||||
### 问题排查
|
||||
|
||||
- **数据库连接问题** → [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md)
|
||||
- **环境配置问题** → [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)
|
||||
- **迁移问题** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)
|
||||
|
||||
## 🔍 快速查找
|
||||
|
||||
| 需求 | 文档 |
|
||||
|------|------|
|
||||
| 如何设置开发环境? | [QUICK_START_ENV.md](./QUICK_START_ENV.md) |
|
||||
| 如何配置数据库? | [DATABASE_SETUP.md](./DATABASE_SETUP.md) |
|
||||
| DATABASE_URL 从哪里来? | [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md) |
|
||||
| 修改 schema 后做什么? | [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) |
|
||||
| 修改环境变量后做什么? | [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md) |
|
||||
| 如何配置权限? | [RBAC_GUIDE.md](./RBAC_GUIDE.md) |
|
||||
| 如何创建管理员? | [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md) |
|
||||
|
||||
## 📝 文档更新记录
|
||||
|
||||
- 2024-11-19: 创建文档索引,归档所有指南文件
|
||||
|
||||
128
backend/docs/SCHEMA_CHANGE_GUIDE.md
Normal file
128
backend/docs/SCHEMA_CHANGE_GUIDE.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Prisma Schema 修改后的操作指南
|
||||
|
||||
## 修改 schema.prisma 后需要执行的步骤
|
||||
|
||||
### 1. 生成 Prisma Client(必须)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma generate
|
||||
# 或使用 npm script
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
**作用**:根据最新的 schema 重新生成 Prisma Client,使 TypeScript 类型和代码与数据库结构同步。
|
||||
|
||||
---
|
||||
|
||||
### 2. 应用数据库迁移(必须)
|
||||
|
||||
根据环境选择不同的方式:
|
||||
|
||||
#### 开发环境(推荐)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma migrate dev
|
||||
# 或使用 npm script
|
||||
npm run prisma:migrate
|
||||
```
|
||||
|
||||
**作用**:
|
||||
|
||||
- 应用待执行的迁移到数据库
|
||||
- 如果有新的迁移,会自动创建并应用
|
||||
- 会重置开发数据库(如果使用 shadow database)
|
||||
|
||||
#### 生产环境
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma migrate deploy
|
||||
# 或使用 npm script
|
||||
npm run prisma:migrate:deploy
|
||||
```
|
||||
|
||||
**作用**:
|
||||
|
||||
- 仅应用待执行的迁移,不会创建新迁移
|
||||
- 不会重置数据库
|
||||
- 适合生产环境使用
|
||||
|
||||
#### 快速同步(仅开发环境,不推荐用于生产)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
**作用**:
|
||||
|
||||
- 直接将 schema 变更推送到数据库
|
||||
- 不创建迁移文件
|
||||
- 适合快速原型开发
|
||||
|
||||
---
|
||||
|
||||
### 3. 重启应用(如果正在运行)
|
||||
|
||||
应用迁移后,需要重启 NestJS 应用以加载新的 Prisma Client:
|
||||
|
||||
```bash
|
||||
# 如果使用 npm run start:dev,会自动重启
|
||||
# 如果使用其他方式启动,需要手动重启
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 当前状态
|
||||
|
||||
✅ **已完成**:
|
||||
|
||||
- schema.prisma 已修改(content 字段改为 TEXT)
|
||||
- 迁移文件已创建:`20251118211424_change_log_content_to_text`
|
||||
|
||||
⏳ **待执行**:
|
||||
|
||||
1. 生成 Prisma Client
|
||||
2. 应用数据库迁移
|
||||
3. 重启应用(如果正在运行)
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```bash
|
||||
# 1. 生成 Prisma Client
|
||||
cd backend
|
||||
npx prisma generate
|
||||
|
||||
# 2. 应用迁移(开发环境)
|
||||
npx prisma migrate dev
|
||||
# 或生产环境
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 3. 重启应用(如果需要)
|
||||
# 如果使用 start:dev,会自动重启
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证迁移是否成功
|
||||
|
||||
```bash
|
||||
# 检查迁移状态
|
||||
npx prisma migrate status
|
||||
|
||||
# 查看数据库结构
|
||||
npx prisma studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **生产环境**:务必使用 `prisma migrate deploy`,不要使用 `prisma migrate dev`
|
||||
2. **备份数据**:在生产环境应用迁移前,建议先备份数据库
|
||||
3. **迁移冲突**:如果迁移失败,检查错误信息并解决后再继续
|
||||
4. **类型同步**:每次修改 schema 后都要运行 `prisma generate` 更新类型
|
||||
270
backend/docs/TENANT_GUIDE.md
Normal file
270
backend/docs/TENANT_GUIDE.md
Normal file
@ -0,0 +1,270 @@
|
||||
# 多租户系统实现指南
|
||||
|
||||
## 概述
|
||||
|
||||
本系统实现了完整的多租户架构,支持:
|
||||
- 每个租户独立的数据隔离(用户、角色、权限、菜单等)
|
||||
- 每个租户独立的访问链接(通过租户编码或域名)
|
||||
- 超级租户可以创建和管理其他租户
|
||||
- 超级租户可以为租户分配菜单
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
|
||||
1. **Tenant(租户表)**
|
||||
- `id`: 租户ID
|
||||
- `name`: 租户名称
|
||||
- `code`: 租户编码(唯一,用于访问链接)
|
||||
- `domain`: 租户域名(可选,用于子域名访问)
|
||||
- `isSuper`: 是否为超级租户(0-否,1-是)
|
||||
- `validState`: 有效状态(1-有效,2-失效)
|
||||
|
||||
2. **TenantMenu(租户菜单关联表)**
|
||||
- `tenantId`: 租户ID
|
||||
- `menuId`: 菜单ID
|
||||
- 用于关联租户和菜单,实现菜单分配
|
||||
|
||||
3. **其他表添加租户字段**
|
||||
- `User`: 添加 `tenantId` 字段
|
||||
- `Role`: 添加 `tenantId` 字段
|
||||
- `Permission`: 添加 `tenantId` 字段
|
||||
- `Dict`: 添加 `tenantId` 字段
|
||||
- `Config`: 添加 `tenantId` 字段
|
||||
|
||||
### 唯一性约束调整
|
||||
|
||||
- `User.username`: 从全局唯一改为 `(tenantId, username)` 唯一
|
||||
- `User.email`: 从全局唯一改为 `(tenantId, email)` 唯一
|
||||
- `Role.name/code`: 从全局唯一改为 `(tenantId, name/code)` 唯一
|
||||
- `Permission.code`: 从全局唯一改为 `(tenantId, code)` 唯一
|
||||
- 其他类似字段也做了相应调整
|
||||
|
||||
## 租户识别机制
|
||||
|
||||
系统支持多种方式识别租户:
|
||||
|
||||
1. **请求头方式**(推荐)
|
||||
- `X-Tenant-Code`: 租户编码
|
||||
- `X-Tenant-Id`: 租户ID
|
||||
|
||||
2. **子域名方式**
|
||||
- 从 `Host` 请求头提取子域名
|
||||
- 匹配租户的 `code` 或 `domain` 字段
|
||||
|
||||
3. **JWT Token方式**
|
||||
- Token中包含 `tenantId` 字段
|
||||
- 登录时自动关联租户
|
||||
|
||||
4. **登录参数方式**
|
||||
- 登录接口支持 `tenantCode` 参数
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
首先需要生成并执行数据库迁移:
|
||||
|
||||
```bash
|
||||
# 生成迁移文件
|
||||
npm run prisma:migrate:dev -- --name add_tenant_support
|
||||
|
||||
# 执行迁移
|
||||
npm run prisma:migrate
|
||||
```
|
||||
|
||||
### 2. 初始化超级租户
|
||||
|
||||
运行初始化脚本创建超级租户:
|
||||
|
||||
```bash
|
||||
npm run init:super-tenant
|
||||
```
|
||||
|
||||
这将创建:
|
||||
- 超级租户(code: `super`)
|
||||
- 超级管理员用户(username: `admin`, password: `admin123`)
|
||||
- 超级管理员角色
|
||||
- 基础权限
|
||||
|
||||
### 3. 创建普通租户
|
||||
|
||||
使用超级租户的管理员账号登录后,通过租户管理接口创建新租户:
|
||||
|
||||
```bash
|
||||
POST /api/tenants
|
||||
Headers:
|
||||
Authorization: Bearer <token>
|
||||
X-Tenant-Code: super
|
||||
Body:
|
||||
{
|
||||
"name": "租户A",
|
||||
"code": "tenant-a",
|
||||
"domain": "tenant-a.example.com",
|
||||
"description": "租户A的描述",
|
||||
"menuIds": [1, 2, 3] // 分配的菜单ID列表
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 为租户分配菜单
|
||||
|
||||
超级租户可以为租户分配菜单:
|
||||
|
||||
```bash
|
||||
PATCH /api/tenants/:id
|
||||
Headers:
|
||||
Authorization: Bearer <token>
|
||||
X-Tenant-Code: super
|
||||
Body:
|
||||
{
|
||||
"menuIds": [1, 2, 3, 4, 5]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 租户用户登录
|
||||
|
||||
租户用户登录时需要指定租户:
|
||||
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Body:
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "password123",
|
||||
"tenantCode": "tenant-a" // 可选,也可以从请求头获取
|
||||
}
|
||||
```
|
||||
|
||||
或者在请求头中指定:
|
||||
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Headers:
|
||||
X-Tenant-Code: tenant-a
|
||||
Body:
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 访问租户数据
|
||||
|
||||
所有API请求都会自动根据租户ID过滤数据:
|
||||
|
||||
```bash
|
||||
GET /api/users
|
||||
Headers:
|
||||
Authorization: Bearer <token>
|
||||
X-Tenant-Code: tenant-a
|
||||
```
|
||||
|
||||
返回的数据只会包含该租户的用户。
|
||||
|
||||
## API接口
|
||||
|
||||
### 租户管理接口
|
||||
|
||||
- `POST /api/tenants` - 创建租户(需要 `tenant:create` 权限)
|
||||
- `GET /api/tenants` - 获取租户列表(需要 `tenant:read` 权限)
|
||||
- `GET /api/tenants/:id` - 获取租户详情(需要 `tenant:read` 权限)
|
||||
- `PATCH /api/tenants/:id` - 更新租户(需要 `tenant:update` 权限)
|
||||
- `DELETE /api/tenants/:id` - 删除租户(需要 `tenant:delete` 权限)
|
||||
- `GET /api/tenants/:id/menus` - 获取租户的菜单树(需要 `tenant:read` 权限)
|
||||
|
||||
### 其他接口
|
||||
|
||||
所有其他接口(用户、角色、权限等)都支持租户隔离,会自动根据请求中的租户信息过滤数据。
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 1. 请求拦截器
|
||||
|
||||
在前端请求拦截器中添加租户信息:
|
||||
|
||||
```typescript
|
||||
// utils/request.ts
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getToken();
|
||||
const tenantCode = getTenantCode(); // 从localStorage或store获取
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (tenantCode && config.headers) {
|
||||
config.headers['X-Tenant-Code'] = tenantCode;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 登录时保存租户信息
|
||||
|
||||
```typescript
|
||||
// 登录成功后
|
||||
localStorage.setItem('tenantCode', response.data.user.tenantCode);
|
||||
localStorage.setItem('tenantId', response.data.user.tenantId);
|
||||
```
|
||||
|
||||
### 3. 租户切换
|
||||
|
||||
如果需要支持租户切换,可以在前端实现租户选择器,切换时更新localStorage中的租户信息并重新加载数据。
|
||||
|
||||
## 权限控制
|
||||
|
||||
### 超级租户权限
|
||||
|
||||
超级租户的用户拥有所有权限,包括:
|
||||
- 创建、查看、更新、删除租户
|
||||
- 为租户分配菜单
|
||||
- 管理所有租户的数据(如果需要在超级租户中查看所有租户数据)
|
||||
|
||||
### 普通租户权限
|
||||
|
||||
普通租户的用户只能:
|
||||
- 管理自己租户内的数据
|
||||
- 查看分配给租户的菜单
|
||||
- 无法访问其他租户的数据
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据隔离**: 所有查询都会自动添加租户过滤条件,确保数据隔离
|
||||
2. **唯一性**: 用户名、邮箱等在租户内唯一,不同租户可以有相同的用户名
|
||||
3. **菜单管理**: 菜单是全局的(由超级租户管理),但通过 `TenantMenu` 表分配给各个租户
|
||||
4. **超级租户**: 超级租户不能被删除,且拥有所有权限
|
||||
5. **迁移数据**: 如果现有系统已有数据,需要编写迁移脚本将现有数据关联到超级租户
|
||||
|
||||
## 迁移现有数据
|
||||
|
||||
如果系统已有数据,需要将现有数据迁移到超级租户:
|
||||
|
||||
```sql
|
||||
-- 假设超级租户ID为1
|
||||
UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||||
UPDATE roles SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||||
UPDATE permissions SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||||
-- 其他表类似
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
1. **租户识别失败**: 检查请求头是否正确设置,或检查JWT token中是否包含tenantId
|
||||
2. **数据查询为空**: 确认租户ID正确,且数据确实属于该租户
|
||||
3. **权限不足**: 确认用户角色有相应权限,且角色属于正确的租户
|
||||
|
||||
## 扩展功能
|
||||
|
||||
未来可以考虑的扩展:
|
||||
1. 租户级别的配置(每个租户可以有自己的系统配置)
|
||||
2. 租户级别的主题和品牌定制
|
||||
3. 租户级别的功能开关
|
||||
4. 租户使用统计和监控
|
||||
5. 租户数据导出和备份
|
||||
|
||||
226
backend/docs/TENANT_LOGIN_GUIDE.md
Normal file
226
backend/docs/TENANT_LOGIN_GUIDE.md
Normal file
@ -0,0 +1,226 @@
|
||||
# 租户登录使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
系统已完整支持多租户登录功能,每个租户可以独立访问系统,数据完全隔离。
|
||||
|
||||
## 租户识别方式
|
||||
|
||||
系统支持以下方式识别租户:
|
||||
|
||||
### 1. URL参数方式(推荐)
|
||||
|
||||
在登录页面URL中添加 `tenant` 参数:
|
||||
|
||||
```
|
||||
http://your-domain.com/login?tenant=tenant-a
|
||||
```
|
||||
|
||||
登录页面会自动识别租户编码,并在登录时自动发送。
|
||||
|
||||
### 2. 登录表单输入
|
||||
|
||||
如果URL中没有租户参数,登录页面会显示租户编码输入框,用户可以手动输入。
|
||||
|
||||
### 3. 请求头方式
|
||||
|
||||
前端会自动将租户信息添加到所有API请求的请求头中:
|
||||
- `X-Tenant-Code`: 租户编码
|
||||
- `X-Tenant-Id`: 租户ID
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 方式一:通过URL参数访问(推荐)
|
||||
|
||||
1. **访问租户登录页面**
|
||||
```
|
||||
http://your-domain.com/login?tenant=tenant-a
|
||||
```
|
||||
|
||||
2. **输入用户名和密码**
|
||||
- 用户名:租户内的用户名
|
||||
- 密码:用户密码
|
||||
- 租户编码:已自动填充(从URL参数)
|
||||
|
||||
3. **登录成功**
|
||||
- 系统自动保存租户信息到 localStorage
|
||||
- 后续所有API请求都会自动携带租户信息
|
||||
- 用户只能看到和操作自己租户的数据
|
||||
|
||||
### 方式二:手动输入租户编码
|
||||
|
||||
1. **访问登录页面**
|
||||
```
|
||||
http://your-domain.com/login
|
||||
```
|
||||
|
||||
2. **输入租户信息**
|
||||
- 租户编码:输入租户编码(如:`tenant-a`)
|
||||
- 用户名:租户内的用户名
|
||||
- 密码:用户密码
|
||||
|
||||
3. **登录成功**
|
||||
- 系统保存租户信息
|
||||
- 后续请求自动携带租户信息
|
||||
|
||||
## 后端API使用
|
||||
|
||||
### 登录接口
|
||||
|
||||
**请求:**
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "password123",
|
||||
"tenantCode": "tenant-a" // 可选,也可以从请求头获取
|
||||
}
|
||||
```
|
||||
|
||||
**或者通过请求头:**
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
X-Tenant-Code: tenant-a
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "user1",
|
||||
"nickname": "用户1",
|
||||
"email": "user1@example.com",
|
||||
"tenantId": 2,
|
||||
"tenantCode": "tenant-a",
|
||||
"roles": ["admin"],
|
||||
"permissions": ["user:read", "user:create", ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 其他API请求
|
||||
|
||||
登录后,所有API请求都会自动携带租户信息(通过JWT Token或请求头),后端会自动过滤数据:
|
||||
|
||||
```bash
|
||||
GET /api/users
|
||||
Authorization: Bearer <token>
|
||||
X-Tenant-Code: tenant-a # 自动添加
|
||||
```
|
||||
|
||||
返回的数据只会包含该租户的用户。
|
||||
|
||||
## 前端实现细节
|
||||
|
||||
### 1. 登录页面自动识别租户
|
||||
|
||||
登录页面 (`Login.vue`) 会:
|
||||
- 从URL参数 `?tenant=xxx` 获取租户编码
|
||||
- 如果URL中没有,从 localStorage 读取之前保存的租户编码
|
||||
- 如果都没有,显示租户输入框
|
||||
|
||||
### 2. 请求拦截器自动添加租户信息
|
||||
|
||||
所有API请求都会自动添加租户信息到请求头:
|
||||
|
||||
```typescript
|
||||
// utils/request.ts
|
||||
service.interceptors.request.use((config) => {
|
||||
const tenantCode = getTenantCode();
|
||||
const tenantId = getTenantId();
|
||||
|
||||
if (tenantCode) {
|
||||
config.headers['X-Tenant-Code'] = tenantCode;
|
||||
}
|
||||
if (tenantId) {
|
||||
config.headers['X-Tenant-Id'] = tenantId;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 登录后保存租户信息
|
||||
|
||||
登录成功后,系统会自动保存:
|
||||
- Token
|
||||
- 租户编码 (tenantCode)
|
||||
- 租户ID (tenantId)
|
||||
|
||||
这些信息保存在 localStorage 中,页面刷新后仍然有效。
|
||||
|
||||
## 示例场景
|
||||
|
||||
### 场景1:租户A的用户登录
|
||||
|
||||
1. 访问:`http://your-domain.com/login?tenant=tenant-a`
|
||||
2. 输入用户名和密码
|
||||
3. 登录后只能看到租户A的数据
|
||||
|
||||
### 场景2:租户B的用户登录
|
||||
|
||||
1. 访问:`http://your-domain.com/login?tenant=tenant-b`
|
||||
2. 输入用户名和密码
|
||||
3. 登录后只能看到租户B的数据
|
||||
4. 租户A的数据完全不可见
|
||||
|
||||
### 场景3:超级租户管理员登录
|
||||
|
||||
1. 访问:`http://your-domain.com/login?tenant=super`
|
||||
2. 使用超级管理员账号登录
|
||||
3. 可以管理所有租户
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **租户编码必须唯一**:每个租户都有唯一的编码(code)
|
||||
2. **用户属于特定租户**:用户只能登录到自己所属的租户
|
||||
3. **数据完全隔离**:不同租户的数据完全隔离,无法互相访问
|
||||
4. **租户信息持久化**:登录后租户信息保存在 localStorage,刷新页面不会丢失
|
||||
5. **切换租户**:如果需要切换租户,需要先登出,然后使用新的租户编码登录
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:登录时提示"无法确定租户信息"
|
||||
|
||||
**原因**:没有提供租户编码或租户ID
|
||||
|
||||
**解决**:
|
||||
- 在URL中添加 `?tenant=xxx` 参数
|
||||
- 或者在登录表单中输入租户编码
|
||||
- 或者通过请求头 `X-Tenant-Code` 提供
|
||||
|
||||
### 问题2:登录时提示"用户不属于该租户"
|
||||
|
||||
**原因**:用户不属于指定的租户
|
||||
|
||||
**解决**:
|
||||
- 确认租户编码是否正确
|
||||
- 确认用户是否属于该租户
|
||||
- 联系管理员检查用户和租户的关联关系
|
||||
|
||||
### 问题3:登录后看不到数据
|
||||
|
||||
**原因**:可能是租户信息没有正确传递
|
||||
|
||||
**解决**:
|
||||
- 检查浏览器控制台的网络请求,确认请求头中是否包含 `X-Tenant-Code`
|
||||
- 检查 localStorage 中是否保存了租户信息
|
||||
- 确认后端是否正确识别了租户
|
||||
|
||||
## 开发建议
|
||||
|
||||
1. **使用URL参数方式**:这是最用户友好的方式,用户只需要记住租户的访问链接
|
||||
2. **提供租户选择器**:如果系统需要支持租户切换,可以在前端添加租户选择器
|
||||
3. **错误提示优化**:当租户信息缺失时,提供清晰的错误提示
|
||||
4. **租户信息显示**:在用户界面显示当前租户信息,让用户知道自己在哪个租户下操作
|
||||
|
||||
9
backend/nest-cli.json
Normal file
9
backend/nest-cli.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
||||
98
backend/package.json
Normal file
98
backend/package.json
Normal file
@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "competition-management-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "比赛管理系统后端",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch",
|
||||
"start:prod": "NODE_ENV=production node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "NODE_ENV=test jest",
|
||||
"test:watch": "NODE_ENV=test jest --watch",
|
||||
"test:cov": "NODE_ENV=test jest --coverage",
|
||||
"test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:generate:dev": "dotenv -e .env.development -- prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:migrate:dev": "dotenv -e .env.development -- prisma migrate dev --create-only --name add_tenant_support",
|
||||
"prisma:migrate:deploy": "NODE_ENV=production prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:studio:dev": "NODE_ENV=development prisma studio",
|
||||
"prisma:studio:prod": "NODE_ENV=production prisma studio",
|
||||
"init:admin": "ts-node scripts/init-admin.ts",
|
||||
"init:admin:permissions": "ts-node scripts/init-admin-permissions.ts",
|
||||
"init:menus": "ts-node scripts/init-menus.ts",
|
||||
"init:super-tenant": "ts-node scripts/init-super-tenant.ts",
|
||||
"init:tenant-admin": "ts-node scripts/init-tenant-admin.ts",
|
||||
"init:tenant-admin:permissions": "ts-node scripts/init-tenant-admin.ts --permissions-only",
|
||||
"update:password": "ts-node scripts/update-password.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.3",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.3",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.3",
|
||||
"@prisma/client": "^5.9.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.9.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
269
backend/prisma/schema.prisma
Normal file
269
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,269 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// 租户表
|
||||
model Tenant {
|
||||
id Int @id @default(autoincrement())
|
||||
name String /// 租户名称
|
||||
code String @unique /// 租户编码(唯一,用于访问链接)
|
||||
domain String? @unique /// 租户域名(可选,用于子域名访问)
|
||||
description String? /// 租户描述
|
||||
isSuper Int @default(0) @map("is_super") /// 是否为超级租户:0-否,1-是
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? /// 创建人ID(超级租户的用户ID)
|
||||
modifier Int? /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
users User[]
|
||||
roles Role[]
|
||||
menus TenantMenu[]
|
||||
permissions Permission[]
|
||||
dicts Dict[]
|
||||
configs Config[]
|
||||
creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
/// 用户表
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
username String /// 用户名(在租户内唯一)
|
||||
password String /// 密码(加密存储)
|
||||
nickname String /// 昵称
|
||||
email String? /// 邮箱(在租户内唯一,可选)
|
||||
avatar String? /// 头像URL
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? @map("creator") /// 创建人ID
|
||||
modifier Int? @map("modifier") /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
roles UserRole[]
|
||||
logs Log[]
|
||||
createdBy User? @relation("UserCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifiedBy User? @relation("UserModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
createdUsers User[] @relation("UserCreator")
|
||||
modifiedUsers User[] @relation("UserModifier")
|
||||
createdRoles Role[] @relation("RoleCreator")
|
||||
modifiedRoles Role[] @relation("RoleModifier")
|
||||
createdPermissions Permission[] @relation("PermissionCreator")
|
||||
modifiedPermissions Permission[] @relation("PermissionModifier")
|
||||
createdMenus Menu[] @relation("MenuCreator")
|
||||
modifiedMenus Menu[] @relation("MenuModifier")
|
||||
createdDicts Dict[] @relation("DictCreator")
|
||||
modifiedDicts Dict[] @relation("DictModifier")
|
||||
createdDictItems DictItem[] @relation("DictItemCreator")
|
||||
modifiedDictItems DictItem[] @relation("DictItemModifier")
|
||||
createdConfigs Config[] @relation("ConfigCreator")
|
||||
modifiedConfigs Config[] @relation("ConfigModifier")
|
||||
createdTenants Tenant[] @relation("TenantCreator")
|
||||
modifiedTenants Tenant[] @relation("TenantModifier")
|
||||
|
||||
@@unique([tenantId, username])
|
||||
@@unique([tenantId, email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// 角色表
|
||||
model Role {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
name String /// 角色名称(在租户内唯一)
|
||||
code String /// 角色编码(在租户内唯一)
|
||||
description String? /// 角色描述
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? /// 创建人ID
|
||||
modifier Int? /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
creatorUser User? @relation("RoleCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("RoleModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@unique([tenantId, code])
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
/// 用户角色关联表
|
||||
model UserRole {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id") /// 用户ID
|
||||
roleId Int @map("role_id") /// 角色ID
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
/// 权限表
|
||||
model Permission {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
name String /// 权限名称
|
||||
code String /// 权限编码(在租户内唯一)
|
||||
resource String /// 资源名称,如 user, role, menu
|
||||
action String /// 操作类型,如 create, read, update, delete
|
||||
description String? /// 权限描述
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? /// 创建人ID
|
||||
modifier Int? /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
roles RolePermission[]
|
||||
creatorUser User? @relation("PermissionCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("PermissionModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([tenantId, resource, action])
|
||||
@@unique([tenantId, code])
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
/// 角色权限关联表
|
||||
model RolePermission {
|
||||
id Int @id @default(autoincrement())
|
||||
roleId Int @map("role_id") /// 角色ID
|
||||
permissionId Int @map("permission_id") /// 权限ID
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
/// 菜单表(全局菜单模板,超级租户管理)
|
||||
model Menu {
|
||||
id Int @id @default(autoincrement())
|
||||
name String /// 菜单名称
|
||||
path String? /// 路由路径
|
||||
icon String? /// 图标
|
||||
component String? /// 组件路径
|
||||
parentId Int? @map("parent_id") /// 父菜单ID
|
||||
permission String? /// 权限编码(用于控制菜单显示,如:menu:read)
|
||||
sort Int @default(0) /// 排序
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? @map("creator") /// 创建人ID
|
||||
modifier Int? @map("modifier") /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
parent Menu? @relation("MenuTree", fields: [parentId], references: [id])
|
||||
children Menu[] @relation("MenuTree")
|
||||
tenantMenus TenantMenu[] /// 租户菜单关联
|
||||
creatorUser User? @relation("MenuCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("MenuModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("menus")
|
||||
}
|
||||
|
||||
/// 租户菜单关联表(租户分配的菜单)
|
||||
model TenantMenu {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
menuId Int @map("menu_id") /// 菜单ID
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, menuId])
|
||||
@@map("tenant_menus")
|
||||
}
|
||||
|
||||
/// 数据字典表
|
||||
model Dict {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
name String /// 字典名称
|
||||
code String /// 字典编码(在租户内唯一)
|
||||
description String? /// 字典描述
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? /// 创建人ID
|
||||
modifier Int? /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
items DictItem[]
|
||||
creatorUser User? @relation("DictCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("DictModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([tenantId, code])
|
||||
@@map("dicts")
|
||||
}
|
||||
|
||||
/// 字典项表
|
||||
model DictItem {
|
||||
id Int @id @default(autoincrement())
|
||||
dictId Int @map("dict_id") /// 字典ID
|
||||
label String /// 标签
|
||||
value String /// 值
|
||||
sort Int @default(0) /// 排序
|
||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||
creator Int? @map("creator") /// 创建人ID
|
||||
modifier Int? @map("modifier") /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
dict Dict @relation(fields: [dictId], references: [id], onDelete: Cascade)
|
||||
creatorUser User? @relation("DictItemCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("DictItemModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("dict_items")
|
||||
}
|
||||
|
||||
/// 系统配置表
|
||||
model Config {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id") /// 租户ID
|
||||
key String /// 配置键(在租户内唯一)
|
||||
value String /// 配置值
|
||||
description String? /// 配置描述
|
||||
creator Int? /// 创建人ID
|
||||
modifier Int? /// 修改人ID
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
creatorUser User? @relation("ConfigCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("ConfigModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([tenantId, key])
|
||||
@@map("configs")
|
||||
}
|
||||
|
||||
/// 日志记录表
|
||||
model Log {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int? @map("user_id") /// 用户ID
|
||||
action String /// 操作类型
|
||||
content String? @db.Text /// 操作内容(使用 TEXT 类型支持长文本)
|
||||
ip String? /// IP地址
|
||||
userAgent String? @map("user_agent") /// 用户代理
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("logs")
|
||||
}
|
||||
421
backend/scripts/init-admin-permissions.ts
Normal file
421
backend/scripts/init-admin-permissions.ts
Normal file
@ -0,0 +1,421 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 定义所有基础权限
|
||||
const permissions = [
|
||||
// 用户管理权限
|
||||
{
|
||||
code: 'user:create',
|
||||
resource: 'user',
|
||||
action: 'create',
|
||||
name: '创建用户',
|
||||
description: '允许创建新用户',
|
||||
},
|
||||
{
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
name: '查看用户',
|
||||
description: '允许查看用户列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'user:update',
|
||||
resource: 'user',
|
||||
action: 'update',
|
||||
name: '更新用户',
|
||||
description: '允许更新用户信息',
|
||||
},
|
||||
{
|
||||
code: 'user:delete',
|
||||
resource: 'user',
|
||||
action: 'delete',
|
||||
name: '删除用户',
|
||||
description: '允许删除用户',
|
||||
},
|
||||
|
||||
// 角色管理权限
|
||||
{
|
||||
code: 'role:create',
|
||||
resource: 'role',
|
||||
action: 'create',
|
||||
name: '创建角色',
|
||||
description: '允许创建新角色',
|
||||
},
|
||||
{
|
||||
code: 'role:read',
|
||||
resource: 'role',
|
||||
action: 'read',
|
||||
name: '查看角色',
|
||||
description: '允许查看角色列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'role:update',
|
||||
resource: 'role',
|
||||
action: 'update',
|
||||
name: '更新角色',
|
||||
description: '允许更新角色信息',
|
||||
},
|
||||
{
|
||||
code: 'role:delete',
|
||||
resource: 'role',
|
||||
action: 'delete',
|
||||
name: '删除角色',
|
||||
description: '允许删除角色',
|
||||
},
|
||||
{
|
||||
code: 'role:assign',
|
||||
resource: 'role',
|
||||
action: 'assign',
|
||||
name: '分配角色',
|
||||
description: '允许给用户分配角色',
|
||||
},
|
||||
|
||||
// 权限管理权限
|
||||
{
|
||||
code: 'permission:create',
|
||||
resource: 'permission',
|
||||
action: 'create',
|
||||
name: '创建权限',
|
||||
description: '允许创建新权限',
|
||||
},
|
||||
{
|
||||
code: 'permission:read',
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
name: '查看权限',
|
||||
description: '允许查看权限列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'permission:update',
|
||||
resource: 'permission',
|
||||
action: 'update',
|
||||
name: '更新权限',
|
||||
description: '允许更新权限信息',
|
||||
},
|
||||
{
|
||||
code: 'permission:delete',
|
||||
resource: 'permission',
|
||||
action: 'delete',
|
||||
name: '删除权限',
|
||||
description: '允许删除权限',
|
||||
},
|
||||
|
||||
// 菜单管理权限
|
||||
{
|
||||
code: 'menu:create',
|
||||
resource: 'menu',
|
||||
action: 'create',
|
||||
name: '创建菜单',
|
||||
description: '允许创建新菜单',
|
||||
},
|
||||
{
|
||||
code: 'menu:read',
|
||||
resource: 'menu',
|
||||
action: 'read',
|
||||
name: '查看菜单',
|
||||
description: '允许查看菜单列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'menu:update',
|
||||
resource: 'menu',
|
||||
action: 'update',
|
||||
name: '更新菜单',
|
||||
description: '允许更新菜单信息',
|
||||
},
|
||||
{
|
||||
code: 'menu:delete',
|
||||
resource: 'menu',
|
||||
action: 'delete',
|
||||
name: '删除菜单',
|
||||
description: '允许删除菜单',
|
||||
},
|
||||
|
||||
// 数据字典权限
|
||||
{
|
||||
code: 'dict:create',
|
||||
resource: 'dict',
|
||||
action: 'create',
|
||||
name: '创建字典',
|
||||
description: '允许创建新字典',
|
||||
},
|
||||
{
|
||||
code: 'dict:read',
|
||||
resource: 'dict',
|
||||
action: 'read',
|
||||
name: '查看字典',
|
||||
description: '允许查看字典列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'dict:update',
|
||||
resource: 'dict',
|
||||
action: 'update',
|
||||
name: '更新字典',
|
||||
description: '允许更新字典信息',
|
||||
},
|
||||
{
|
||||
code: 'dict:delete',
|
||||
resource: 'dict',
|
||||
action: 'delete',
|
||||
name: '删除字典',
|
||||
description: '允许删除字典',
|
||||
},
|
||||
|
||||
// 系统配置权限
|
||||
{
|
||||
code: 'config:create',
|
||||
resource: 'config',
|
||||
action: 'create',
|
||||
name: '创建配置',
|
||||
description: '允许创建新配置',
|
||||
},
|
||||
{
|
||||
code: 'config:read',
|
||||
resource: 'config',
|
||||
action: 'read',
|
||||
name: '查看配置',
|
||||
description: '允许查看配置列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'config:update',
|
||||
resource: 'config',
|
||||
action: 'update',
|
||||
name: '更新配置',
|
||||
description: '允许更新配置信息',
|
||||
},
|
||||
{
|
||||
code: 'config:delete',
|
||||
resource: 'config',
|
||||
action: 'delete',
|
||||
name: '删除配置',
|
||||
description: '允许删除配置',
|
||||
},
|
||||
|
||||
// 日志管理权限
|
||||
{
|
||||
code: 'log:read',
|
||||
resource: 'log',
|
||||
action: 'read',
|
||||
name: '查看日志',
|
||||
description: '允许查看系统日志',
|
||||
},
|
||||
{
|
||||
code: 'log:delete',
|
||||
resource: 'log',
|
||||
action: 'delete',
|
||||
name: '删除日志',
|
||||
description: '允许删除系统日志',
|
||||
},
|
||||
|
||||
// 用户密码管理权限
|
||||
{
|
||||
code: 'user:password:update',
|
||||
resource: 'user',
|
||||
action: 'password:update',
|
||||
name: '修改用户密码',
|
||||
description: '允许修改用户密码',
|
||||
},
|
||||
];
|
||||
|
||||
async function initAdminPermissions() {
|
||||
try {
|
||||
console.log('🚀 开始为超级管理员(admin)用户初始化权限...\n');
|
||||
|
||||
// 1. 检查 admin 用户是否存在
|
||||
console.log('👤 步骤 1: 检查 admin 用户...');
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
console.error('❌ 错误: admin 用户不存在!');
|
||||
console.error(' 请先运行 pnpm init:admin 创建 admin 用户');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✅ admin 用户存在: ${adminUser.username} (${adminUser.nickname})\n`,
|
||||
);
|
||||
|
||||
// 2. 创建或更新所有权限
|
||||
console.log('📝 步骤 2: 确保所有权限存在...');
|
||||
const createdPermissions = [];
|
||||
for (const perm of permissions) {
|
||||
const permission = await prisma.permission.upsert({
|
||||
where: { code: perm.code },
|
||||
update: {
|
||||
name: perm.name,
|
||||
resource: perm.resource,
|
||||
action: perm.action,
|
||||
description: perm.description,
|
||||
},
|
||||
create: perm,
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
}
|
||||
console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`);
|
||||
|
||||
// 3. 创建或获取超级管理员角色
|
||||
console.log('👤 步骤 3: 确保超级管理员角色存在...');
|
||||
const adminRole = await prisma.role.upsert({
|
||||
where: { code: 'super_admin' },
|
||||
update: {
|
||||
name: '超级管理员',
|
||||
description: '拥有系统所有权限的超级管理员角色',
|
||||
validState: 1,
|
||||
},
|
||||
create: {
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '拥有系统所有权限的超级管理员角色',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ 超级管理员角色已确保存在: ${adminRole.name} (${adminRole.code})\n`,
|
||||
);
|
||||
|
||||
// 4. 确保超级管理员角色拥有所有权限
|
||||
console.log('🔗 步骤 4: 为超级管理员角色分配所有权限...');
|
||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
||||
where: { roleId: adminRole.id },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingPermissionIds = new Set(
|
||||
existingRolePermissions.map((rp) => rp.permissionId),
|
||||
);
|
||||
|
||||
let addedCount = 0;
|
||||
for (const permission of createdPermissions) {
|
||||
if (!existingPermissionIds.has(permission.id)) {
|
||||
await prisma.rolePermission.create({
|
||||
data: {
|
||||
roleId: adminRole.id,
|
||||
permissionId: permission.id,
|
||||
},
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
console.log(`✅ 为超级管理员角色添加了 ${addedCount} 个权限\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ 超级管理员角色已拥有所有权限(${createdPermissions.length} 个)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 确保 admin 用户拥有超级管理员角色
|
||||
console.log('🔗 步骤 5: 确保 admin 用户拥有超级管理员角色...');
|
||||
const existingUserRole = await prisma.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: adminUser.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUserRole) {
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
userId: adminUser.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
});
|
||||
console.log(`✅ 已为 admin 用户分配超级管理员角色\n`);
|
||||
} else {
|
||||
console.log(`✅ admin 用户已拥有超级管理员角色\n`);
|
||||
}
|
||||
|
||||
// 6. 验证结果
|
||||
console.log('🔍 步骤 6: 验证结果...');
|
||||
const userWithRoles = await prisma.user.findUnique({
|
||||
where: { id: adminUser.id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || [];
|
||||
const permissionCodes = new Set<string>();
|
||||
userWithRoles?.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
permissionCodes.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n📊 初始化结果:`);
|
||||
console.log(` 用户名: ${adminUser.username}`);
|
||||
console.log(` 昵称: ${adminUser.nickname}`);
|
||||
console.log(` 角色: ${roleCodes.join(', ')}`);
|
||||
console.log(` 权限数量: ${permissionCodes.size}`);
|
||||
console.log(` 权限列表:`);
|
||||
Array.from(permissionCodes)
|
||||
.sort()
|
||||
.forEach((code) => {
|
||||
console.log(` - ${code}`);
|
||||
});
|
||||
console.log(`\n✅ 超级管理员权限初始化完成!`);
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initAdminPermissions()
|
||||
.then(() => {
|
||||
console.log('\n🎉 权限初始化脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 权限初始化脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
526
backend/scripts/init-admin.ts
Normal file
526
backend/scripts/init-admin.ts
Normal file
@ -0,0 +1,526 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 定义所有基础权限
|
||||
const permissions = [
|
||||
// 用户管理权限
|
||||
{
|
||||
code: 'user:create',
|
||||
resource: 'user',
|
||||
action: 'create',
|
||||
name: '创建用户',
|
||||
description: '允许创建新用户',
|
||||
},
|
||||
{
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
name: '查看用户',
|
||||
description: '允许查看用户列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'user:update',
|
||||
resource: 'user',
|
||||
action: 'update',
|
||||
name: '更新用户',
|
||||
description: '允许更新用户信息',
|
||||
},
|
||||
{
|
||||
code: 'user:delete',
|
||||
resource: 'user',
|
||||
action: 'delete',
|
||||
name: '删除用户',
|
||||
description: '允许删除用户',
|
||||
},
|
||||
|
||||
// 角色管理权限
|
||||
{
|
||||
code: 'role:create',
|
||||
resource: 'role',
|
||||
action: 'create',
|
||||
name: '创建角色',
|
||||
description: '允许创建新角色',
|
||||
},
|
||||
{
|
||||
code: 'role:read',
|
||||
resource: 'role',
|
||||
action: 'read',
|
||||
name: '查看角色',
|
||||
description: '允许查看角色列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'role:update',
|
||||
resource: 'role',
|
||||
action: 'update',
|
||||
name: '更新角色',
|
||||
description: '允许更新角色信息',
|
||||
},
|
||||
{
|
||||
code: 'role:delete',
|
||||
resource: 'role',
|
||||
action: 'delete',
|
||||
name: '删除角色',
|
||||
description: '允许删除角色',
|
||||
},
|
||||
{
|
||||
code: 'role:assign',
|
||||
resource: 'role',
|
||||
action: 'assign',
|
||||
name: '分配角色',
|
||||
description: '允许给用户分配角色',
|
||||
},
|
||||
|
||||
// 权限管理权限
|
||||
{
|
||||
code: 'permission:create',
|
||||
resource: 'permission',
|
||||
action: 'create',
|
||||
name: '创建权限',
|
||||
description: '允许创建新权限',
|
||||
},
|
||||
{
|
||||
code: 'permission:read',
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
name: '查看权限',
|
||||
description: '允许查看权限列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'permission:update',
|
||||
resource: 'permission',
|
||||
action: 'update',
|
||||
name: '更新权限',
|
||||
description: '允许更新权限信息',
|
||||
},
|
||||
{
|
||||
code: 'permission:delete',
|
||||
resource: 'permission',
|
||||
action: 'delete',
|
||||
name: '删除权限',
|
||||
description: '允许删除权限',
|
||||
},
|
||||
|
||||
// 菜单管理权限
|
||||
{
|
||||
code: 'menu:create',
|
||||
resource: 'menu',
|
||||
action: 'create',
|
||||
name: '创建菜单',
|
||||
description: '允许创建新菜单',
|
||||
},
|
||||
{
|
||||
code: 'menu:read',
|
||||
resource: 'menu',
|
||||
action: 'read',
|
||||
name: '查看菜单',
|
||||
description: '允许查看菜单列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'menu:update',
|
||||
resource: 'menu',
|
||||
action: 'update',
|
||||
name: '更新菜单',
|
||||
description: '允许更新菜单信息',
|
||||
},
|
||||
{
|
||||
code: 'menu:delete',
|
||||
resource: 'menu',
|
||||
action: 'delete',
|
||||
name: '删除菜单',
|
||||
description: '允许删除菜单',
|
||||
},
|
||||
|
||||
// 数据字典权限
|
||||
{
|
||||
code: 'dict:create',
|
||||
resource: 'dict',
|
||||
action: 'create',
|
||||
name: '创建字典',
|
||||
description: '允许创建新字典',
|
||||
},
|
||||
{
|
||||
code: 'dict:read',
|
||||
resource: 'dict',
|
||||
action: 'read',
|
||||
name: '查看字典',
|
||||
description: '允许查看字典列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'dict:update',
|
||||
resource: 'dict',
|
||||
action: 'update',
|
||||
name: '更新字典',
|
||||
description: '允许更新字典信息',
|
||||
},
|
||||
{
|
||||
code: 'dict:delete',
|
||||
resource: 'dict',
|
||||
action: 'delete',
|
||||
name: '删除字典',
|
||||
description: '允许删除字典',
|
||||
},
|
||||
|
||||
// 系统配置权限
|
||||
{
|
||||
code: 'config:create',
|
||||
resource: 'config',
|
||||
action: 'create',
|
||||
name: '创建配置',
|
||||
description: '允许创建新配置',
|
||||
},
|
||||
{
|
||||
code: 'config:read',
|
||||
resource: 'config',
|
||||
action: 'read',
|
||||
name: '查看配置',
|
||||
description: '允许查看配置列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'config:update',
|
||||
resource: 'config',
|
||||
action: 'update',
|
||||
name: '更新配置',
|
||||
description: '允许更新配置信息',
|
||||
},
|
||||
{
|
||||
code: 'config:delete',
|
||||
resource: 'config',
|
||||
action: 'delete',
|
||||
name: '删除配置',
|
||||
description: '允许删除配置',
|
||||
},
|
||||
|
||||
// 日志管理权限
|
||||
{
|
||||
code: 'log:read',
|
||||
resource: 'log',
|
||||
action: 'read',
|
||||
name: '查看日志',
|
||||
description: '允许查看系统日志',
|
||||
},
|
||||
{
|
||||
code: 'log:delete',
|
||||
resource: 'log',
|
||||
action: 'delete',
|
||||
name: '删除日志',
|
||||
description: '允许删除系统日志',
|
||||
},
|
||||
|
||||
// 用户密码管理权限
|
||||
{
|
||||
code: 'user:password:update',
|
||||
resource: 'user',
|
||||
action: 'password:update',
|
||||
name: '修改用户密码',
|
||||
description: '允许修改用户密码',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据路由配置定义的菜单数据
|
||||
const menus = [
|
||||
// 顶级菜单:仪表盘
|
||||
{
|
||||
name: '仪表盘',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
component: 'dashboard/Index',
|
||||
parentId: null,
|
||||
sort: 1,
|
||||
},
|
||||
// 父菜单:系统管理
|
||||
{
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
component: null, // 父菜单不需要组件
|
||||
parentId: null,
|
||||
sort: 10,
|
||||
children: [
|
||||
{
|
||||
name: '用户管理',
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
component: 'system/users/Index',
|
||||
sort: 1,
|
||||
},
|
||||
{
|
||||
name: '角色管理',
|
||||
path: '/system/roles',
|
||||
icon: 'TeamOutlined',
|
||||
component: 'system/roles/Index',
|
||||
sort: 2,
|
||||
},
|
||||
{
|
||||
name: '菜单管理',
|
||||
path: '/system/menus',
|
||||
icon: 'MenuOutlined',
|
||||
component: 'system/menus/Index',
|
||||
sort: 3,
|
||||
},
|
||||
{
|
||||
name: '数据字典',
|
||||
path: '/system/dict',
|
||||
icon: 'BookOutlined',
|
||||
component: 'system/dict/Index',
|
||||
sort: 4,
|
||||
},
|
||||
{
|
||||
name: '系统配置',
|
||||
path: '/system/config',
|
||||
icon: 'ToolOutlined',
|
||||
component: 'system/config/Index',
|
||||
sort: 5,
|
||||
},
|
||||
{
|
||||
name: '日志记录',
|
||||
path: '/system/logs',
|
||||
icon: 'FileTextOutlined',
|
||||
component: 'system/logs/Index',
|
||||
sort: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function initAdmin() {
|
||||
try {
|
||||
console.log('🚀 开始初始化超级管理员...\n');
|
||||
|
||||
// 1. 创建或获取所有权限
|
||||
console.log('📝 步骤 1: 创建基础权限...');
|
||||
const createdPermissions = [];
|
||||
for (const perm of permissions) {
|
||||
const permission = await prisma.permission.upsert({
|
||||
where: { code: perm.code },
|
||||
update: perm,
|
||||
create: perm,
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
console.log(` ✓ ${perm.code} - ${perm.name}`);
|
||||
}
|
||||
console.log(`✅ 共创建/更新 ${createdPermissions.length} 个权限\n`);
|
||||
|
||||
// 2. 创建或获取超级管理员角色
|
||||
console.log('👤 步骤 2: 创建超级管理员角色...');
|
||||
const adminRole = await prisma.role.upsert({
|
||||
where: { code: 'super_admin' },
|
||||
update: {
|
||||
name: '超级管理员',
|
||||
description: '拥有系统所有权限的超级管理员角色',
|
||||
},
|
||||
create: {
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '拥有系统所有权限的超级管理员角色',
|
||||
permissions: {
|
||||
create: createdPermissions.map((perm) => ({
|
||||
permission: { connect: { id: perm.id } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ 超级管理员角色已创建/更新: ${adminRole.name} (${adminRole.code})\n`,
|
||||
);
|
||||
|
||||
// 3. 创建或获取 admin 用户
|
||||
console.log('👤 步骤 3: 创建 admin 用户...');
|
||||
const hashedPassword = await bcrypt.hash('cms@admin', 10);
|
||||
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
update: {
|
||||
password: hashedPassword,
|
||||
nickname: '超级管理员',
|
||||
validState: 1,
|
||||
},
|
||||
create: {
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
nickname: '超级管理员',
|
||||
email: 'admin@example.com',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ 用户已创建/更新: ${adminUser.username} (${adminUser.nickname})\n`,
|
||||
);
|
||||
|
||||
// 4. 给 admin 用户分配超级管理员角色
|
||||
console.log('🔗 步骤 4: 分配角色...');
|
||||
await prisma.userRole.upsert({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: adminUser.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
user: { connect: { id: adminUser.id } },
|
||||
role: { connect: { id: adminRole.id } },
|
||||
},
|
||||
});
|
||||
console.log(`✅ 角色分配成功\n`);
|
||||
|
||||
// 5. 初始化菜单数据
|
||||
console.log('📋 步骤 5: 初始化菜单数据...');
|
||||
|
||||
// 递归创建菜单
|
||||
async function createMenu(menuData: any, parentId: number | null = null) {
|
||||
const { children, ...menuFields } = menuData;
|
||||
|
||||
// 查找是否已存在相同名称和父菜单的菜单
|
||||
const existingMenu = await prisma.menu.findFirst({
|
||||
where: {
|
||||
name: menuFields.name,
|
||||
parentId: parentId,
|
||||
},
|
||||
});
|
||||
|
||||
let menu;
|
||||
if (existingMenu) {
|
||||
// 更新现有菜单
|
||||
menu = await prisma.menu.update({
|
||||
where: { id: existingMenu.id },
|
||||
data: {
|
||||
name: menuFields.name,
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新菜单
|
||||
menu = await prisma.menu.create({
|
||||
data: {
|
||||
name: menuFields.name,
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有子菜单,递归创建
|
||||
if (children && children.length > 0) {
|
||||
for (const child of children) {
|
||||
await createMenu(child, menu.id);
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// 创建所有菜单
|
||||
for (const menu of menus) {
|
||||
await createMenu(menu);
|
||||
}
|
||||
|
||||
// 统计菜单数量
|
||||
const menuCount = await prisma.menu.count();
|
||||
const topLevelMenuCount = await prisma.menu.count({
|
||||
where: { parentId: null },
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ 菜单初始化完成: 共 ${menuCount} 个菜单(${topLevelMenuCount} 个顶级菜单)\n`,
|
||||
);
|
||||
|
||||
// 6. 验证结果
|
||||
console.log('🔍 步骤 6: 验证结果...');
|
||||
const userWithRoles = await prisma.user.findUnique({
|
||||
where: { id: adminUser.id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || [];
|
||||
const permissionCodes = new Set<string>();
|
||||
userWithRoles?.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
permissionCodes.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n📊 初始化结果:`);
|
||||
console.log(` 用户名: ${adminUser.username}`);
|
||||
console.log(` 昵称: ${adminUser.nickname}`);
|
||||
console.log(` 密码: cms@admin`);
|
||||
console.log(` 角色: ${roleCodes.join(', ')}`);
|
||||
console.log(` 权限数量: ${permissionCodes.size}`);
|
||||
console.log(` 菜单数量: ${menuCount} (${topLevelMenuCount} 个顶级菜单)`);
|
||||
console.log(`\n✅ 超级管理员和菜单数据初始化完成!`);
|
||||
console.log(`\n💡 现在可以使用以下凭据登录:`);
|
||||
console.log(` 用户名: admin`);
|
||||
console.log(` 密码: cms@admin`);
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initAdmin()
|
||||
.then(() => {
|
||||
console.log('\n🎉 初始化脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 初始化脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
237
backend/scripts/init-menus.ts
Normal file
237
backend/scripts/init-menus.ts
Normal file
@ -0,0 +1,237 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 根据路由配置定义的菜单数据
|
||||
const menus = [
|
||||
// 顶级菜单:仪表盘
|
||||
{
|
||||
name: '仪表盘',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
component: 'dashboard/Index',
|
||||
parentId: null,
|
||||
sort: 1,
|
||||
},
|
||||
// 父菜单:系统管理
|
||||
{
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
component: null, // 父菜单不需要组件
|
||||
parentId: null,
|
||||
sort: 10,
|
||||
children: [
|
||||
{
|
||||
name: '用户管理',
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
component: 'system/users/Index',
|
||||
sort: 1,
|
||||
},
|
||||
{
|
||||
name: '角色管理',
|
||||
path: '/system/roles',
|
||||
icon: 'TeamOutlined',
|
||||
component: 'system/roles/Index',
|
||||
sort: 2,
|
||||
},
|
||||
{
|
||||
name: '菜单管理',
|
||||
path: '/system/menus',
|
||||
icon: 'MenuOutlined',
|
||||
component: 'system/menus/Index',
|
||||
sort: 3,
|
||||
},
|
||||
{
|
||||
name: '数据字典',
|
||||
path: '/system/dict',
|
||||
icon: 'BookOutlined',
|
||||
component: 'system/dict/Index',
|
||||
sort: 4,
|
||||
},
|
||||
{
|
||||
name: '系统配置',
|
||||
path: '/system/config',
|
||||
icon: 'ToolOutlined',
|
||||
component: 'system/config/Index',
|
||||
sort: 5,
|
||||
},
|
||||
{
|
||||
name: '日志记录',
|
||||
path: '/system/logs',
|
||||
icon: 'FileTextOutlined',
|
||||
component: 'system/logs/Index',
|
||||
sort: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function initMenus() {
|
||||
try {
|
||||
console.log('🚀 开始初始化菜单数据...\n');
|
||||
|
||||
// 递归创建菜单
|
||||
async function createMenu(menuData: any, parentId: number | null = null) {
|
||||
const { children, ...menuFields } = menuData;
|
||||
|
||||
// 查找是否已存在相同名称和父菜单的菜单
|
||||
const existingMenu = await prisma.menu.findFirst({
|
||||
where: {
|
||||
name: menuFields.name,
|
||||
parentId: parentId,
|
||||
},
|
||||
});
|
||||
|
||||
let menu;
|
||||
if (existingMenu) {
|
||||
// 更新现有菜单
|
||||
menu = await prisma.menu.update({
|
||||
where: { id: existingMenu.id },
|
||||
data: {
|
||||
name: menuFields.name,
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新菜单
|
||||
menu = await prisma.menu.create({
|
||||
data: {
|
||||
name: menuFields.name,
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ ${menu.name} (${menu.path || '无路径'})`);
|
||||
|
||||
// 如果有子菜单,递归创建
|
||||
if (children && children.length > 0) {
|
||||
for (const child of children) {
|
||||
await createMenu(child, menu.id);
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// 清空现有菜单(重新初始化)
|
||||
console.log('🗑️ 清空现有菜单...');
|
||||
// 先删除所有子菜单,再删除父菜单(避免外键约束问题)
|
||||
await prisma.menu.deleteMany({
|
||||
where: {
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.menu.deleteMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
},
|
||||
});
|
||||
console.log('✅ 已清空现有菜单\n');
|
||||
|
||||
// 创建所有菜单
|
||||
console.log('📝 创建菜单...\n');
|
||||
for (const menu of menus) {
|
||||
await createMenu(menu);
|
||||
}
|
||||
|
||||
// 验证结果
|
||||
console.log('\n🔍 验证结果...');
|
||||
const allMenus = await prisma.menu.findMany({
|
||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||
include: {
|
||||
children: {
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const topLevelMenus = allMenus.filter((m) => !m.parentId);
|
||||
const totalMenus = allMenus.length;
|
||||
|
||||
console.log(`\n📊 初始化结果:`);
|
||||
console.log(` 顶级菜单数量: ${topLevelMenus.length}`);
|
||||
console.log(` 总菜单数量: ${totalMenus}`);
|
||||
console.log(`\n📋 菜单结构:`);
|
||||
|
||||
function printMenuTree(menu: any, indent: string = '') {
|
||||
console.log(`${indent}├─ ${menu.name} (${menu.path || '无路径'})`);
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children.forEach((child: any, index: number) => {
|
||||
const isLast = index === menu.children.length - 1;
|
||||
const childIndent = indent + (isLast ? ' ' : '│ ');
|
||||
printMenuTree(child, childIndent);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
topLevelMenus.forEach((menu) => {
|
||||
printMenuTree(menu);
|
||||
});
|
||||
|
||||
console.log(`\n✅ 菜单初始化完成!`);
|
||||
} catch (error) {
|
||||
console.error('\n💥 初始化菜单失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initMenus()
|
||||
.then(() => {
|
||||
console.log('\n🎉 菜单初始化脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 菜单初始化脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
360
backend/scripts/init-super-tenant.ts
Normal file
360
backend/scripts/init-super-tenant.ts
Normal file
@ -0,0 +1,360 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始初始化超级租户...');
|
||||
|
||||
// 检查是否已存在超级租户
|
||||
const existingSuperTenant = await prisma.tenant.findFirst({
|
||||
where: { isSuper: 1 },
|
||||
});
|
||||
|
||||
if (existingSuperTenant) {
|
||||
console.log('超级租户已存在,跳过创建');
|
||||
console.log(`租户编码: ${existingSuperTenant.code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建超级租户
|
||||
const superTenant = await prisma.tenant.create({
|
||||
data: {
|
||||
name: '超级租户',
|
||||
code: 'super',
|
||||
domain: 'super',
|
||||
description: '系统超级租户,拥有所有权限',
|
||||
isSuper: 1,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级租户创建成功!');
|
||||
console.log(`租户ID: ${superTenant.id}`);
|
||||
console.log(`租户编码: ${superTenant.code}`);
|
||||
console.log(`租户名称: ${superTenant.name}`);
|
||||
|
||||
// 创建超级管理员用户
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
|
||||
const superAdmin = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
nickname: '超级管理员',
|
||||
email: 'admin@super.com',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员用户创建成功!');
|
||||
console.log(`用户名: ${superAdmin.username}`);
|
||||
console.log(`密码: admin123`);
|
||||
console.log(`用户ID: ${superAdmin.id}`);
|
||||
|
||||
// 创建超级管理员角色
|
||||
const superAdminRole = await prisma.role.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '超级管理员角色,拥有所有权限',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员角色创建成功!');
|
||||
console.log(`角色编码: ${superAdminRole.code}`);
|
||||
|
||||
// 将超级管理员角色分配给用户
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
userId: superAdmin.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员角色已分配给用户');
|
||||
|
||||
// 创建基础权限
|
||||
const permissions = [
|
||||
{
|
||||
name: '租户管理-创建',
|
||||
code: 'tenant:create',
|
||||
resource: 'tenant',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '租户管理-查看',
|
||||
code: 'tenant:read',
|
||||
resource: 'tenant',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '租户管理-更新',
|
||||
code: 'tenant:update',
|
||||
resource: 'tenant',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '租户管理-删除',
|
||||
code: 'tenant:delete',
|
||||
resource: 'tenant',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '用户管理-创建',
|
||||
code: 'user:create',
|
||||
resource: 'user',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '用户管理-查看',
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '用户管理-更新',
|
||||
code: 'user:update',
|
||||
resource: 'user',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '用户管理-删除',
|
||||
code: 'user:delete',
|
||||
resource: 'user',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '角色管理-创建',
|
||||
code: 'role:create',
|
||||
resource: 'role',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '角色管理-查看',
|
||||
code: 'role:read',
|
||||
resource: 'role',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '角色管理-更新',
|
||||
code: 'role:update',
|
||||
resource: 'role',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '角色管理-删除',
|
||||
code: 'role:delete',
|
||||
resource: 'role',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '权限管理-创建',
|
||||
code: 'permission:create',
|
||||
resource: 'permission',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '权限管理-查看',
|
||||
code: 'permission:read',
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '权限管理-更新',
|
||||
code: 'permission:update',
|
||||
resource: 'permission',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '权限管理-删除',
|
||||
code: 'permission:delete',
|
||||
resource: 'permission',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-创建',
|
||||
code: 'menu:create',
|
||||
resource: 'menu',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-查看',
|
||||
code: 'menu:read',
|
||||
resource: 'menu',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-更新',
|
||||
code: 'menu:update',
|
||||
resource: 'menu',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-删除',
|
||||
code: 'menu:delete',
|
||||
resource: 'menu',
|
||||
action: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
const createdPermissions = [];
|
||||
for (const perm of permissions) {
|
||||
const existing = await prisma.permission.findFirst({
|
||||
where: {
|
||||
tenantId: superTenant.id,
|
||||
code: perm.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const permission = await prisma.permission.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
...perm,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
} else {
|
||||
createdPermissions.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`创建了 ${createdPermissions.length} 个权限`);
|
||||
|
||||
// 将所有权限分配给超级管理员角色
|
||||
await prisma.rolePermission.createMany({
|
||||
data: createdPermissions.map((perm) => ({
|
||||
roleId: superAdminRole.id,
|
||||
permissionId: perm.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
console.log('所有权限已分配给超级管理员角色');
|
||||
|
||||
// 创建租户管理菜单(如果不存在)
|
||||
console.log('\n创建租户管理菜单...');
|
||||
|
||||
// 查找系统管理菜单(父菜单)
|
||||
const systemMenu = await prisma.menu.findFirst({
|
||||
where: {
|
||||
name: '系统管理',
|
||||
parentId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (systemMenu) {
|
||||
// 检查租户管理菜单是否已存在
|
||||
const existingTenantMenu = await prisma.menu.findFirst({
|
||||
where: {
|
||||
name: '租户管理',
|
||||
path: '/system/tenants',
|
||||
},
|
||||
});
|
||||
|
||||
let tenantMenu;
|
||||
if (!existingTenantMenu) {
|
||||
tenantMenu = await prisma.menu.create({
|
||||
data: {
|
||||
name: '租户管理',
|
||||
path: '/system/tenants',
|
||||
icon: 'TeamOutlined',
|
||||
component: 'system/tenants/Index',
|
||||
parentId: systemMenu.id,
|
||||
permission: 'tenant:read',
|
||||
sort: 7,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log('租户管理菜单创建成功');
|
||||
} else {
|
||||
tenantMenu = existingTenantMenu;
|
||||
console.log('租户管理菜单已存在,跳过创建');
|
||||
}
|
||||
|
||||
// 为超级租户分配租户管理菜单
|
||||
if (tenantMenu) {
|
||||
const existingTenantMenuRelation = await prisma.tenantMenu.findFirst({
|
||||
where: {
|
||||
tenantId: superTenant.id,
|
||||
menuId: tenantMenu.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingTenantMenuRelation) {
|
||||
await prisma.tenantMenu.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
menuId: tenantMenu.id,
|
||||
},
|
||||
});
|
||||
console.log('租户管理菜单已分配给超级租户');
|
||||
} else {
|
||||
console.log('租户管理菜单已分配给超级租户,跳过');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('警告:未找到系统管理菜单,无法创建租户管理菜单');
|
||||
}
|
||||
|
||||
console.log('\n初始化完成!');
|
||||
console.log('========================================');
|
||||
console.log('超级租户信息:');
|
||||
console.log(` 租户编码: ${superTenant.code}`);
|
||||
console.log(` 访问链接: http://your-domain.com/?tenant=${superTenant.code}`);
|
||||
console.log('========================================');
|
||||
console.log('超级管理员登录信息:');
|
||||
console.log(` 用户名: ${superAdmin.username}`);
|
||||
console.log(` 密码: admin123`);
|
||||
console.log(` 租户编码: ${superTenant.code}`);
|
||||
console.log('========================================');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.log('\n🎉 初始化脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 初始化脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
755
backend/scripts/init-tenant-admin.ts
Normal file
755
backend/scripts/init-tenant-admin.ts
Normal file
@ -0,0 +1,755 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 定义所有基础权限
|
||||
const permissions = [
|
||||
{
|
||||
code: 'workbench:read',
|
||||
resource: 'workbench',
|
||||
action: 'read',
|
||||
name: '查看工作台',
|
||||
description: '允许查看工作台',
|
||||
},
|
||||
// 用户管理权限
|
||||
{
|
||||
code: 'user:create',
|
||||
resource: 'user',
|
||||
action: 'create',
|
||||
name: '创建用户',
|
||||
description: '允许创建新用户',
|
||||
},
|
||||
{
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
name: '查看用户',
|
||||
description: '允许查看用户列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'user:update',
|
||||
resource: 'user',
|
||||
action: 'update',
|
||||
name: '更新用户',
|
||||
description: '允许更新用户信息',
|
||||
},
|
||||
{
|
||||
code: 'user:delete',
|
||||
resource: 'user',
|
||||
action: 'delete',
|
||||
name: '删除用户',
|
||||
description: '允许删除用户',
|
||||
},
|
||||
|
||||
// 角色管理权限
|
||||
{
|
||||
code: 'role:create',
|
||||
resource: 'role',
|
||||
action: 'create',
|
||||
name: '创建角色',
|
||||
description: '允许创建新角色',
|
||||
},
|
||||
{
|
||||
code: 'role:read',
|
||||
resource: 'role',
|
||||
action: 'read',
|
||||
name: '查看角色',
|
||||
description: '允许查看角色列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'role:update',
|
||||
resource: 'role',
|
||||
action: 'update',
|
||||
name: '更新角色',
|
||||
description: '允许更新角色信息',
|
||||
},
|
||||
{
|
||||
code: 'role:delete',
|
||||
resource: 'role',
|
||||
action: 'delete',
|
||||
name: '删除角色',
|
||||
description: '允许删除角色',
|
||||
},
|
||||
{
|
||||
code: 'role:assign',
|
||||
resource: 'role',
|
||||
action: 'assign',
|
||||
name: '分配角色',
|
||||
description: '允许给用户分配角色',
|
||||
},
|
||||
|
||||
// 权限管理权限
|
||||
{
|
||||
code: 'permission:create',
|
||||
resource: 'permission',
|
||||
action: 'create',
|
||||
name: '创建权限',
|
||||
description: '允许创建新权限',
|
||||
},
|
||||
{
|
||||
code: 'permission:read',
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
name: '查看权限',
|
||||
description: '允许查看权限列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'permission:update',
|
||||
resource: 'permission',
|
||||
action: 'update',
|
||||
name: '更新权限',
|
||||
description: '允许更新权限信息',
|
||||
},
|
||||
{
|
||||
code: 'permission:delete',
|
||||
resource: 'permission',
|
||||
action: 'delete',
|
||||
name: '删除权限',
|
||||
description: '允许删除权限',
|
||||
},
|
||||
|
||||
// 菜单管理权限
|
||||
{
|
||||
code: 'menu:create',
|
||||
resource: 'menu',
|
||||
action: 'create',
|
||||
name: '创建菜单',
|
||||
description: '允许创建新菜单',
|
||||
},
|
||||
{
|
||||
code: 'menu:read',
|
||||
resource: 'menu',
|
||||
action: 'read',
|
||||
name: '查看菜单',
|
||||
description: '允许查看菜单列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'menu:update',
|
||||
resource: 'menu',
|
||||
action: 'update',
|
||||
name: '更新菜单',
|
||||
description: '允许更新菜单信息',
|
||||
},
|
||||
{
|
||||
code: 'menu:delete',
|
||||
resource: 'menu',
|
||||
action: 'delete',
|
||||
name: '删除菜单',
|
||||
description: '允许删除菜单',
|
||||
},
|
||||
|
||||
// 数据字典权限
|
||||
{
|
||||
code: 'dict:create',
|
||||
resource: 'dict',
|
||||
action: 'create',
|
||||
name: '创建字典',
|
||||
description: '允许创建新字典',
|
||||
},
|
||||
{
|
||||
code: 'dict:read',
|
||||
resource: 'dict',
|
||||
action: 'read',
|
||||
name: '查看字典',
|
||||
description: '允许查看字典列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'dict:update',
|
||||
resource: 'dict',
|
||||
action: 'update',
|
||||
name: '更新字典',
|
||||
description: '允许更新字典信息',
|
||||
},
|
||||
{
|
||||
code: 'dict:delete',
|
||||
resource: 'dict',
|
||||
action: 'delete',
|
||||
name: '删除字典',
|
||||
description: '允许删除字典',
|
||||
},
|
||||
|
||||
// 系统配置权限
|
||||
{
|
||||
code: 'config:create',
|
||||
resource: 'config',
|
||||
action: 'create',
|
||||
name: '创建配置',
|
||||
description: '允许创建新配置',
|
||||
},
|
||||
{
|
||||
code: 'config:read',
|
||||
resource: 'config',
|
||||
action: 'read',
|
||||
name: '查看配置',
|
||||
description: '允许查看配置列表和详情',
|
||||
},
|
||||
{
|
||||
code: 'config:update',
|
||||
resource: 'config',
|
||||
action: 'update',
|
||||
name: '更新配置',
|
||||
description: '允许更新配置信息',
|
||||
},
|
||||
{
|
||||
code: 'config:delete',
|
||||
resource: 'config',
|
||||
action: 'delete',
|
||||
name: '删除配置',
|
||||
description: '允许删除配置',
|
||||
},
|
||||
|
||||
// 日志管理权限
|
||||
{
|
||||
code: 'log:read',
|
||||
resource: 'log',
|
||||
action: 'read',
|
||||
name: '查看日志',
|
||||
description: '允许查看系统日志',
|
||||
},
|
||||
{
|
||||
code: 'log:delete',
|
||||
resource: 'log',
|
||||
action: 'delete',
|
||||
name: '删除日志',
|
||||
description: '允许删除系统日志',
|
||||
},
|
||||
|
||||
// 用户密码管理权限
|
||||
{
|
||||
code: 'user:password:update',
|
||||
resource: 'user',
|
||||
action: 'password:update',
|
||||
name: '修改用户密码',
|
||||
description: '允许修改用户密码',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 仅初始化 admin 角色的权限(不创建用户、角色和菜单)
|
||||
*/
|
||||
async function initTenantAdminPermissionsOnly(tenantCode: string) {
|
||||
try {
|
||||
console.log(`🚀 开始为租户 "${tenantCode}" 的 admin 角色初始化权限...\n`);
|
||||
|
||||
// 1. 查找租户
|
||||
console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`);
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { code: tenantCode },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`);
|
||||
console.error(' 请先创建租户后再运行此脚本');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tenant.validState !== 1) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
|
||||
|
||||
// 2. 检查 admin 角色是否存在
|
||||
console.log(`👤 步骤 2: 检查 admin 角色是否存在...`);
|
||||
const adminRole = await prisma.role.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
code: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminRole) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 的 admin 角色不存在!`);
|
||||
console.error(' 请先运行完整初始化脚本创建 admin 角色');
|
||||
console.error(` 使用方法: pnpm init:tenant-admin ${tenantCode}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ 找到 admin 角色: ${adminRole.name} (${adminRole.code})\n`);
|
||||
|
||||
// 3. 初始化租户权限(如果不存在则创建)
|
||||
console.log(`📝 步骤 3: 初始化租户权限...`);
|
||||
const createdPermissions = [];
|
||||
|
||||
for (const perm of permissions) {
|
||||
// 检查权限是否已存在
|
||||
const existingPermission = await prisma.permission.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
code: perm.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingPermission) {
|
||||
// 创建权限
|
||||
const permission = await prisma.permission.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
code: perm.code,
|
||||
resource: perm.resource,
|
||||
action: perm.action,
|
||||
name: perm.name,
|
||||
description: perm.description,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
console.log(` ✓ 创建权限: ${perm.code} - ${perm.name}`);
|
||||
} else {
|
||||
// 更新现有权限(确保信息是最新的)
|
||||
const permission = await prisma.permission.update({
|
||||
where: { id: existingPermission.id },
|
||||
data: {
|
||||
name: perm.name,
|
||||
resource: perm.resource,
|
||||
action: perm.action,
|
||||
description: perm.description,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`);
|
||||
|
||||
// 获取租户的所有有效权限
|
||||
const tenantPermissions = await prisma.permission.findMany({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 为 admin 角色分配所有权限
|
||||
console.log(`🔗 步骤 4: 为 admin 角色分配所有权限...`);
|
||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
||||
where: { roleId: adminRole.id },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingPermissionIds = new Set(
|
||||
existingRolePermissions.map((rp) => rp.permissionId),
|
||||
);
|
||||
|
||||
let addedCount = 0;
|
||||
for (const permission of tenantPermissions) {
|
||||
if (!existingPermissionIds.has(permission.id)) {
|
||||
await prisma.rolePermission.create({
|
||||
data: {
|
||||
roleId: adminRole.id,
|
||||
permissionId: permission.id,
|
||||
},
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
console.log(`✅ 为 admin 角色添加了 ${addedCount} 个权限`);
|
||||
console.log(`✅ admin 角色现在拥有 ${tenantPermissions.length} 个权限\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ admin 角色已拥有所有权限(${tenantPermissions.length} 个)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 验证结果
|
||||
console.log('🔍 步骤 5: 验证结果...');
|
||||
const roleWithPermissions = await prisma.role.findUnique({
|
||||
where: { id: adminRole.id },
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const permissionCodes = new Set<string>();
|
||||
roleWithPermissions?.permissions.forEach((rp) => {
|
||||
permissionCodes.add(rp.permission.code);
|
||||
});
|
||||
|
||||
console.log(`\n📊 初始化结果:`);
|
||||
console.log(` 租户名称: ${tenant.name}`);
|
||||
console.log(` 租户编码: ${tenant.code}`);
|
||||
console.log(` 角色名称: ${adminRole.name}`);
|
||||
console.log(` 角色编码: ${adminRole.code}`);
|
||||
console.log(` 权限数量: ${permissionCodes.size}`);
|
||||
if (permissionCodes.size > 0) {
|
||||
console.log(` 权限列表:`);
|
||||
Array.from(permissionCodes)
|
||||
.sort()
|
||||
.forEach((code) => {
|
||||
console.log(` - ${code}`);
|
||||
});
|
||||
}
|
||||
console.log(`\n✅ admin 角色权限初始化完成!`);
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function initTenantAdmin(tenantCode: string) {
|
||||
try {
|
||||
console.log(`🚀 开始为租户 "${tenantCode}" 初始化 admin 账号...\n`);
|
||||
|
||||
// 1. 查找租户
|
||||
console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`);
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { code: tenantCode },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`);
|
||||
console.error(' 请先创建租户后再运行此脚本');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tenant.validState !== 1) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
|
||||
|
||||
// 2. 检查是否已存在 admin 用户
|
||||
console.log(`👤 步骤 2: 检查 admin 用户是否已存在...`);
|
||||
const existingAdmin = await prisma.user.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
username: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log(`⚠️ 警告: 租户 "${tenantCode}" 已存在 admin 用户`);
|
||||
console.log(` 用户ID: ${existingAdmin.id}`);
|
||||
console.log(` 用户名: ${existingAdmin.username}`);
|
||||
console.log(` 昵称: ${existingAdmin.nickname}`);
|
||||
console.log(` 将更新密码和权限...\n`);
|
||||
}
|
||||
|
||||
// 3. 初始化租户权限(如果不存在则创建)
|
||||
console.log(`📝 步骤 3: 初始化租户权限...`);
|
||||
const createdPermissions = [];
|
||||
|
||||
for (const perm of permissions) {
|
||||
// 检查权限是否已存在
|
||||
const existingPermission = await prisma.permission.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
code: perm.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingPermission) {
|
||||
// 创建权限
|
||||
const permission = await prisma.permission.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
code: perm.code,
|
||||
resource: perm.resource,
|
||||
action: perm.action,
|
||||
name: perm.name,
|
||||
description: perm.description,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
console.log(` ✓ 创建权限: ${perm.code} - ${perm.name}`);
|
||||
} else {
|
||||
// 更新现有权限(确保信息是最新的)
|
||||
const permission = await prisma.permission.update({
|
||||
where: { id: existingPermission.id },
|
||||
data: {
|
||||
name: perm.name,
|
||||
resource: perm.resource,
|
||||
action: perm.action,
|
||||
description: perm.description,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`);
|
||||
|
||||
// 获取租户的所有有效权限
|
||||
const tenantPermissions = await prisma.permission.findMany({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 创建或获取 admin 角色
|
||||
console.log(`👤 步骤 4: 创建或获取 admin 角色...`);
|
||||
let adminRole = await prisma.role.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
code: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminRole) {
|
||||
adminRole = await prisma.role.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: '管理员',
|
||||
code: 'admin',
|
||||
description: '租户管理员角色,拥有租户的所有权限',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ admin 角色已创建: ${adminRole.name} (${adminRole.code})\n`,
|
||||
);
|
||||
} else {
|
||||
// 更新角色信息
|
||||
adminRole = await prisma.role.update({
|
||||
where: { id: adminRole.id },
|
||||
data: {
|
||||
name: '管理员',
|
||||
description: '租户管理员角色,拥有租户的所有权限',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ admin 角色已更新: ${adminRole.name} (${adminRole.code})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 为 admin 角色分配所有权限
|
||||
console.log(`🔗 步骤 5: 为 admin 角色分配所有权限...`);
|
||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
||||
where: { roleId: adminRole.id },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingPermissionIds = new Set(
|
||||
existingRolePermissions.map((rp) => rp.permissionId),
|
||||
);
|
||||
|
||||
let addedCount = 0;
|
||||
for (const permission of tenantPermissions) {
|
||||
if (!existingPermissionIds.has(permission.id)) {
|
||||
await prisma.rolePermission.create({
|
||||
data: {
|
||||
roleId: adminRole.id,
|
||||
permissionId: permission.id,
|
||||
},
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
console.log(`✅ 为 admin 角色添加了 ${addedCount} 个权限`);
|
||||
console.log(`✅ admin 角色现在拥有 ${tenantPermissions.length} 个权限\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ admin 角色已拥有所有权限(${tenantPermissions.length} 个)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 创建或更新 admin 用户
|
||||
console.log(`👤 步骤 6: 创建或更新 admin 用户...`);
|
||||
const password = `admin@${tenantCode}`;
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
let adminUser;
|
||||
if (existingAdmin) {
|
||||
adminUser = await prisma.user.update({
|
||||
where: { id: existingAdmin.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
nickname: '管理员',
|
||||
email: `admin@${tenantCode}.com`,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ 用户已更新: ${adminUser.username} (${adminUser.nickname})\n`,
|
||||
);
|
||||
} else {
|
||||
adminUser = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
nickname: '管理员',
|
||||
email: `admin@${tenantCode}.com`,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`✅ 用户已创建: ${adminUser.username} (${adminUser.nickname})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 为 admin 用户分配 admin 角色
|
||||
console.log(`🔗 步骤 7: 为 admin 用户分配 admin 角色...`);
|
||||
const existingUserRole = await prisma.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: adminUser.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUserRole) {
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
userId: adminUser.id,
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
});
|
||||
console.log(`✅ 角色分配成功\n`);
|
||||
} else {
|
||||
console.log(`✅ 用户已拥有 admin 角色\n`);
|
||||
}
|
||||
|
||||
// 8. 验证结果
|
||||
console.log('🔍 步骤 8: 验证结果...');
|
||||
const userWithRoles = await prisma.user.findUnique({
|
||||
where: { id: adminUser.id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || [];
|
||||
const permissionCodes = new Set<string>();
|
||||
userWithRoles?.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
permissionCodes.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n📊 初始化结果:`);
|
||||
console.log(` 租户名称: ${tenant.name}`);
|
||||
console.log(` 租户编码: ${tenant.code}`);
|
||||
console.log(` 用户名: ${adminUser.username}`);
|
||||
console.log(` 昵称: ${adminUser.nickname}`);
|
||||
console.log(` 密码: ${password}`);
|
||||
console.log(` 角色: ${roleCodes.join(', ')}`);
|
||||
console.log(` 权限数量: ${permissionCodes.size}`);
|
||||
if (permissionCodes.size > 0) {
|
||||
console.log(` 权限列表:`);
|
||||
Array.from(permissionCodes)
|
||||
.sort()
|
||||
.forEach((code) => {
|
||||
console.log(` - ${code}`);
|
||||
});
|
||||
}
|
||||
console.log(`\n✅ 租户 admin 账号初始化完成!`);
|
||||
console.log(`\n💡 现在可以使用以下凭据登录:`);
|
||||
console.log(` 租户编码: ${tenant.code}`);
|
||||
console.log(` 用户名: ${adminUser.username}`);
|
||||
console.log(` 密码: ${password}`);
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取命令行参数
|
||||
// 支持两种调用方式:
|
||||
// 1. pnpm init:tenant-admin tenant1 --permissions-only
|
||||
// 2. pnpm init:tenant-admin:permissions tenant1 (--permissions-only 在 argv[2])
|
||||
let tenantCode: string | undefined;
|
||||
let permissionsOnly = false;
|
||||
|
||||
// 检查是否有 --permissions-only 标志
|
||||
if (process.argv[2] === '--permissions-only') {
|
||||
permissionsOnly = true;
|
||||
tenantCode = process.argv[3];
|
||||
} else if (process.argv[3] === '--permissions-only') {
|
||||
permissionsOnly = true;
|
||||
tenantCode = process.argv[2];
|
||||
} else {
|
||||
tenantCode = process.argv[2];
|
||||
}
|
||||
|
||||
if (!tenantCode) {
|
||||
console.error('❌ 错误: 请提供租户编码作为参数');
|
||||
console.error(' 使用方法:');
|
||||
console.error(' 完整初始化: pnpm init:tenant-admin <租户编码>');
|
||||
console.error(
|
||||
' 仅初始化权限: pnpm init:tenant-admin <租户编码> --permissions-only',
|
||||
);
|
||||
console.error(' 或: pnpm init:tenant-admin:permissions <租户编码>');
|
||||
console.error(' 示例:');
|
||||
console.error(' pnpm init:tenant-admin tenant1');
|
||||
console.error(' pnpm init:tenant-admin tenant1 --permissions-only');
|
||||
console.error(' pnpm init:tenant-admin:permissions tenant1');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
const initFunction = permissionsOnly
|
||||
? initTenantAdminPermissionsOnly
|
||||
: initTenantAdmin;
|
||||
|
||||
initFunction(tenantCode)
|
||||
.then(() => {
|
||||
console.log('\n🎉 初始化脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 初始化脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
120
backend/scripts/update-password.ts
Normal file
120
backend/scripts/update-password.ts
Normal file
@ -0,0 +1,120 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// 加载环境变量(必须在其他导入之前)
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const envFile = `.env.${nodeEnv}`;
|
||||
// scripts 目录的父目录就是 backend 目录
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
const envPath = path.resolve(backendDir, envFile);
|
||||
|
||||
// 尝试加载环境特定的配置文件
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
||||
if (!process.env.DATABASE_URL) {
|
||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
||||
}
|
||||
|
||||
// 验证必要的环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
||||
console.error(` 请确保存在以下文件之一:`);
|
||||
console.error(` - ${envPath}`);
|
||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function updatePassword() {
|
||||
try {
|
||||
const tenantCode = 'super';
|
||||
const username = 'admin';
|
||||
const newPassword = process.argv[2] || 'cms@admin'; // 支持命令行参数传入新密码
|
||||
|
||||
console.log(`🔐 开始修改租户 "${tenantCode}" 的 admin 用户密码...\n`);
|
||||
|
||||
// 1. 查找租户
|
||||
console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`);
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { code: tenantCode },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
|
||||
|
||||
// 2. 查找用户
|
||||
console.log(`👤 步骤 2: 查找用户 "${username}"...`);
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
username: username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
console.error(
|
||||
`❌ 错误: 租户 "${tenantCode}" 下不存在用户 "${username}"!`,
|
||||
);
|
||||
console.error(` 请先创建该用户`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 找到用户: ${existingUser.username} (${existingUser.nickname})\n`,
|
||||
);
|
||||
|
||||
// 3. 加密新密码
|
||||
console.log(`🔒 步骤 3: 加密新密码...`);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
console.log(`✅ 密码加密完成\n`);
|
||||
|
||||
// 4. 更新密码
|
||||
console.log(`💾 步骤 4: 更新用户密码...`);
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ 密码修改成功!\n`);
|
||||
console.log(`📊 更新结果:`);
|
||||
console.log(` 租户名称: ${tenant.name}`);
|
||||
console.log(` 租户编码: ${tenant.code}`);
|
||||
console.log(` 用户ID: ${updatedUser.id}`);
|
||||
console.log(` 用户名: ${updatedUser.username}`);
|
||||
console.log(` 昵称: ${updatedUser.nickname}`);
|
||||
console.log(` 新密码: ${newPassword}`);
|
||||
console.log(` 修改时间: ${updatedUser.modifyTime}\n`);
|
||||
} catch (error) {
|
||||
console.error('❌ 修改密码时发生错误:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 执行脚本
|
||||
updatePassword()
|
||||
.then(() => {
|
||||
console.log('🎉 密码修改脚本执行完成!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n💥 密码修改脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
64
backend/scripts/verify-admin.js
Normal file
64
backend/scripts/verify-admin.js
Normal file
@ -0,0 +1,64 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function verifyAdmin() {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log('\n✅ 验证结果:');
|
||||
console.log(`用户名: ${user.username}`);
|
||||
console.log(`昵称: ${user.nickname}`);
|
||||
console.log(`邮箱: ${user.email || '未设置'}`);
|
||||
console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`);
|
||||
console.log(`\n角色列表:`);
|
||||
user.roles.forEach((ur) => {
|
||||
console.log(` - ${ur.role.name} (${ur.role.code})`);
|
||||
console.log(` 权限数量: ${ur.role.permissions.length}`);
|
||||
});
|
||||
|
||||
const allPermissions = new Set();
|
||||
user.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
allPermissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n总权限数: ${allPermissions.size}`);
|
||||
console.log(`\n权限列表 (前10个):`);
|
||||
Array.from(allPermissions).sort().slice(0, 10).forEach((perm) => {
|
||||
console.log(` - ${perm}`);
|
||||
});
|
||||
if (allPermissions.size > 10) {
|
||||
console.log(` ... 还有 ${allPermissions.size - 10} 个权限`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 未找到 admin 用户');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error.message);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
verifyAdmin();
|
||||
|
||||
|
||||
59
backend/scripts/verify-admin.ts
Normal file
59
backend/scripts/verify-admin.ts
Normal file
@ -0,0 +1,59 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function verifyAdmin() {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log('\n✅ 验证结果:');
|
||||
console.log(`用户名: ${user.username}`);
|
||||
console.log(`昵称: ${user.nickname}`);
|
||||
console.log(`邮箱: ${user.email || '未设置'}`);
|
||||
console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`);
|
||||
console.log(`\n角色列表:`);
|
||||
user.roles.forEach((ur) => {
|
||||
console.log(` - ${ur.role.name} (${ur.role.code})`);
|
||||
console.log(` 权限数量: ${ur.role.permissions.length}`);
|
||||
});
|
||||
|
||||
const allPermissions = new Set<string>();
|
||||
user.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
allPermissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n总权限数: ${allPermissions.size}`);
|
||||
console.log(`\n权限列表:`);
|
||||
Array.from(allPermissions)
|
||||
.sort()
|
||||
.forEach((perm) => {
|
||||
console.log(` - ${perm}`);
|
||||
});
|
||||
} else {
|
||||
console.log('❌ 未找到 admin 用户');
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
verifyAdmin();
|
||||
58
backend/sql/add_tenant_menu.sql
Normal file
58
backend/sql/add_tenant_menu.sql
Normal file
@ -0,0 +1,58 @@
|
||||
-- 为超级租户添加租户管理菜单
|
||||
-- 注意:需要先查询系统管理菜单的ID,然后替换下面的 parent_id
|
||||
|
||||
-- 查询系统管理菜单的ID
|
||||
-- SELECT id FROM menus WHERE name = '系统管理' AND parent_id IS NULL;
|
||||
|
||||
-- 假设系统管理菜单的ID为某个值(需要根据实际情况调整)
|
||||
-- 这里使用子查询来动态获取系统管理菜单的ID
|
||||
|
||||
INSERT INTO menus (
|
||||
name,
|
||||
path,
|
||||
icon,
|
||||
component,
|
||||
parent_id,
|
||||
permission,
|
||||
sort,
|
||||
valid_state,
|
||||
create_time,
|
||||
modify_time
|
||||
)
|
||||
SELECT
|
||||
'租户管理',
|
||||
'/system/tenants',
|
||||
'TeamOutlined',
|
||||
'system/tenants/Index',
|
||||
id, -- 系统管理菜单的ID
|
||||
'tenant:read',
|
||||
7, -- 排序,放在其他系统管理菜单之后
|
||||
1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM menus
|
||||
WHERE name = '系统管理' AND parent_id IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
-- 如果系统管理菜单不存在,可以手动指定ID:
|
||||
-- INSERT INTO menus (name, path, icon, component, parent_id, permission, sort, valid_state, create_time, modify_time)
|
||||
-- VALUES ('租户管理', '/system/tenants', 'TeamOutlined', 'system/tenants/Index', 2, 'tenant:read', 7, 1, NOW(), NOW());
|
||||
|
||||
-- 为超级租户分配租户管理菜单
|
||||
-- 假设超级租户的ID为1(需要根据实际情况调整)
|
||||
-- 假设租户管理菜单的ID为刚插入的菜单ID
|
||||
|
||||
INSERT INTO tenant_menus (tenant_id, menu_id)
|
||||
SELECT
|
||||
t.id AS tenant_id,
|
||||
m.id AS menu_id
|
||||
FROM tenants t
|
||||
CROSS JOIN menus m
|
||||
WHERE t.code = 'super' AND t.is_super = 1
|
||||
AND m.name = '租户管理' AND m.path = '/system/tenants'
|
||||
LIMIT 1;
|
||||
|
||||
-- 如果上面的查询没有结果,可以手动指定ID:
|
||||
-- INSERT INTO tenant_menus (tenant_id, menu_id)
|
||||
-- VALUES (1, (SELECT id FROM menus WHERE name = '租户管理' AND path = '/system/tenants' LIMIT 1));
|
||||
|
||||
200
backend/sql/competition.sql
Normal file
200
backend/sql/competition.sql
Normal file
@ -0,0 +1,200 @@
|
||||
CREATE TABLE `t_contest` (
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_name` varchar(127) NOT NULL COMMENT '赛事名称',
|
||||
`contest_type` varchar(31) NOT NULL COMMENT '赛事类型,字典:contest_type:individual/team',
|
||||
`contest_state` varchar(31) NOT NULL COMMENT '赛事状态(未发布: unpublished 已发布: published'),
|
||||
`start_time` datetime NOT NULL COMMENT '赛事开始时间',
|
||||
`end_time` datetime NOT NULL COMMENT '赛事结束时间',
|
||||
`address` varchar(512) DEFAULT NULL COMMENT '线下地址',
|
||||
`content` text COMMENT '赛事详情',
|
||||
`contest_tenant` text COMMENT '赛事参赛范围(授权租户)',
|
||||
`cover_url` varchar(255) DEFAULT NULL COMMENT '封面url',
|
||||
`poster_url` varchar(255) DEFAULT NULL COMMENT '海报url',
|
||||
`contact_name` varchar(63) DEFAULT NULL COMMENT '联系人',
|
||||
`contact_phone` varchar(63) DEFAULT NULL COMMENT '联系电话',
|
||||
`contact_qrcode` varchar(255) DEFAULT NULL COMMENT '联系人二维码',
|
||||
`organizers` text DEFAULT NULL COMMENT '主办单位数组',
|
||||
`co_organizers` text DEFAULT NULL COMMENT '协办单位数组',
|
||||
`sponsors` text DEFAULT NULL COMMENT '赞助单位数组',
|
||||
`register_start_time` datetime NOT NULL COMMENT '报名开始时间',
|
||||
`register_end_time` datetime NOT NULL COMMENT '报名结束时间',
|
||||
`register_state` varchar(31) DEFAULT NULL COMMENT '报名任务状态,映射写死:启动(started),已关闭(closed)',
|
||||
`submit_rule` varchar(31) NOT NULL DEFAULT 'once' COMMENT '提交规则:once/resubmit',
|
||||
`submit_start_time` datetime NOT NULL COMMENT '作品提交开始时间',
|
||||
`submit_end_time` datetime NOT NULL COMMENT '作品提交结束时间',
|
||||
`review_rule_id` varchar(63) DEFAULT NULL COMMENT '评审规则id',
|
||||
`review_start_time` datetime NOT NULL COMMENT '评审开始时间',
|
||||
`review_end_time` datetime NOT NULL COMMENT '评审结束时间',
|
||||
`result_publish_time` datetime DEFAULT NULL COMMENT '结果发布时间',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`contest_id`) USING BTREE,
|
||||
UNIQUE KEY `uk_contest_name` (`contest_name`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事表';
|
||||
|
||||
CREATE TABLE `t_contest_attachment` (
|
||||
`id` varchar(63) NOT NULL,
|
||||
`contest_id` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '赛事id',
|
||||
`file_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名',
|
||||
`file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件路径',
|
||||
`format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事附件';
|
||||
|
||||
CREATE TABLE `t_contest_work` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id',
|
||||
`work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)',
|
||||
`title` varchar(255) NOT NULL COMMENT '作品标题',
|
||||
`description` text DEFAULT NULL COMMENT '作品说明',
|
||||
`files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)',
|
||||
`is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否',
|
||||
`status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted',
|
||||
`submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
|
||||
`submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id',
|
||||
`submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)',
|
||||
`submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader',
|
||||
`preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)',
|
||||
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_work_no` (`work_no`),
|
||||
KEY `idx_work_contest_latest` (`tenant_key`,`contest_id`,`is_latest`),
|
||||
KEY `idx_work_entry` (`entry_id`),
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品';
|
||||
|
||||
CREATE TABLE `t_contest_work_attachment` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
|
||||
`format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`),
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表';
|
||||
|
||||
CREATE TABLE `t_contest_work_score` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)',
|
||||
`judge_id` varchar(63) NOT NULL COMMENT '评委账号id',
|
||||
`judge_name` varchar(127) NOT NULL COMMENT '评委姓名',
|
||||
`dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}',
|
||||
`total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)',
|
||||
`comments` text DEFAULT NULL COMMENT '评语',
|
||||
`score_time` datetime NOT NULL COMMENT '评分时间',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
||||
|
||||
CREATE TABLE `t_contest_work_score` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)',
|
||||
`judge_id` varchar(63) NOT NULL COMMENT '评委账号id',
|
||||
`judge_name` varchar(127) NOT NULL COMMENT '评委姓名',
|
||||
`dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}',
|
||||
`total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)',
|
||||
`comments` text DEFAULT NULL COMMENT '评语',
|
||||
`score_time` datetime NOT NULL COMMENT '评分时间',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
||||
|
||||
CREATE TABLE `t_contest_registration` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`tenant_key` varchar(64) NOT NULL COMMENT '所属租户键(学校/机构)',
|
||||
`registration_type` varchar(20) DEFAULT NULL COMMENT '报名类型:individual(个人)/team(团队)',
|
||||
`team_id` varchar(64) DEFAULT NULL COMMENT '团队id',
|
||||
`team_name` varchar(255) DEFAULT NULL COMMENT '团队名称快照(团队赛)',
|
||||
`account_id` varchar(64) NOT NULL COMMENT '账号id',
|
||||
`account_no` varchar(64) NOT NULL COMMENT '报名账号(记录报名快照)',
|
||||
`account_name` varchar(100) NOT NULL COMMENT '报名账号名称(记录报名快照)',
|
||||
`role` varchar(63) DEFAULT NULL COMMENT '报名角色快照:leader(队长)/member(队员)/mentor(指导教师)',
|
||||
`registration_state` varchar(31) NOT NULL COMMENT '报名状态:pending(待审核)、passed(已通过)、rejected(已拒绝)、withdrawn(已撤回)',
|
||||
`registrant` varchar(63) DEFAULT NULL COMMENT '实际报名人(老师报名填老师账号)',
|
||||
`registration_time` datetime NOT NULL COMMENT '报名时间',
|
||||
`reason` varchar(1023) DEFAULT NULL COMMENT '审核理由',
|
||||
`operator` varchar(64) DEFAULT NULL COMMENT '审核人',
|
||||
`operation_date` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事报名人员记录表';
|
||||
|
||||
CREATE TABLE `t_contest_team` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
|
||||
`leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id',
|
||||
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`),
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队';
|
||||
|
||||
CREATE TABLE `t_contest_team_member` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '成员所属租户键',
|
||||
`team_id` varchar(63) NOT NULL COMMENT '团队id',
|
||||
`account_id` varchar(63) NOT NULL COMMENT '成员用户id',
|
||||
`role` varchar(31) NOT NULL DEFAULT 'member' COMMENT '成员角色:member/leader/mentor',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_member_once` (`tenant_key`,`team_id`,`account_id`),
|
||||
KEY `idx_team` (`team_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='团队成员';
|
||||
180
backend/sql/competition_fixes.md
Normal file
180
backend/sql/competition_fixes.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 赛事管理 SQL 文件修复清单
|
||||
|
||||
## ⚠️ 需要修复的问题
|
||||
|
||||
### 1. 删除重复的表定义
|
||||
|
||||
**问题位置:** 第125-144行
|
||||
**问题描述:** `t_contest_work_score` 表被定义了两次(第104-123行和第125-144行)
|
||||
**修复方案:** 删除第125-144行的重复定义
|
||||
|
||||
```sql
|
||||
-- 删除以下重复定义(第125-144行)
|
||||
CREATE TABLE `t_contest_work_score` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
...
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
||||
```
|
||||
|
||||
### 2. 修复表定义语法错误
|
||||
|
||||
**问题位置:** 第102行
|
||||
**问题描述:** `t_contest_work_attachment` 表定义末尾有多余的逗号
|
||||
**修复方案:** 删除第101行末尾的逗号
|
||||
|
||||
```sql
|
||||
-- 第101行,删除末尾逗号
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`), -- ❌ 错误:末尾有逗号
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 正确:删除逗号
|
||||
```
|
||||
|
||||
### 3. 修复索引字段名错误
|
||||
|
||||
**问题位置:** 第183行
|
||||
**问题描述:** `t_contest_team` 表的唯一索引引用了不存在的字段 `name`,实际字段名是 `team_name`
|
||||
**修复方案:** 将索引字段名改为 `team_name`
|
||||
|
||||
```sql
|
||||
-- 第183行,修改前:
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`), -- ❌ 错误:字段名错误
|
||||
|
||||
-- 修改后:
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 正确
|
||||
```
|
||||
|
||||
### 4. 修复索引字段不存在错误
|
||||
|
||||
**问题位置:** 第82行
|
||||
**问题描述:** `t_contest_work` 表的索引 `idx_submit_filter` 引用了不存在的字段 `review_status`
|
||||
**修复方案:** 删除该索引或修改为存在的字段
|
||||
|
||||
```sql
|
||||
-- 第82行,修改前:
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`) -- ❌ 错误:review_status 字段不存在
|
||||
|
||||
-- 修改方案1:删除该索引(如果不需要)
|
||||
-- 直接删除这一行
|
||||
|
||||
-- 修改方案2:修改为存在的字段(如果需要该索引)
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 使用 status 字段
|
||||
```
|
||||
|
||||
## 📝 修复后的完整 SQL(关键部分)
|
||||
|
||||
### t_contest_work_attachment 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_work_attachment` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
|
||||
`format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 已删除末尾逗号
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表';
|
||||
```
|
||||
|
||||
### t_contest_work 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_work` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id',
|
||||
`work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)',
|
||||
`title` varchar(255) NOT NULL COMMENT '作品标题',
|
||||
`description` text DEFAULT NULL COMMENT '作品说明',
|
||||
`files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)',
|
||||
`is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否',
|
||||
`status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted',
|
||||
`submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
|
||||
`submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id',
|
||||
`submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)',
|
||||
`submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader',
|
||||
`preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)',
|
||||
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_work_no` (`work_no`),
|
||||
KEY `idx_work_contest_latest` (`tenant_key`,`contest_id`,`is_latest`),
|
||||
KEY `idx_work_entry` (`entry_id`),
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 已修复:使用 status 字段
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品';
|
||||
```
|
||||
|
||||
### t_contest_team 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_team` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
|
||||
`leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id',
|
||||
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 已修复:使用 team_name 字段
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队';
|
||||
```
|
||||
|
||||
## 🔍 验证步骤
|
||||
|
||||
修复完成后,请执行以下验证:
|
||||
|
||||
1. **语法检查**
|
||||
```bash
|
||||
# 使用 MySQL 客户端检查语法
|
||||
mysql -u root -p < competition.sql
|
||||
```
|
||||
|
||||
2. **表结构验证**
|
||||
```sql
|
||||
-- 检查表是否存在
|
||||
SHOW TABLES LIKE 't_contest%';
|
||||
|
||||
-- 检查表结构
|
||||
DESCRIBE t_contest_work;
|
||||
DESCRIBE t_contest_work_attachment;
|
||||
DESCRIBE t_contest_team;
|
||||
DESCRIBE t_contest_work_score;
|
||||
```
|
||||
|
||||
3. **索引验证**
|
||||
```sql
|
||||
-- 检查索引是否正确
|
||||
SHOW INDEX FROM t_contest_work;
|
||||
SHOW INDEX FROM t_contest_team;
|
||||
```
|
||||
|
||||
## 📌 建议
|
||||
|
||||
在修复 SQL 文件后,建议:
|
||||
|
||||
1. **创建数据库迁移文件**:使用 Prisma Migrate 或手动创建迁移
|
||||
2. **更新 Prisma Schema**:将修复后的表结构同步到 `schema.prisma`
|
||||
3. **测试数据插入**:插入测试数据验证表结构正确性
|
||||
4. **备份数据库**:在执行迁移前备份现有数据
|
||||
|
||||
65
backend/src/app.module.ts
Normal file
65
backend/src/app.module.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { MenusModule } from './menus/menus.module';
|
||||
import { DictModule } from './dict/dict.module';
|
||||
import { ConfigModule as SystemConfigModule } from './config/config.module';
|
||||
import { LogsModule } from './logs/logs.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
// envFilePath 指定配置文件路径
|
||||
// 如果需要后备文件,可以取消下面的注释,但要注意 .env 会覆盖 .development.env 的值
|
||||
envFilePath: [
|
||||
'.env',
|
||||
`.env.${process.env.NODE_ENV || 'development'}`, // 优先加载
|
||||
],
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
PermissionsModule,
|
||||
MenusModule,
|
||||
DictModule,
|
||||
SystemConfigModule,
|
||||
LogsModule,
|
||||
TenantsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: LoggingInterceptor, // 日志拦截器,先执行
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor, // 响应转换拦截器
|
||||
},
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
41
backend/src/auth/auth.controller.ts
Normal file
41
backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(AuthGuard('local'))
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto, @Request() req) {
|
||||
// 从请求头或请求体获取租户ID
|
||||
const tenantId = req.headers['x-tenant-id']
|
||||
? parseInt(req.headers['x-tenant-id'], 10)
|
||||
: req.user?.tenantId;
|
||||
|
||||
return this.authService.login(req.user, tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get('user-info')
|
||||
async getUserInfo(@Request() req) {
|
||||
return this.authService.getUserInfo(req.user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Post('logout')
|
||||
async logout() {
|
||||
return { message: '登出成功' };
|
||||
}
|
||||
}
|
||||
30
backend/src/auth/auth.module.ts
Normal file
30
backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy, RolesGuard],
|
||||
exports: [AuthService, RolesGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
111
backend/src/auth/auth.service.ts
Normal file
111
backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, password: string, tenantId?: number): Promise<any> {
|
||||
const user = await this.usersService.findByUsername(username, tenantId);
|
||||
if (user && (await bcrypt.compare(password, user.password))) {
|
||||
// 验证租户是否匹配
|
||||
if (tenantId && user.tenantId !== tenantId) {
|
||||
throw new UnauthorizedException('用户不属于该租户');
|
||||
}
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: any, tenantId?: number) {
|
||||
// 确保租户ID存在
|
||||
const finalTenantId = tenantId || user.tenantId;
|
||||
if (!finalTenantId) {
|
||||
throw new BadRequestException('无法确定租户信息');
|
||||
}
|
||||
|
||||
// 验证租户是否有效
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: finalTenantId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('租户不存在');
|
||||
}
|
||||
|
||||
if (tenant.validState !== 1) {
|
||||
throw new BadRequestException('租户已失效');
|
||||
}
|
||||
|
||||
// 验证用户是否属于该租户
|
||||
if (user.tenantId !== finalTenantId) {
|
||||
throw new UnauthorizedException('用户不属于该租户');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.id,
|
||||
tenantId: finalTenantId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
tenantId: finalTenantId,
|
||||
tenantCode: tenant.code,
|
||||
roles: user.roles?.map((ur: any) => ur.role.code) || [],
|
||||
permissions: await this.getUserPermissions(user.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(userId: number) {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
tenantId: user.tenantId,
|
||||
tenantCode: tenant?.code,
|
||||
roles: user.roles?.map((ur: any) => ur.role.code) || [],
|
||||
permissions: await this.getUserPermissions(userId),
|
||||
};
|
||||
}
|
||||
|
||||
async getUserPermissions(userId: number): Promise<string[]> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) return [];
|
||||
|
||||
const permissions = new Set<string>();
|
||||
user.roles?.forEach((ur: any) => {
|
||||
ur.role.permissions?.forEach((rp: any) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
}
|
||||
4
backend/src/auth/decorators/public.decorator.ts
Normal file
4
backend/src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSION_KEY = 'permission';
|
||||
export const RequirePermission = (permission: string) =>
|
||||
SetMetadata(PERMISSION_KEY, permission);
|
||||
4
backend/src/auth/decorators/roles.decorator.ts
Normal file
4
backend/src/auth/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
15
backend/src/auth/dto/login.dto.ts
Normal file
15
backend/src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tenantCode?: string; // 租户编码(可选,如果未提供则从请求头获取)
|
||||
}
|
||||
24
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
24
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
40
backend/src/auth/guards/permissions.guard.ts
Normal file
40
backend/src/auth/guards/permissions.guard.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredPermission = this.reflector.getAllAndOverride<string>(PERMISSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.userId) {
|
||||
throw new ForbiddenException('未授权访问');
|
||||
}
|
||||
|
||||
// 获取用户的所有权限
|
||||
const userPermissions = await this.authService.getUserPermissions(user.userId);
|
||||
|
||||
if (!userPermissions.includes(requiredPermission)) {
|
||||
throw new ForbiddenException(`缺少权限: ${requiredPermission}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/src/auth/guards/roles.guard.ts
Normal file
56
backend/src/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.userId) {
|
||||
throw new ForbiddenException('未授权访问');
|
||||
}
|
||||
|
||||
// 从数据库获取用户的角色
|
||||
const userWithRoles = await this.prisma.user.findUnique({
|
||||
where: { id: user.userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWithRoles) {
|
||||
throw new ForbiddenException('用户不存在');
|
||||
}
|
||||
|
||||
const userRoles = userWithRoles.roles?.map((ur: any) => ur.role.code) || [];
|
||||
|
||||
// 检查用户是否有任一所需角色
|
||||
const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role));
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
throw new ForbiddenException(`需要以下角色之一: ${requiredRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
backend/src/auth/strategies/jwt.strategy.ts
Normal file
23
backend/src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.username,
|
||||
tenantId: payload.tenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
45
backend/src/auth/strategies/local.strategy.ts
Normal file
45
backend/src/auth/strategies/local.strategy.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
usernameField: 'username',
|
||||
passwordField: 'password',
|
||||
passReqToCallback: true, // 允许访问request对象
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: any, username: string, password: string): Promise<any> {
|
||||
// 从请求体或请求头获取租户信息
|
||||
const tenantCode = req.body?.tenantCode || req.headers['x-tenant-code'];
|
||||
const tenantId = req.headers['x-tenant-id'];
|
||||
|
||||
let finalTenantId: number | undefined;
|
||||
|
||||
if (tenantId) {
|
||||
finalTenantId = parseInt(tenantId, 10);
|
||||
} else if (tenantCode) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { code: tenantCode },
|
||||
});
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('租户不存在');
|
||||
}
|
||||
finalTenantId = tenant.id;
|
||||
}
|
||||
|
||||
const user = await this.authService.validateUser(username, password, finalTenantId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
89
backend/src/common/filters/http-exception.filter.ts
Normal file
89
backend/src/common/filters/http-exception.filter.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { LogsService } from '../../logs/logs.service';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
constructor(private logsService: LogsService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: 'Internal server error';
|
||||
|
||||
const errorMessage =
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: (message as any).message || 'Error';
|
||||
|
||||
const errorResponse = {
|
||||
code: status,
|
||||
message: errorMessage,
|
||||
data: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
// 记录错误日志(仅记录 500 及以上错误)
|
||||
// 跳过日志接口本身,避免循环记录
|
||||
if (status >= 500 && !request.url.startsWith('/logs')) {
|
||||
const user = (request as any).user;
|
||||
const userId = user?.userId || null;
|
||||
console.error(
|
||||
'[HttpExceptionFilter]',
|
||||
request.method,
|
||||
request.url,
|
||||
userId,
|
||||
exception,
|
||||
);
|
||||
|
||||
// const errorContent = {
|
||||
// status,
|
||||
// message: errorMessage,
|
||||
// method: request.method,
|
||||
// url: request.url,
|
||||
// error: exception instanceof Error ? exception.stack : String(exception),
|
||||
// };
|
||||
// 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB)
|
||||
// const content = this.truncateContent(JSON.stringify(errorContent), 50000);
|
||||
|
||||
// this.logsService
|
||||
// .create({
|
||||
// userId,
|
||||
// action: `ERROR ${request.method} ${request.url}`,
|
||||
// content,
|
||||
// ip: request.ip || '',
|
||||
// userAgent: request.headers['user-agent'] || '',
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.error('Failed to log error:', error);
|
||||
// });
|
||||
}
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
|
||||
// 截断内容,避免超过数据库字段限制
|
||||
private truncateContent(content: string, maxLength: number): string {
|
||||
if (!content || content.length <= maxLength) {
|
||||
return content;
|
||||
}
|
||||
return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)';
|
||||
}
|
||||
}
|
||||
94
backend/src/common/interceptors/logging.interceptor.ts
Normal file
94
backend/src/common/interceptors/logging.interceptor.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Request } from 'express';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { LogsService } from '../../logs/logs.service';
|
||||
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private logsService: LogsService,
|
||||
private reflector: Reflector,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const { method, url, ip, headers } = request;
|
||||
const userAgent = headers['user-agent'] || '';
|
||||
|
||||
// 检查是否为公共接口,公共接口不记录日志
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// 跳过日志接口本身,避免循环记录
|
||||
if (url.startsWith('/logs') || isPublic) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// 获取用户信息(如果已认证)
|
||||
const user = (request as any).user;
|
||||
const userId = user?.userId || null;
|
||||
|
||||
// 构建操作内容
|
||||
const action = `${method} ${url}`;
|
||||
const contentData = {
|
||||
method,
|
||||
url,
|
||||
query: request.query,
|
||||
body: this.sanitizeBody(request.body),
|
||||
};
|
||||
// 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB)
|
||||
console.log('[LoggingInterceptor]', contentData);
|
||||
const content = this.truncateContent(JSON.stringify(contentData), 50000);
|
||||
|
||||
// 异步记录日志,不阻塞请求
|
||||
this.logsService
|
||||
.create({
|
||||
userId,
|
||||
action,
|
||||
content,
|
||||
ip: ip || request.ip || '',
|
||||
userAgent,
|
||||
})
|
||||
.catch((error) => {
|
||||
// 日志记录失败不影响主流程,只打印错误
|
||||
console.error('Failed to log request:', error);
|
||||
});
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// 清理敏感信息(如密码)
|
||||
private sanitizeBody(body: any): any {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return body;
|
||||
}
|
||||
|
||||
const sanitized = { ...body };
|
||||
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'token'];
|
||||
|
||||
sensitiveFields.forEach((field) => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = '***';
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 截断内容,避免超过数据库字段限制
|
||||
private truncateContent(content: string, maxLength: number): string {
|
||||
if (!content || content.length <= maxLength) {
|
||||
return content;
|
||||
}
|
||||
return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)';
|
||||
}
|
||||
}
|
||||
32
backend/src/common/interceptors/transform.interceptor.ts
Normal file
32
backend/src/common/interceptors/transform.interceptor.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
114
backend/src/config/config-verification.controller.ts
Normal file
114
backend/src/config/config-verification.controller.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 配置验证控制器
|
||||
* 用于验证环境配置文件是否正确加载
|
||||
*/
|
||||
@Controller('config-verification')
|
||||
export class ConfigVerificationController {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* 公开接口,用于验证配置加载
|
||||
*/
|
||||
@Public()
|
||||
@Get('env-info')
|
||||
getEnvInfo() {
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const expectedEnvFile = `.env.${nodeEnv}`; // 匹配实际文件名格式:.development.env
|
||||
const envFilePath = path.join(process.cwd(), expectedEnvFile);
|
||||
const fallbackEnvPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// 检查文件是否存在
|
||||
const envFileExists = fs.existsSync(envFilePath);
|
||||
const fallbackExists = fs.existsSync(fallbackEnvPath);
|
||||
|
||||
// 获取一些关键配置(不暴露敏感信息)
|
||||
const config = {
|
||||
nodeEnv,
|
||||
expectedEnvFile,
|
||||
envFileExists,
|
||||
fallbackExists,
|
||||
envFilePath,
|
||||
fallbackEnvPath,
|
||||
loadedFrom: envFileExists
|
||||
? expectedEnvFile
|
||||
: fallbackExists
|
||||
? '.env'
|
||||
: '环境变量',
|
||||
// 显示具体配置信息(包括实际值)
|
||||
configs: {
|
||||
PORT: this.configService.get('PORT') || process.env.PORT || 3001,
|
||||
DATABASE_URL:
|
||||
this.configService.get('DATABASE_URL') ||
|
||||
process.env.DATABASE_URL ||
|
||||
'未配置',
|
||||
JWT_SECRET:
|
||||
this.configService.get('JWT_SECRET') ||
|
||||
process.env.JWT_SECRET ||
|
||||
'未配置',
|
||||
NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv,
|
||||
},
|
||||
publicConfigs: {
|
||||
PORT: this.configService.get('PORT') || process.env.PORT || 3001,
|
||||
NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '配置信息',
|
||||
data: config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要认证的接口,显示更多配置详情(仍隐藏敏感信息)
|
||||
*/
|
||||
@Get('detailed')
|
||||
getDetailedConfig() {
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const expectedEnvFile = `.env.${nodeEnv}`;
|
||||
const envFilePath = path.join(process.cwd(), expectedEnvFile);
|
||||
|
||||
// 读取文件内容(用于验证,但不返回敏感信息)
|
||||
let fileContent = '';
|
||||
try {
|
||||
if (fs.existsSync(envFilePath)) {
|
||||
fileContent = fs.readFileSync(envFilePath, 'utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略读取错误
|
||||
}
|
||||
|
||||
// 统计配置项数量
|
||||
const configKeys = fileContent
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !line.trim().startsWith('#'))
|
||||
.map((line) => line.split('=')[0]?.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '详细配置信息',
|
||||
data: {
|
||||
nodeEnv,
|
||||
expectedEnvFile,
|
||||
fileExists: fs.existsSync(envFilePath),
|
||||
configKeysCount: configKeys.length,
|
||||
configKeys: configKeys, // 只显示键名,不显示值
|
||||
// 验证关键配置是否加载
|
||||
verification: {
|
||||
DATABASE_URL: !!this.configService.get('DATABASE_URL'),
|
||||
JWT_SECRET: !!this.configService.get('JWT_SECRET'),
|
||||
PORT: !!this.configService.get('PORT'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
73
backend/src/config/config.controller.ts
Normal file
73
backend/src/config/config.controller.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from './config.service';
|
||||
import { CreateConfigDto } from './dto/create-config.dto';
|
||||
import { UpdateConfigDto } from './dto/update-config.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ConfigController {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createConfigDto: CreateConfigDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.configService.create(createConfigDto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.configService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('key/:key')
|
||||
findByKey(@Param('key') key: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.configService.findByKey(key, tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.configService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateConfigDto: UpdateConfigDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.configService.update(+id, updateConfigDto, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.configService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
10
backend/src/config/config.module.ts
Normal file
10
backend/src/config/config.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService as SystemConfigService } from './config.service';
|
||||
import { ConfigController } from './config.controller';
|
||||
import { ConfigVerificationController } from './config-verification.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ConfigController, ConfigVerificationController],
|
||||
providers: [SystemConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
88
backend/src/config/config.service.ts
Normal file
88
backend/src/config/config.service.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateConfigDto } from './dto/create-config.dto';
|
||||
import { UpdateConfigDto } from './dto/update-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createConfigDto: CreateConfigDto, tenantId: number) {
|
||||
return this.prisma.config.create({
|
||||
data: {
|
||||
...createConfigDto,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.config.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.config.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const config = await this.prisma.config.findFirst({
|
||||
where,
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
throw new NotFoundException('配置不存在');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async findByKey(key: string, tenantId?: number) {
|
||||
if (!tenantId) {
|
||||
throw new NotFoundException('无法确定租户信息');
|
||||
}
|
||||
|
||||
return this.prisma.config.findFirst({
|
||||
where: {
|
||||
key,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateConfigDto: UpdateConfigDto, tenantId?: number) {
|
||||
// 验证配置是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.config.update({
|
||||
where: { id },
|
||||
data: updateConfigDto,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证配置是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.config.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
13
backend/src/config/dto/create-config.dto.ts
Normal file
13
backend/src/config/dto/create-config.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateConfigDto {
|
||||
@IsString()
|
||||
key: string;
|
||||
|
||||
@IsString()
|
||||
value: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
15
backend/src/config/dto/update-config.dto.ts
Normal file
15
backend/src/config/dto/update-config.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateConfigDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
key?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
value?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
73
backend/src/dict/dict.controller.ts
Normal file
73
backend/src/dict/dict.controller.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { DictService } from './dict.service';
|
||||
import { CreateDictDto } from './dto/create-dict.dto';
|
||||
import { UpdateDictDto } from './dto/update-dict.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('dict')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DictController {
|
||||
constructor(private readonly dictService: DictService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createDictDto: CreateDictDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.dictService.create(createDictDto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.dictService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('code/:code')
|
||||
findByCode(@Param('code') code: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.dictService.findByCode(code, tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.dictService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDictDto: UpdateDictDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.dictService.update(+id, updateDictDto, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.dictService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
9
backend/src/dict/dict.module.ts
Normal file
9
backend/src/dict/dict.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DictService } from './dict.service';
|
||||
import { DictController } from './dict.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [DictController],
|
||||
providers: [DictService],
|
||||
})
|
||||
export class DictModule {}
|
||||
112
backend/src/dict/dict.service.ts
Normal file
112
backend/src/dict/dict.service.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateDictDto } from './dto/create-dict.dto';
|
||||
import { UpdateDictDto } from './dto/update-dict.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DictService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createDictDto: CreateDictDto, tenantId: number) {
|
||||
return this.prisma.dict.create({
|
||||
data: {
|
||||
...createDictDto,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.dict.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
items: {
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.dict.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const dict = await this.prisma.dict.findFirst({
|
||||
where,
|
||||
include: {
|
||||
items: {
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dict) {
|
||||
throw new NotFoundException('字典不存在');
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
async findByCode(code: string, tenantId?: number) {
|
||||
if (!tenantId) {
|
||||
throw new NotFoundException('无法确定租户信息');
|
||||
}
|
||||
|
||||
return this.prisma.dict.findFirst({
|
||||
where: {
|
||||
code,
|
||||
tenantId,
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateDictDto: UpdateDictDto, tenantId?: number) {
|
||||
// 验证字典是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.dict.update({
|
||||
where: { id },
|
||||
data: updateDictDto,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证字典是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.dict.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
13
backend/src/dict/dto/create-dict.dto.ts
Normal file
13
backend/src/dict/dto/create-dict.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateDictDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
15
backend/src/dict/dto/update-dict.dto.ts
Normal file
15
backend/src/dict/dto/update-dict.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateDictDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
35
backend/src/main.ts
Normal file
35
backend/src/main.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// 验证环境配置加载
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
const port = configService.get('PORT') || process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
console.log(`Application is running on: http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
30
backend/src/menus/dto/create-menu.dto.ts
Normal file
30
backend/src/menus/dto/create-menu.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional, IsInt, IsNumber } from 'class-validator';
|
||||
|
||||
export class CreateMenuDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
path?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
icon?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
component?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
parentId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
permission?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
sort?: number;
|
||||
}
|
||||
31
backend/src/menus/dto/update-menu.dto.ts
Normal file
31
backend/src/menus/dto/update-menu.dto.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IsString, IsOptional, IsInt, IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateMenuDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
path?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
icon?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
component?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
parentId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
permission?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
sort?: number;
|
||||
}
|
||||
55
backend/src/menus/menus.controller.ts
Normal file
55
backend/src/menus/menus.controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { MenusService } from './menus.service';
|
||||
import { CreateMenuDto } from './dto/create-menu.dto';
|
||||
import { UpdateMenuDto } from './dto/update-menu.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('menus')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MenusController {
|
||||
constructor(private readonly menusService: MenusService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createMenuDto: CreateMenuDto) {
|
||||
return this.menusService.create(createMenuDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.menusService.findAll();
|
||||
}
|
||||
|
||||
@Get('user-menus')
|
||||
getUserMenus(@Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.menusService.findUserMenus(req.user.userId, tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.menusService.findOne(+id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateMenuDto: UpdateMenuDto) {
|
||||
return this.menusService.update(+id, updateMenuDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.menusService.remove(+id);
|
||||
}
|
||||
}
|
||||
11
backend/src/menus/menus.module.ts
Normal file
11
backend/src/menus/menus.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MenusService } from './menus.service';
|
||||
import { MenusController } from './menus.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [MenusController],
|
||||
providers: [MenusService],
|
||||
})
|
||||
export class MenusModule {}
|
||||
174
backend/src/menus/menus.service.ts
Normal file
174
backend/src/menus/menus.service.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateMenuDto } from './dto/create-menu.dto';
|
||||
import { UpdateMenuDto } from './dto/update-menu.dto';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class MenusService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async create(createMenuDto: CreateMenuDto) {
|
||||
return this.prisma.menu.create({
|
||||
data: createMenuDto,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.menu.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const menu = await this.prisma.menu.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
children: true,
|
||||
parent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!menu) {
|
||||
throw new NotFoundException('菜单不存在');
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
async update(id: number, updateMenuDto: UpdateMenuDto) {
|
||||
return this.prisma.menu.update({
|
||||
where: { id },
|
||||
data: updateMenuDto,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
return this.prisma.menu.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的菜单(根据权限过滤)
|
||||
* @param userId 用户ID
|
||||
* @param tenantId 租户ID
|
||||
* @returns 过滤后的菜单树
|
||||
*/
|
||||
async findUserMenus(userId: number, tenantId: number) {
|
||||
// 获取用户的所有权限
|
||||
const userPermissions = await this.authService.getUserPermissions(userId);
|
||||
|
||||
// 获取租户分配的菜单ID
|
||||
const tenantMenus = await this.prisma.tenantMenu.findMany({
|
||||
where: { tenantId },
|
||||
});
|
||||
const menuIds = tenantMenus.map((tm) => tm.menuId);
|
||||
|
||||
if (menuIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取租户分配的所有菜单(包括父菜单)
|
||||
const allMenus = await this.prisma.menu.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: { in: menuIds } },
|
||||
{ children: { some: { id: { in: menuIds } } } },
|
||||
],
|
||||
validState: 1, // 只获取有效的菜单
|
||||
},
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (menus: any[], parentId: number | null = null): any[] => {
|
||||
return menus
|
||||
.filter((menu) => menu.parentId === parentId)
|
||||
.map((menu) => ({
|
||||
...menu,
|
||||
children: buildTree(menus, menu.id),
|
||||
}));
|
||||
};
|
||||
|
||||
// 先构建树
|
||||
const menuTree = buildTree(allMenus);
|
||||
|
||||
// 过滤菜单:如果菜单有permission字段,检查用户是否有该权限;如果没有permission字段,则显示
|
||||
const filterMenus = (menus: any[]): any[] => {
|
||||
return menus
|
||||
.filter((menu) => {
|
||||
// 如果菜单没有设置权限要求,则显示
|
||||
if (!menu.permission) {
|
||||
return true;
|
||||
}
|
||||
// 如果设置了权限要求,检查用户是否有该权限
|
||||
return userPermissions.includes(menu.permission);
|
||||
})
|
||||
.map((menu) => {
|
||||
const filtered = { ...menu };
|
||||
// 递归过滤子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
filtered.children = filterMenus(menu.children);
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
};
|
||||
|
||||
// 过滤菜单树
|
||||
const filteredTree = filterMenus(menuTree);
|
||||
|
||||
// 移除没有子菜单且没有path的父菜单(空菜单)
|
||||
const removeEmptyParents = (menus: any[]): any[] => {
|
||||
return menus
|
||||
.map((menu) => {
|
||||
const hasChildren = menu.children && menu.children.length > 0;
|
||||
const hasPath = menu.path && menu.path.trim() !== '';
|
||||
|
||||
// 如果有子菜单,递归处理
|
||||
if (hasChildren) {
|
||||
const processedChildren = removeEmptyParents(menu.children);
|
||||
// 如果处理后还有子菜单,保留此菜单
|
||||
if (processedChildren.length > 0) {
|
||||
return {
|
||||
...menu,
|
||||
children: processedChildren,
|
||||
};
|
||||
}
|
||||
// 如果处理后没有子菜单,但有path,保留此菜单(作为叶子节点)
|
||||
if (hasPath) {
|
||||
return {
|
||||
...menu,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
// 既没有子菜单也没有path,移除
|
||||
return null;
|
||||
}
|
||||
|
||||
// 叶子节点,保留
|
||||
return menu;
|
||||
})
|
||||
.filter((menu) => menu !== null);
|
||||
};
|
||||
|
||||
return removeEmptyParents(filteredTree);
|
||||
}
|
||||
}
|
||||
20
backend/src/permissions/dto/create-permission.dto.ts
Normal file
20
backend/src/permissions/dto/create-permission.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreatePermissionDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
resource: string;
|
||||
|
||||
@IsString()
|
||||
action: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
24
backend/src/permissions/dto/update-permission.dto.ts
Normal file
24
backend/src/permissions/dto/update-permission.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdatePermissionDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
resource?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
action?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
71
backend/src/permissions/permissions.controller.ts
Normal file
71
backend/src/permissions/permissions.controller.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
||||
import { UpdatePermissionDto } from './dto/update-permission.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@Controller('permissions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PermissionsController {
|
||||
constructor(private readonly permissionsService: PermissionsService) {}
|
||||
|
||||
@Post()
|
||||
@Roles('super_admin')
|
||||
create(@Body() createPermissionDto: CreatePermissionDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.permissionsService.create(createPermissionDto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.permissionsService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.permissionsService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('super_admin')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updatePermissionDto: UpdatePermissionDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.permissionsService.update(+id, updatePermissionDto, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('super_admin')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.permissionsService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
11
backend/src/permissions/permissions.module.ts
Normal file
11
backend/src/permissions/permissions.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import { PermissionsController } from './permissions.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PermissionsController],
|
||||
providers: [PermissionsService],
|
||||
exports: [PermissionsService],
|
||||
})
|
||||
export class PermissionsModule {}
|
||||
|
||||
79
backend/src/permissions/permissions.service.ts
Normal file
79
backend/src/permissions/permissions.service.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
||||
import { UpdatePermissionDto } from './dto/update-permission.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createPermissionDto: CreatePermissionDto, tenantId: number) {
|
||||
return this.prisma.permission.create({
|
||||
data: {
|
||||
...createPermissionDto,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.permission.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {
|
||||
createTime: 'desc',
|
||||
},
|
||||
}),
|
||||
this.prisma.permission.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const permission = await this.prisma.permission.findFirst({
|
||||
where,
|
||||
});
|
||||
|
||||
if (!permission) {
|
||||
throw new NotFoundException('权限不存在');
|
||||
}
|
||||
|
||||
return permission;
|
||||
}
|
||||
|
||||
async update(id: number, updatePermissionDto: UpdatePermissionDto, tenantId?: number) {
|
||||
// 检查权限是否存在
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.permission.update({
|
||||
where: { id },
|
||||
data: updatePermissionDto,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 检查权限是否存在
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.permission.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
backend/src/prisma/prisma.service.ts
Normal file
16
backend/src/prisma/prisma.service.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
18
backend/src/roles/dto/create-role.dto.ts
Normal file
18
backend/src/roles/dto/create-role.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber } from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
permissionIds?: number[];
|
||||
}
|
||||
20
backend/src/roles/dto/update-role.dto.ts
Normal file
20
backend/src/roles/dto/update-role.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
permissionIds?: number[];
|
||||
}
|
||||
67
backend/src/roles/roles.controller.ts
Normal file
67
backend/src/roles/roles.controller.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { RolesService } from './roles.service';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('roles')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RolesController {
|
||||
constructor(private readonly rolesService: RolesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createRoleDto: CreateRoleDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.rolesService.create(createRoleDto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.rolesService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.rolesService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateRoleDto: UpdateRoleDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.rolesService.update(+id, updateRoleDto, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.rolesService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
10
backend/src/roles/roles.module.ts
Normal file
10
backend/src/roles/roles.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesService } from './roles.service';
|
||||
import { RolesController } from './roles.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController],
|
||||
providers: [RolesService],
|
||||
exports: [RolesService],
|
||||
})
|
||||
export class RolesModule {}
|
||||
158
backend/src/roles/roles.service.ts
Normal file
158
backend/src/roles/roles.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createRoleDto: CreateRoleDto, tenantId: number) {
|
||||
const { permissionIds, ...roleData } = createRoleDto;
|
||||
|
||||
// 验证权限是否属于该租户
|
||||
if (permissionIds && permissionIds.length > 0) {
|
||||
const permissions = await this.prisma.permission.findMany({
|
||||
where: {
|
||||
id: { in: permissionIds },
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (permissions.length !== permissionIds.length) {
|
||||
throw new NotFoundException('部分权限不存在或不属于该租户');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.role.create({
|
||||
data: {
|
||||
...roleData,
|
||||
tenantId,
|
||||
permissions:
|
||||
permissionIds && permissionIds.length > 0
|
||||
? {
|
||||
create: permissionIds.map((permissionId) => ({
|
||||
permissionId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.role.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.role.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const role = await this.prisma.role.findFirst({
|
||||
where,
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException('角色不存在');
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async update(id: number, updateRoleDto: UpdateRoleDto, tenantId?: number) {
|
||||
const { permissionIds, ...roleData } = updateRoleDto;
|
||||
|
||||
// 验证角色是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
const data: any = { ...roleData };
|
||||
|
||||
// 如果提供了 permissionIds,更新权限关联
|
||||
if (permissionIds !== undefined && tenantId) {
|
||||
// 验证权限是否属于该租户
|
||||
const permissions = await this.prisma.permission.findMany({
|
||||
where: {
|
||||
id: { in: permissionIds },
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (permissions.length !== permissionIds.length) {
|
||||
throw new NotFoundException('部分权限不存在或不属于该租户');
|
||||
}
|
||||
|
||||
// 先删除所有现有权限关联
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId: id },
|
||||
});
|
||||
|
||||
// 创建新的权限关联
|
||||
if (permissionIds.length > 0) {
|
||||
data.permissions = {
|
||||
create: permissionIds.map((permissionId) => ({
|
||||
permissionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.role.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证角色是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.role.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
16
backend/src/tenants/decorators/tenant.decorator.ts
Normal file
16
backend/src/tenants/decorators/tenant.decorator.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const Tenant = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.tenant;
|
||||
},
|
||||
);
|
||||
|
||||
export const TenantId = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.tenantId;
|
||||
},
|
||||
);
|
||||
|
||||
31
backend/src/tenants/dto/create-tenant.dto.ts
Normal file
31
backend/src/tenants/dto/create-tenant.dto.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string; // 租户编码,用于访问链接
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
domain?: string; // 租户域名(可选)
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
menuIds?: number[]; // 分配的菜单ID列表
|
||||
}
|
||||
|
||||
31
backend/src/tenants/dto/update-tenant.dto.ts
Normal file
31
backend/src/tenants/dto/update-tenant.dto.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateTenantDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
domain?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
@IsOptional()
|
||||
validState?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
menuIds?: number[]; // 分配的菜单ID列表
|
||||
}
|
||||
|
||||
89
backend/src/tenants/guards/tenant.guard.ts
Normal file
89
backend/src/tenants/guards/tenant.guard.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export const TENANT_KEY = 'tenant';
|
||||
export const SetTenant = () => Reflector.createDecorator<boolean>();
|
||||
|
||||
@Injectable()
|
||||
export class TenantGuard implements CanActivate {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// 从请求头获取租户信息
|
||||
const tenantCode = request.headers['x-tenant-code'];
|
||||
const tenantId = request.headers['x-tenant-id'];
|
||||
const host = request.headers['host'];
|
||||
|
||||
let tenantIdValue: number | null = null;
|
||||
|
||||
// 方式1: 从请求头获取租户ID
|
||||
if (tenantId) {
|
||||
tenantIdValue = parseInt(tenantId, 10);
|
||||
}
|
||||
// 方式2: 从请求头获取租户编码
|
||||
else if (tenantCode) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { code: tenantCode },
|
||||
});
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('租户不存在');
|
||||
}
|
||||
tenantIdValue = tenant.id;
|
||||
}
|
||||
// 方式3: 从子域名获取租户(如果配置了domain)
|
||||
else if (host) {
|
||||
const subdomain = host.split('.')[0];
|
||||
if (subdomain && subdomain !== 'www' && subdomain !== 'localhost') {
|
||||
const tenant = await this.prisma.tenant.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ code: subdomain },
|
||||
{ domain: subdomain },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (tenant) {
|
||||
tenantIdValue = tenant.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 方式4: 从JWT token中获取(如果用户已登录)
|
||||
else if (request.user?.tenantId) {
|
||||
tenantIdValue = request.user.tenantId;
|
||||
}
|
||||
|
||||
// 如果找到了租户,验证租户是否有效
|
||||
if (tenantIdValue) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: tenantIdValue },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('租户不存在');
|
||||
}
|
||||
|
||||
if (tenant.validState !== 1) {
|
||||
throw new BadRequestException('租户已失效');
|
||||
}
|
||||
|
||||
// 将租户信息附加到请求对象
|
||||
request.tenant = tenant;
|
||||
request.tenantId = tenantIdValue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
79
backend/src/tenants/tenants.controller.ts
Normal file
79
backend/src/tenants/tenants.controller.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../auth/guards/permissions.guard';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('tenants')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenantsService: TenantsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('tenant:create')
|
||||
create(@Body() createTenantDto: CreateTenantDto, @Request() req) {
|
||||
const userId = req.user?.userId;
|
||||
const currentTenantId = req.user?.tenantId;
|
||||
return this.tenantsService.create(createTenantDto, userId, currentTenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('tenant:read')
|
||||
findAll(
|
||||
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
|
||||
@Query('pageSize', new ParseIntPipe({ optional: true }))
|
||||
pageSize: number = 10,
|
||||
) {
|
||||
return this.tenantsService.findAll(page, pageSize);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('tenant:read')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.tenantsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('tenant:update')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateTenantDto: UpdateTenantDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const userId = req.user?.userId;
|
||||
const currentTenantId = req.user?.tenantId;
|
||||
return this.tenantsService.update(
|
||||
id,
|
||||
updateTenantDto,
|
||||
userId,
|
||||
currentTenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('tenant:delete')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const currentTenantId = req.user?.tenantId;
|
||||
return this.tenantsService.remove(id, currentTenantId);
|
||||
}
|
||||
|
||||
@Get(':id/menus')
|
||||
@RequirePermission('tenant:read')
|
||||
getTenantMenus(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.tenantsService.getTenantMenus(id);
|
||||
}
|
||||
}
|
||||
13
backend/src/tenants/tenants.module.ts
Normal file
13
backend/src/tenants/tenants.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
exports: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
343
backend/src/tenants/tenants.service.ts
Normal file
343
backend/src/tenants/tenants.service.ts
Normal file
@ -0,0 +1,343 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 检查当前用户所属租户是否为超级租户
|
||||
*/
|
||||
private async checkSuperTenant(currentTenantId?: number): Promise<void> {
|
||||
if (!currentTenantId) {
|
||||
throw new ForbiddenException('无法确定当前租户信息');
|
||||
}
|
||||
|
||||
const currentTenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: currentTenantId },
|
||||
});
|
||||
|
||||
if (!currentTenant) {
|
||||
throw new ForbiddenException('当前租户不存在');
|
||||
}
|
||||
|
||||
if (currentTenant.isSuper !== 1) {
|
||||
throw new ForbiddenException('只有超级租户才能操作租户管理');
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
createTenantDto: CreateTenantDto,
|
||||
creatorId?: number,
|
||||
currentTenantId?: number,
|
||||
) {
|
||||
// 检查是否为超级租户
|
||||
await this.checkSuperTenant(currentTenantId);
|
||||
|
||||
const { menuIds, ...tenantData } = createTenantDto;
|
||||
|
||||
// 检查租户编码是否已存在
|
||||
const existingTenant = await this.prisma.tenant.findUnique({
|
||||
where: { code: tenantData.code },
|
||||
});
|
||||
if (existingTenant) {
|
||||
throw new BadRequestException('租户编码已存在');
|
||||
}
|
||||
|
||||
// 如果提供了域名,检查域名是否已存在
|
||||
if (tenantData.domain) {
|
||||
const existingDomain = await this.prisma.tenant.findUnique({
|
||||
where: { domain: tenantData.domain },
|
||||
});
|
||||
if (existingDomain) {
|
||||
throw new BadRequestException('租户域名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.tenant.create({
|
||||
data: {
|
||||
...tenantData,
|
||||
creator: creatorId,
|
||||
menus:
|
||||
menuIds && menuIds.length > 0
|
||||
? {
|
||||
create: menuIds.map((menuId) => ({
|
||||
menuId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.tenant.findMany({
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
users: true,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createTime: 'desc',
|
||||
},
|
||||
}),
|
||||
this.prisma.tenant.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
users: true,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('租户不存在');
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
async findByCode(code: string) {
|
||||
return this.prisma.tenant.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByDomain(domain: string) {
|
||||
return this.prisma.tenant.findUnique({
|
||||
where: { domain },
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
updateTenantDto: UpdateTenantDto,
|
||||
modifierId?: number,
|
||||
currentTenantId?: number,
|
||||
) {
|
||||
// 检查是否为超级租户
|
||||
await this.checkSuperTenant(currentTenantId);
|
||||
|
||||
const { menuIds, ...tenantData } = updateTenantDto;
|
||||
|
||||
// 检查租户是否存在
|
||||
await this.findOne(id);
|
||||
|
||||
// 如果更新了code,检查是否冲突
|
||||
if (tenantData.code) {
|
||||
const existingTenant = await this.prisma.tenant.findFirst({
|
||||
where: {
|
||||
code: tenantData.code,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
if (existingTenant) {
|
||||
throw new BadRequestException('租户编码已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新了domain,检查是否冲突
|
||||
if (tenantData.domain) {
|
||||
const existingDomain = await this.prisma.tenant.findFirst({
|
||||
where: {
|
||||
domain: tenantData.domain,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
if (existingDomain) {
|
||||
throw new BadRequestException('租户域名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
...tenantData,
|
||||
modifier: modifierId,
|
||||
};
|
||||
|
||||
// 如果提供了 menuIds,更新菜单关联
|
||||
if (menuIds !== undefined) {
|
||||
// 先删除所有现有菜单关联
|
||||
await this.prisma.tenantMenu.deleteMany({
|
||||
where: { tenantId: id },
|
||||
});
|
||||
|
||||
// 创建新的菜单关联
|
||||
if (menuIds.length > 0) {
|
||||
data.menus = {
|
||||
create: menuIds.map((menuId) => ({
|
||||
menuId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, currentTenantId?: number) {
|
||||
// 检查是否为超级租户
|
||||
await this.checkSuperTenant(currentTenantId);
|
||||
|
||||
// 检查租户是否存在
|
||||
await this.findOne(id);
|
||||
|
||||
// 检查要删除的租户是否为超级租户
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (tenant?.isSuper === 1) {
|
||||
throw new BadRequestException('不能删除超级租户');
|
||||
}
|
||||
|
||||
return this.prisma.tenant.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户的菜单树(根据租户分配的菜单)
|
||||
*/
|
||||
async getTenantMenus(tenantId: number) {
|
||||
const tenant = await this.findOne(tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('租户不存在');
|
||||
}
|
||||
|
||||
// 获取租户分配的所有菜单ID
|
||||
const tenantMenus = await this.prisma.tenantMenu.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
});
|
||||
|
||||
const menuIds = tenantMenus.map((tm) => tm.menuId);
|
||||
|
||||
if (menuIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取所有菜单(包括父菜单,因为子菜单可能被分配)
|
||||
const allMenus = await this.prisma.menu.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: { in: menuIds } },
|
||||
{ children: { some: { id: { in: menuIds } } } },
|
||||
],
|
||||
validState: 1,
|
||||
},
|
||||
orderBy: {
|
||||
sort: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (menus: any[], parentId: number | null = null): any[] => {
|
||||
return menus
|
||||
.filter((menu) => menu.parentId === parentId)
|
||||
.map((menu) => ({
|
||||
...menu,
|
||||
children: buildTree(menus, menu.id),
|
||||
}));
|
||||
};
|
||||
|
||||
const menuTree = buildTree(allMenus);
|
||||
|
||||
// 过滤:只保留被分配的菜单及其父菜单
|
||||
const filterMenus = (menus: any[]): any[] => {
|
||||
return menus
|
||||
.filter((menu) => {
|
||||
// 如果菜单被分配,保留
|
||||
if (menuIds.includes(menu.id)) {
|
||||
return true;
|
||||
}
|
||||
// 如果有子菜单被分配,保留
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const filteredChildren = filterMenus(menu.children);
|
||||
return filteredChildren.length > 0;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((menu) => {
|
||||
const filtered = { ...menu };
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
filtered.children = filterMenus(menu.children);
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
};
|
||||
|
||||
return filterMenus(menuTree);
|
||||
}
|
||||
}
|
||||
31
backend/src/users/dto/create-user.dto.ts
Normal file
31
backend/src/users/dto/create-user.dto.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
nickname: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
roleIds?: number[];
|
||||
}
|
||||
28
backend/src/users/dto/update-user.dto.ts
Normal file
28
backend/src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { IsString, IsEmail, IsOptional, IsArray, IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
nickname?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@IsOptional()
|
||||
roleIds?: number[];
|
||||
}
|
||||
68
backend/src/users/users.controller.ts
Normal file
68
backend/src/users/users.controller.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TenantId } from '../tenants/decorators/tenant.decorator';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createUserDto: CreateUserDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.usersService.create(createUserDto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.usersService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.usersService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.usersService.update(+id, updateUserDto, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.usersService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
10
backend/src/users/users.module.ts
Normal file
10
backend/src/users/users.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
198
backend/src/users/users.service.ts
Normal file
198
backend/src/users/users.service.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createUserDto: CreateUserDto, tenantId: number) {
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
const { roleIds, ...userData } = createUserDto;
|
||||
|
||||
// 验证角色是否属于该租户
|
||||
if (roleIds && roleIds.length > 0) {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: {
|
||||
id: { in: roleIds },
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (roles.length !== roleIds.length) {
|
||||
throw new NotFoundException('部分角色不存在或不属于该租户');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...userData,
|
||||
tenantId,
|
||||
password: hashedPassword,
|
||||
roles:
|
||||
roleIds && roleIds.length > 0
|
||||
? {
|
||||
create: roleIds.map((roleId) => ({
|
||||
roleId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId } : {};
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByUsername(username: string, tenantId?: number) {
|
||||
const where: any = { username };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
return this.prisma.user.findFirst({
|
||||
where,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, updateUserDto: UpdateUserDto, tenantId?: number) {
|
||||
const { roleIds, ...userData } = updateUserDto;
|
||||
const data: any = { ...userData };
|
||||
|
||||
// 验证用户是否存在且属于该租户
|
||||
const existingUser = await this.findOne(id, tenantId);
|
||||
|
||||
if (updateUserDto.password) {
|
||||
data.password = await bcrypt.hash(updateUserDto.password, 10);
|
||||
}
|
||||
|
||||
// 如果提供了 roleIds,更新角色关联
|
||||
if (roleIds !== undefined && tenantId) {
|
||||
// 验证角色是否属于该租户
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: {
|
||||
id: { in: roleIds },
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (roles.length !== roleIds.length) {
|
||||
throw new NotFoundException('部分角色不存在或不属于该租户');
|
||||
}
|
||||
|
||||
// 先删除所有现有角色关联
|
||||
await this.prisma.userRole.deleteMany({
|
||||
where: { userId: id },
|
||||
});
|
||||
|
||||
// 创建新的角色关联
|
||||
if (roleIds.length > 0) {
|
||||
data.roles = {
|
||||
create: roleIds.map((roleId) => ({
|
||||
roleId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证用户是否存在且属于该租户
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
return this.prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
749
docs/CONTEST_MANAGEMENT_PLAN.md
Normal file
749
docs/CONTEST_MANAGEMENT_PLAN.md
Normal file
@ -0,0 +1,749 @@
|
||||
# 赛事管理模块产品方案与实现计划
|
||||
|
||||
## 📋 目录
|
||||
1. [产品交互方案](#产品交互方案)
|
||||
2. [数据库设计分析](#数据库设计分析)
|
||||
3. [功能模块划分](#功能模块划分)
|
||||
4. [实现计划](#实现计划)
|
||||
5. [技术实现要点](#技术实现要点)
|
||||
|
||||
---
|
||||
|
||||
## 产品交互方案
|
||||
|
||||
### 1. 赛事创建
|
||||
|
||||
#### 1.1 功能概述
|
||||
管理员创建赛事,填写赛事基本信息、时间安排、参赛范围等。
|
||||
|
||||
#### 1.2 交互流程
|
||||
```
|
||||
管理员进入"赛事管理" → 点击"创建赛事" → 填写表单 → 保存草稿/提交审核
|
||||
```
|
||||
|
||||
#### 1.3 表单字段(基于 t_contest 表)
|
||||
- **基本信息**
|
||||
- 赛事名称(必填,唯一性校验)
|
||||
- 赛事类型(字典:individual/team)
|
||||
- 赛事状态(默认:unpublished)
|
||||
- 封面图(上传)
|
||||
- 海报图(上传)
|
||||
- 赛事详情(富文本编辑器)
|
||||
|
||||
- **时间安排**
|
||||
- 赛事开始时间
|
||||
- 赛事结束时间
|
||||
- 报名开始时间
|
||||
- 报名结束时间
|
||||
- 作品提交开始时间
|
||||
- 作品提交结束时间
|
||||
- 评审开始时间
|
||||
- 评审结束时间
|
||||
- 结果发布时间(可选)
|
||||
|
||||
- **参赛范围**
|
||||
- 授权租户(多选,支持租户列表选择)
|
||||
- 提交规则(once/resubmit)
|
||||
|
||||
- **联系信息**
|
||||
- 联系人姓名
|
||||
- 联系电话
|
||||
- 联系人二维码(上传)
|
||||
|
||||
- **组织信息**
|
||||
- 主办单位(数组)
|
||||
- 协办单位(数组)
|
||||
- 赞助单位(数组)
|
||||
|
||||
- **线下信息**
|
||||
- 线下地址(可选)
|
||||
|
||||
- **评审规则**
|
||||
- 评审规则ID(关联评审规则配置)
|
||||
|
||||
#### 1.4 业务规则
|
||||
- 时间顺序校验:报名开始 < 报名结束 < 提交开始 < 提交结束 < 评审开始 < 评审结束 < 结果发布
|
||||
- 赛事名称在系统内唯一
|
||||
- 创建后状态为 `unpublished`,需要发布后才能被租户看到
|
||||
- 支持保存草稿(可多次编辑)
|
||||
|
||||
---
|
||||
|
||||
### 2. 赛事发布
|
||||
|
||||
#### 2.1 功能概述
|
||||
管理员将已创建的赛事发布,使其对授权租户可见。
|
||||
|
||||
#### 2.2 交互流程
|
||||
```
|
||||
赛事列表 → 选择赛事 → 点击"发布" → 确认发布 → 更新状态为 published
|
||||
```
|
||||
|
||||
#### 2.3 业务规则
|
||||
- 只有状态为 `unpublished` 的赛事可以发布
|
||||
- 发布前校验必填字段完整性
|
||||
- 发布后,授权租户可以看到该赛事
|
||||
- 已发布的赛事可以撤回(状态改回 `unpublished`),但需要检查是否有报名记录
|
||||
|
||||
---
|
||||
|
||||
### 3. 赛事公告
|
||||
|
||||
#### 3.1 功能概述
|
||||
管理员发布赛事相关公告,通知参赛者重要信息。
|
||||
|
||||
#### 3.2 交互流程
|
||||
```
|
||||
赛事详情页 → "公告管理" → 创建公告 → 编辑内容 → 发布公告
|
||||
```
|
||||
|
||||
#### 3.3 功能设计
|
||||
**注意**:当前 SQL 中没有公告表,需要新增:
|
||||
- `t_contest_notice` 表
|
||||
- id, contest_id, title, content, notice_type, priority, publish_time, creator, create_time, modify_time, valid_state
|
||||
|
||||
#### 3.4 公告类型
|
||||
- 系统公告(系统自动生成)
|
||||
- 人工公告(管理员发布)
|
||||
- 紧急通知(高优先级)
|
||||
|
||||
---
|
||||
|
||||
### 4. 赛事报名
|
||||
|
||||
#### 4.1 功能概述
|
||||
授权租户的用户(学生/老师)报名参加赛事,支持个人赛和团队赛。
|
||||
|
||||
#### 4.2 交互流程
|
||||
|
||||
**个人赛报名:**
|
||||
```
|
||||
租户用户登录 → 浏览已发布赛事 → 选择赛事 → 点击"立即报名" → 填写信息 → 提交报名
|
||||
```
|
||||
|
||||
**团队赛报名:**
|
||||
```
|
||||
队长创建团队 → 邀请成员 → 成员确认加入 → 队长提交团队报名 → 等待审核
|
||||
```
|
||||
|
||||
#### 4.3 报名流程详细设计
|
||||
|
||||
**4.3.1 个人赛报名**
|
||||
1. 用户选择赛事
|
||||
2. 检查报名时间是否在有效期内
|
||||
3. 检查用户是否已报名(防止重复报名)
|
||||
4. 填写报名信息(账号信息自动填充)
|
||||
5. 提交报名(状态:pending)
|
||||
6. 管理员审核(可选,根据赛事配置)
|
||||
7. 审核通过(状态:passed)或拒绝(状态:rejected)
|
||||
|
||||
**4.3.2 团队赛报名**
|
||||
1. 队长创建团队
|
||||
- 填写团队名称(租户内唯一)
|
||||
- 设置最大成员数
|
||||
- 邀请成员(通过账号搜索)
|
||||
2. 成员确认加入
|
||||
- 收到邀请通知
|
||||
- 确认/拒绝加入
|
||||
3. 队长提交团队报名
|
||||
- 检查团队成员数量是否符合要求
|
||||
- 提交报名(所有成员状态:pending)
|
||||
4. 管理员审核团队报名
|
||||
- 审核通过:所有成员状态改为 passed
|
||||
- 审核拒绝:所有成员状态改为 rejected
|
||||
|
||||
#### 4.4 数据表关系
|
||||
- `t_contest_registration`:报名记录表
|
||||
- 个人赛:registration_type = 'individual',team_id = null
|
||||
- 团队赛:registration_type = 'team',team_id 关联 t_contest_team
|
||||
- `t_contest_team`:团队表
|
||||
- `t_contest_team_member`:团队成员表
|
||||
|
||||
#### 4.5 业务规则
|
||||
- 报名时间限制:必须在 `register_start_time` 和 `register_end_time` 之间
|
||||
- 报名状态流转:pending → passed/rejected/withdrawn
|
||||
- 已通过的报名可以撤回(withdrawn),但需要检查是否已提交作品
|
||||
- 团队名称在同一赛事、同一租户内唯一
|
||||
- 团队成员角色:leader(队长)、member(队员)、mentor(指导教师)
|
||||
|
||||
---
|
||||
|
||||
### 5. 赛事作品提交
|
||||
|
||||
#### 5.1 功能概述
|
||||
已报名的用户提交参赛作品,支持单次提交和多次提交(根据赛事配置)。
|
||||
|
||||
#### 5.2 交互流程
|
||||
```
|
||||
已报名用户 → 进入"我的赛事" → 选择赛事 → 点击"提交作品" → 上传作品文件 → 填写作品信息 → 提交
|
||||
```
|
||||
|
||||
#### 5.3 作品提交表单
|
||||
- 作品标题(必填)
|
||||
- 作品说明(可选)
|
||||
- 作品文件(支持多文件上传)
|
||||
- 图片、视频、3D模型等
|
||||
- 文件类型和大小限制
|
||||
- 作品预览URL(可选,用于3D/视频预览)
|
||||
- AI建模元数据(可选,JSON格式)
|
||||
|
||||
#### 5.4 提交规则
|
||||
|
||||
**单次提交(submit_rule = 'once'):**
|
||||
- 只能提交一次作品
|
||||
- 提交后状态为 `submitted`,不可修改
|
||||
|
||||
**多次提交(submit_rule = 'resubmit'):**
|
||||
- 可以多次提交作品
|
||||
- 每次提交创建新版本(version 递增)
|
||||
- 旧版本 `is_latest = 0`,新版本 `is_latest = 1`
|
||||
- 只有最新版本参与评审
|
||||
|
||||
#### 5.5 数据表关系
|
||||
- `t_contest_work`:作品主表
|
||||
- entry_id 关联 t_contest_registration.id
|
||||
- files 字段存储简易文件列表(JSON)
|
||||
- `t_contest_work_attachment`:作品附件表(详细文件信息)
|
||||
|
||||
#### 5.6 业务规则
|
||||
- 提交时间限制:必须在 `submit_start_time` 和 `submit_end_time` 之间
|
||||
- 必须已通过报名审核(registration_state = 'passed')
|
||||
- 作品编号(work_no)自动生成,格式:CONTEST-{contest_id}-{序号}
|
||||
- 作品状态流转:
|
||||
- submitted(已提交)
|
||||
- locked(已锁定,不可修改)
|
||||
- reviewing(评审中)
|
||||
- rejected(已拒绝)
|
||||
- accepted(已接受)
|
||||
|
||||
---
|
||||
|
||||
### 6. 赛事作品评审
|
||||
|
||||
#### 6.1 功能概述
|
||||
评委对提交的作品进行评分,支持多维度评分和评语。
|
||||
|
||||
#### 6.2 交互流程
|
||||
```
|
||||
评委登录 → 进入"评审管理" → 选择赛事 → 查看分配的作品列表 → 点击作品 → 查看作品详情 → 评分 → 提交评分
|
||||
```
|
||||
|
||||
#### 6.3 评审流程设计
|
||||
|
||||
**6.3.1 作品分配**
|
||||
- 需要新增表:`t_contest_work_judge_assignment`(作品分配表)
|
||||
- id, contest_id, work_id, judge_id, assignment_time, status
|
||||
- 管理员或系统自动分配作品给评委
|
||||
- 支持手动分配和自动分配(轮询、随机等)
|
||||
|
||||
**6.3.2 评分界面**
|
||||
- 显示作品信息(标题、说明、文件、预览等)
|
||||
- 显示评审规则(review_rule_id 关联的评审规则)
|
||||
- 多维度评分表单
|
||||
- 根据评审规则动态生成评分维度
|
||||
- 每个维度设置分数范围
|
||||
- 总分自动计算(根据评审规则配置的权重)
|
||||
- 评语输入框
|
||||
- 提交评分按钮
|
||||
|
||||
**6.3.3 评分数据**
|
||||
- `t_contest_work_score`:评分表
|
||||
- dimension_scores:JSON格式,存储各维度分数
|
||||
- total_score:总分(根据规则计算)
|
||||
- comments:评语
|
||||
|
||||
#### 6.4 评审规则设计
|
||||
需要新增表:`t_contest_review_rule`(评审规则表)
|
||||
- id, contest_id, rule_name, dimensions(JSON,存储评分维度配置)
|
||||
- 示例维度配置:
|
||||
```json
|
||||
{
|
||||
"dimension1": {
|
||||
"name": "创意性",
|
||||
"weight": 0.3,
|
||||
"maxScore": 100
|
||||
},
|
||||
"dimension2": {
|
||||
"name": "技术性",
|
||||
"weight": 0.4,
|
||||
"maxScore": 100
|
||||
},
|
||||
"dimension3": {
|
||||
"name": "完成度",
|
||||
"weight": 0.3,
|
||||
"maxScore": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.5 业务规则
|
||||
- 评审时间限制:必须在 `review_start_time` 和 `review_end_time` 之间
|
||||
- 作品状态更新:评审开始时,作品状态改为 `reviewing`
|
||||
- 每个作品可以被多个评委评审
|
||||
- 最终得分计算:取所有评委的平均分,或根据评审规则计算
|
||||
- 评审完成后,作品状态改为 `accepted` 或 `rejected`
|
||||
|
||||
---
|
||||
|
||||
### 7. 赛事结果公布
|
||||
|
||||
#### 7.1 功能概述
|
||||
管理员公布赛事评审结果,包括获奖名单、排名等。
|
||||
|
||||
#### 7.2 交互流程
|
||||
```
|
||||
管理员 → 进入"赛事管理" → 选择赛事 → 点击"公布结果" → 确认公布 → 结果发布
|
||||
```
|
||||
|
||||
#### 7.3 结果公布内容
|
||||
- 获奖名单(按奖项分类)
|
||||
- 作品排名(按总分排序)
|
||||
- 各维度平均分统计
|
||||
- 评审统计信息(参与评审人数、作品数量等)
|
||||
|
||||
#### 7.4 业务规则
|
||||
- 只有评审已完成的赛事可以公布结果
|
||||
- 公布后,`result_publish_time` 设置为当前时间
|
||||
- 结果公布后,所有用户可以看到排名和获奖信息
|
||||
- 支持导出结果(Excel/PDF)
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计分析
|
||||
|
||||
### 现有表结构
|
||||
|
||||
#### 1. t_contest(赛事表)
|
||||
✅ **优点:**
|
||||
- 字段设计完整,覆盖赛事全生命周期
|
||||
- 支持多租户(contest_tenant 字段)
|
||||
- 时间字段齐全
|
||||
|
||||
⚠️ **注意事项:**
|
||||
- `contest_tenant` 使用 text 类型存储租户列表,建议考虑 JSON 类型或关联表
|
||||
- `organizers`、`co_organizers`、`sponsors` 使用 text 存储数组,建议使用 JSON
|
||||
|
||||
#### 2. t_contest_attachment(赛事附件表)
|
||||
✅ 设计合理,支持多种文件类型
|
||||
|
||||
#### 3. t_contest_work(作品表)
|
||||
✅ **优点:**
|
||||
- 支持版本控制(version、is_latest)
|
||||
- 支持多种提交来源(teacher/student/team_leader)
|
||||
- 支持 AI 建模元数据
|
||||
|
||||
⚠️ **问题:**
|
||||
- 索引 `idx_submit_filter` 引用了不存在的字段 `review_status`,应删除或修正
|
||||
|
||||
#### 4. t_contest_work_attachment(作品附件表)
|
||||
⚠️ **问题:**
|
||||
- 第102行末尾有多余的逗号,需要删除
|
||||
|
||||
#### 5. t_contest_work_score(评分表)
|
||||
⚠️ **问题:**
|
||||
- 表定义重复(104-123行和125-144行),需要删除重复定义
|
||||
|
||||
#### 6. t_contest_registration(报名表)
|
||||
✅ 设计合理,支持个人和团队报名
|
||||
|
||||
#### 7. t_contest_team(团队表)
|
||||
⚠️ **问题:**
|
||||
- 唯一索引 `uk_team_name` 引用了不存在的字段 `name`,应改为 `team_name`
|
||||
|
||||
#### 8. t_contest_team_member(团队成员表)
|
||||
✅ 设计合理
|
||||
|
||||
### 缺失的表结构
|
||||
|
||||
#### 1. t_contest_notice(赛事公告表)
|
||||
```sql
|
||||
CREATE TABLE `t_contest_notice` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`title` varchar(255) NOT NULL COMMENT '公告标题',
|
||||
`content` text NOT NULL COMMENT '公告内容',
|
||||
`notice_type` varchar(31) NOT NULL DEFAULT 'manual' COMMENT '公告类型:system/manual/urgent',
|
||||
`priority` int DEFAULT 0 COMMENT '优先级(数字越大优先级越高)',
|
||||
`publish_time` datetime DEFAULT NULL COMMENT '发布时间',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事公告表';
|
||||
```
|
||||
|
||||
#### 2. t_contest_review_rule(评审规则表)
|
||||
```sql
|
||||
CREATE TABLE `t_contest_review_rule` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`rule_name` varchar(127) NOT NULL COMMENT '规则名称',
|
||||
`dimensions` json NOT NULL COMMENT '评分维度配置JSON',
|
||||
`calculation_rule` varchar(31) DEFAULT 'average' COMMENT '计算规则:average/max/min/weighted',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评审规则表';
|
||||
```
|
||||
|
||||
#### 3. t_contest_work_judge_assignment(作品分配表)
|
||||
```sql
|
||||
CREATE TABLE `t_contest_work_judge_assignment` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`judge_id` varchar(63) NOT NULL COMMENT '评委用户id',
|
||||
`assignment_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '分配时间',
|
||||
`status` varchar(31) NOT NULL DEFAULT 'assigned' COMMENT '分配状态:assigned/reviewing/completed',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_work_judge` (`work_id`, `judge_id`),
|
||||
KEY `idx_contest_judge` (`contest_id`, `judge_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作品分配表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 功能模块划分
|
||||
|
||||
### 后端模块结构
|
||||
|
||||
```
|
||||
backend/src/contests/
|
||||
├── contests.module.ts # 赛事主模块
|
||||
├── contests.controller.ts # 赛事控制器
|
||||
├── contests.service.ts # 赛事服务
|
||||
├── dto/
|
||||
│ ├── create-contest.dto.ts
|
||||
│ ├── update-contest.dto.ts
|
||||
│ ├── query-contest.dto.ts
|
||||
│ └── publish-contest.dto.ts
|
||||
├── works/
|
||||
│ ├── works.module.ts
|
||||
│ ├── works.controller.ts
|
||||
│ ├── works.service.ts
|
||||
│ └── dto/
|
||||
│ ├── create-work.dto.ts
|
||||
│ ├── update-work.dto.ts
|
||||
│ └── submit-work.dto.ts
|
||||
├── registrations/
|
||||
│ ├── registrations.module.ts
|
||||
│ ├── registrations.controller.ts
|
||||
│ ├── registrations.service.ts
|
||||
│ └── dto/
|
||||
│ ├── create-registration.dto.ts
|
||||
│ ├── review-registration.dto.ts
|
||||
│ └── create-team.dto.ts
|
||||
├── teams/
|
||||
│ ├── teams.module.ts
|
||||
│ ├── teams.controller.ts
|
||||
│ ├── teams.service.ts
|
||||
│ └── dto/
|
||||
│ ├── create-team.dto.ts
|
||||
│ └── invite-member.dto.ts
|
||||
├── reviews/
|
||||
│ ├── reviews.module.ts
|
||||
│ ├── reviews.controller.ts
|
||||
│ ├── reviews.service.ts
|
||||
│ └── dto/
|
||||
│ ├── create-score.dto.ts
|
||||
│ ├── assign-work.dto.ts
|
||||
│ └── create-review-rule.dto.ts
|
||||
└── notices/
|
||||
├── notices.module.ts
|
||||
├── notices.controller.ts
|
||||
├── notices.service.ts
|
||||
└── dto/
|
||||
├── create-notice.dto.ts
|
||||
└── update-notice.dto.ts
|
||||
```
|
||||
|
||||
### 前端模块结构
|
||||
|
||||
```
|
||||
frontend/src/views/contests/
|
||||
├── Index.vue # 赛事列表页
|
||||
├── Create.vue # 创建赛事页
|
||||
├── Detail.vue # 赛事详情页
|
||||
├── Edit.vue # 编辑赛事页
|
||||
├── works/
|
||||
│ ├── Index.vue # 作品列表页
|
||||
│ ├── Submit.vue # 提交作品页
|
||||
│ └── Detail.vue # 作品详情页
|
||||
├── registrations/
|
||||
│ ├── Index.vue # 报名列表页
|
||||
│ ├── Register.vue # 报名页
|
||||
│ └── Review.vue # 审核报名页
|
||||
├── teams/
|
||||
│ ├── Index.vue # 团队列表页
|
||||
│ ├── Create.vue # 创建团队页
|
||||
│ └── Detail.vue # 团队详情页
|
||||
├── reviews/
|
||||
│ ├── Index.vue # 评审列表页
|
||||
│ ├── Score.vue # 评分页
|
||||
│ └── Results.vue # 结果公布页
|
||||
└── notices/
|
||||
├── Index.vue # 公告列表页
|
||||
└── Create.vue # 创建公告页
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### 阶段一:基础功能(2-3周)
|
||||
|
||||
#### 1.1 数据库迁移
|
||||
- [ ] 修复 SQL 文件中的错误
|
||||
- 删除 `t_contest_work_score` 重复定义
|
||||
- 修复 `t_contest_work_attachment` 的逗号错误
|
||||
- 修复 `t_contest_team` 索引字段名错误
|
||||
- 修复 `t_contest_work` 索引字段错误
|
||||
- [ ] 创建缺失的表(公告表、评审规则表、作品分配表)
|
||||
- [ ] 更新 Prisma schema
|
||||
- [ ] 执行数据库迁移
|
||||
|
||||
#### 1.2 后端基础模块
|
||||
- [ ] 创建 contests 模块(Controller、Service、DTO)
|
||||
- [ ] 实现赛事 CRUD 接口
|
||||
- [ ] 实现赛事发布/撤回接口
|
||||
- [ ] 实现租户权限控制
|
||||
- [ ] 编写单元测试
|
||||
|
||||
#### 1.3 前端基础页面
|
||||
- [ ] 创建赛事列表页
|
||||
- [ ] 创建赛事创建/编辑页
|
||||
- [ ] 创建赛事详情页
|
||||
- [ ] 集成权限控制
|
||||
- [ ] 实现路由配置
|
||||
|
||||
### 阶段二:报名功能(2周)
|
||||
|
||||
#### 2.1 后端实现
|
||||
- [ ] 创建 registrations 模块
|
||||
- [ ] 实现个人报名接口
|
||||
- [ ] 实现团队报名接口
|
||||
- [ ] 实现报名审核接口
|
||||
- [ ] 实现报名状态流转逻辑
|
||||
|
||||
#### 2.2 前端实现
|
||||
- [ ] 创建报名页面
|
||||
- [ ] 创建团队管理页面
|
||||
- [ ] 创建报名审核页面
|
||||
- [ ] 实现报名状态展示
|
||||
|
||||
### 阶段三:作品提交功能(2周)
|
||||
|
||||
#### 3.1 后端实现
|
||||
- [ ] 创建 works 模块
|
||||
- [ ] 实现作品提交接口
|
||||
- [ ] 实现文件上传功能
|
||||
- [ ] 实现作品版本控制逻辑
|
||||
- [ ] 实现作品状态管理
|
||||
|
||||
#### 3.2 前端实现
|
||||
- [ ] 创建作品提交页面
|
||||
- [ ] 实现文件上传组件
|
||||
- [ ] 创建作品列表页
|
||||
- [ ] 创建作品详情页
|
||||
|
||||
### 阶段四:评审功能(2-3周)
|
||||
|
||||
#### 4.1 后端实现
|
||||
- [ ] 创建 reviews 模块
|
||||
- [ ] 实现评审规则管理接口
|
||||
- [ ] 实现作品分配接口
|
||||
- [ ] 实现评分接口
|
||||
- [ ] 实现评分计算逻辑
|
||||
|
||||
#### 4.2 前端实现
|
||||
- [ ] 创建评审规则配置页
|
||||
- [ ] 创建作品分配页
|
||||
- [ ] 创建评分页面
|
||||
- [ ] 实现多维度评分表单
|
||||
|
||||
### 阶段五:结果公布与公告(1周)
|
||||
|
||||
#### 5.1 后端实现
|
||||
- [ ] 创建 notices 模块
|
||||
- [ ] 实现公告 CRUD 接口
|
||||
- [ ] 实现结果公布接口
|
||||
- [ ] 实现结果统计接口
|
||||
|
||||
#### 5.2 前端实现
|
||||
- [ ] 创建公告管理页面
|
||||
- [ ] 创建结果公布页面
|
||||
- [ ] 实现结果展示页面
|
||||
|
||||
### 阶段六:优化与测试(1-2周)
|
||||
|
||||
#### 6.1 功能优化
|
||||
- [ ] 性能优化(数据库查询优化、缓存)
|
||||
- [ ] 用户体验优化
|
||||
- [ ] 错误处理完善
|
||||
|
||||
#### 6.2 测试
|
||||
- [ ] 单元测试
|
||||
- [ ] 集成测试
|
||||
- [ ] 端到端测试
|
||||
- [ ] 压力测试
|
||||
|
||||
---
|
||||
|
||||
## 技术实现要点
|
||||
|
||||
### 1. 权限设计
|
||||
|
||||
#### 1.1 权限编码规划
|
||||
```
|
||||
contest:create # 创建赛事
|
||||
contest:read # 查看赛事
|
||||
contest:update # 更新赛事
|
||||
contest:delete # 删除赛事
|
||||
contest:publish # 发布赛事
|
||||
contest:register # 报名赛事
|
||||
work:submit # 提交作品
|
||||
work:read # 查看作品
|
||||
work:update # 更新作品
|
||||
review:assign # 分配作品
|
||||
review:score # 评分
|
||||
review:read # 查看评审
|
||||
result:publish # 公布结果
|
||||
notice:create # 创建公告
|
||||
notice:read # 查看公告
|
||||
```
|
||||
|
||||
#### 1.2 角色规划
|
||||
- **超级管理员**:所有权限
|
||||
- **赛事管理员**:contest:*、notice:*、result:publish
|
||||
- **评委**:review:assign、review:score、review:read、work:read
|
||||
- **参赛者**:contest:read、contest:register、work:submit、work:read
|
||||
|
||||
### 2. 租户隔离
|
||||
|
||||
#### 2.1 数据隔离策略
|
||||
- 赛事创建:超级租户创建,通过 `contest_tenant` 字段控制可见范围
|
||||
- 报名数据:通过 `tenant_key` 字段隔离
|
||||
- 作品数据:通过 `tenant_key` 字段隔离
|
||||
- 评审数据:通过 `tenant_key` 字段隔离
|
||||
|
||||
#### 2.2 接口权限控制
|
||||
- 使用 `@TenantId()` 装饰器获取租户信息
|
||||
- Service 层自动过滤租户数据
|
||||
- Controller 层验证租户权限
|
||||
|
||||
### 3. 时间状态管理
|
||||
|
||||
#### 3.1 赛事状态机
|
||||
```
|
||||
unpublished → published → (可撤回) → unpublished
|
||||
```
|
||||
|
||||
#### 3.2 报名状态机
|
||||
```
|
||||
pending → passed/rejected/withdrawn
|
||||
```
|
||||
|
||||
#### 3.3 作品状态机
|
||||
```
|
||||
submitted → locked → reviewing → accepted/rejected
|
||||
```
|
||||
|
||||
#### 3.4 定时任务
|
||||
- 自动更新报名状态(根据时间)
|
||||
- 自动更新作品提交状态
|
||||
- 自动更新评审状态
|
||||
- 自动发送通知(可选)
|
||||
|
||||
### 4. 文件上传
|
||||
|
||||
#### 4.1 文件存储
|
||||
- 使用对象存储(OSS/S3)或本地存储
|
||||
- 文件类型限制:图片、视频、3D模型、文档等
|
||||
- 文件大小限制:根据文件类型设置
|
||||
|
||||
#### 4.2 文件管理
|
||||
- 文件上传接口
|
||||
- 文件删除接口
|
||||
- 文件预览接口
|
||||
- 文件下载接口
|
||||
|
||||
### 5. 通知系统
|
||||
|
||||
#### 5.1 通知类型
|
||||
- 报名成功通知
|
||||
- 报名审核结果通知
|
||||
- 作品提交成功通知
|
||||
- 评审结果通知
|
||||
- 结果公布通知
|
||||
|
||||
#### 5.2 通知方式(可选)
|
||||
- 站内消息
|
||||
- 邮件通知
|
||||
- 短信通知(可选)
|
||||
|
||||
### 6. 数据统计
|
||||
|
||||
#### 6.1 赛事统计
|
||||
- 报名人数统计
|
||||
- 作品提交数量统计
|
||||
- 评审进度统计
|
||||
- 结果统计
|
||||
|
||||
#### 6.2 报表导出
|
||||
- 报名名单导出(Excel)
|
||||
- 作品列表导出(Excel)
|
||||
- 评审结果导出(Excel)
|
||||
- 结果报告导出(PDF)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. SQL 文件问题修复
|
||||
在开始开发前,必须先修复 SQL 文件中的错误:
|
||||
- 删除重复的 `t_contest_work_score` 表定义
|
||||
- 修复 `t_contest_work_attachment` 表的语法错误
|
||||
- 修复 `t_contest_team` 表的索引错误
|
||||
- 修复 `t_contest_work` 表的索引错误
|
||||
|
||||
### 2. 数据一致性
|
||||
- 报名和作品的关系:一个报名可以对应多个作品版本
|
||||
- 团队和报名的关系:一个团队对应多个报名记录(每个成员一条)
|
||||
- 作品和评分的关系:一个作品可以有多条评分记录(多个评委)
|
||||
|
||||
### 3. 性能考虑
|
||||
- 赛事列表查询需要分页
|
||||
- 作品列表查询需要分页和筛选
|
||||
- 评分计算需要缓存
|
||||
- 文件上传需要异步处理
|
||||
|
||||
### 4. 安全性
|
||||
- 文件上传需要验证文件类型和大小
|
||||
- 接口需要权限验证
|
||||
- 敏感操作需要日志记录
|
||||
- 防止 SQL 注入和 XSS 攻击
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本方案基于现有的多租户 RBAC 系统架构,设计了完整的赛事管理功能模块。主要特点:
|
||||
|
||||
1. **完整的生命周期管理**:从赛事创建到结果公布的全流程
|
||||
2. **灵活的参赛方式**:支持个人赛和团队赛
|
||||
3. **强大的评审系统**:支持多维度评分和自定义评审规则
|
||||
4. **良好的扩展性**:模块化设计,易于扩展新功能
|
||||
|
||||
建议按照阶段逐步实现,每个阶段完成后进行测试和优化,确保系统稳定可靠。
|
||||
|
||||
246
docs/MENU_PERMISSION_CONTROL.md
Normal file
246
docs/MENU_PERMISSION_CONTROL.md
Normal file
@ -0,0 +1,246 @@
|
||||
# 菜单权限控制说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
系统通过 **权限编码(Permission Code)** 来控制用户对菜单的访问。菜单权限控制分为两个层面:
|
||||
|
||||
1. **菜单显示控制**:根据用户权限过滤菜单,只显示用户有权限访问的菜单
|
||||
2. **路由访问控制**:通过路由守卫检查用户是否有权限访问某个页面
|
||||
|
||||
## 🔄 权限控制流程
|
||||
|
||||
```
|
||||
用户登录
|
||||
↓
|
||||
获取用户角色和权限
|
||||
↓
|
||||
调用 /api/menus/user-menus 获取用户菜单
|
||||
↓
|
||||
后端根据用户权限过滤菜单
|
||||
↓
|
||||
前端动态生成路由和菜单
|
||||
↓
|
||||
路由守卫检查页面访问权限
|
||||
```
|
||||
|
||||
## 🎯 如何配置菜单权限
|
||||
|
||||
### 1. 创建权限
|
||||
|
||||
首先需要在权限管理中创建权限,例如:
|
||||
|
||||
- `menu:read` - 查看菜单权限
|
||||
- `user:read` - 查看用户权限
|
||||
- `role:read` - 查看角色权限
|
||||
|
||||
### 2. 将权限分配给角色
|
||||
|
||||
在角色管理中,为角色分配相应的权限。例如:
|
||||
|
||||
- **管理员角色**:拥有所有权限
|
||||
- **普通用户角色**:只拥有 `user:read` 权限
|
||||
|
||||
### 3. 为用户分配角色
|
||||
|
||||
在用户管理中,为用户分配角色。用户会继承角色的所有权限。
|
||||
|
||||
### 4. 为菜单设置权限编码
|
||||
|
||||
在菜单管理中,为菜单设置 `权限编码` 字段:
|
||||
|
||||
#### 示例配置
|
||||
|
||||
| 菜单名称 | 路径 | 权限编码 | 说明 |
|
||||
| -------- | ------------------- | ----------- | --------------------------------------------- |
|
||||
| 用户管理 | /system/users | `user:read` | 只有拥有 `user:read` 权限的用户才能看到此菜单 |
|
||||
| 角色管理 | /system/roles | `role:read` | 只有拥有 `role:read` 权限的用户才能看到此菜单 |
|
||||
| 权限管理 | /system/permissions | - | 不设置权限编码,所有用户都可以看到 |
|
||||
| 仪表盘 | /dashboard | - | 不设置权限编码,所有用户都可以看到 |
|
||||
|
||||
### 5. 权限编码规则
|
||||
|
||||
权限编码格式:`资源:操作`
|
||||
|
||||
常见示例:
|
||||
|
||||
- `user:read` - 查看用户
|
||||
- `user:create` - 创建用户
|
||||
- `user:update` - 更新用户
|
||||
- `user:delete` - 删除用户
|
||||
- `role:read` - 查看角色
|
||||
- `menu:read` - 查看菜单
|
||||
|
||||
## 💻 技术实现
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. 菜单权限过滤(`MenusService.findUserMenus`)
|
||||
|
||||
```typescript
|
||||
// 获取用户的所有权限
|
||||
const userPermissions = await this.authService.getUserPermissions(userId);
|
||||
|
||||
// 过滤菜单:如果菜单有permission字段,检查用户是否有该权限
|
||||
const filterMenus = (menus: any[]): any[] => {
|
||||
return menus
|
||||
.filter((menu) => {
|
||||
// 如果菜单没有设置权限要求,则显示
|
||||
if (!menu.permission) {
|
||||
return true;
|
||||
}
|
||||
// 如果设置了权限要求,检查用户是否有该权限
|
||||
return userPermissions.includes(menu.permission);
|
||||
})
|
||||
.map((menu) => {
|
||||
// 递归过滤子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children = filterMenus(menu.children);
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. 用户权限获取(`AuthService.getUserPermissions`)
|
||||
|
||||
```typescript
|
||||
async getUserPermissions(userId: number): Promise<string[]> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
const permissions = new Set<string>();
|
||||
|
||||
// 遍历用户的所有角色
|
||||
user.roles?.forEach((ur: any) => {
|
||||
// 遍历角色的所有权限
|
||||
ur.role.permissions?.forEach((rp: any) => {
|
||||
permissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
```
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 菜单转换为路由(`convertMenusToRoutes`)
|
||||
|
||||
```typescript
|
||||
const route: RouteRecordRaw = {
|
||||
path: routePath,
|
||||
name: routeName,
|
||||
meta: {
|
||||
title: menu.name,
|
||||
requiresAuth: true,
|
||||
// 如果菜单有权限要求,添加到路由meta中
|
||||
...(menu.permission && { permissions: [menu.permission] }),
|
||||
},
|
||||
component: componentLoader,
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. 路由守卫检查(`router.beforeEach`)
|
||||
|
||||
```typescript
|
||||
// 检查权限
|
||||
const requiredPermissions = to.meta.permissions;
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
if (!authStore.hasAnyPermission(requiredPermissions)) {
|
||||
// 没有所需权限,跳转到 403 页面
|
||||
next({ name: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 权限检查方法(`authStore`)
|
||||
|
||||
```typescript
|
||||
// 检查是否有指定权限
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
return user.value?.permissions?.includes(permission) ?? false;
|
||||
};
|
||||
|
||||
// 检查是否有任一权限
|
||||
const hasAnyPermission = (permissions: string[]): boolean => {
|
||||
if (!permissions || permissions.length === 0) return true;
|
||||
return permissions.some((perm) => hasPermission(perm));
|
||||
};
|
||||
```
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 示例 1:创建需要权限的菜单
|
||||
|
||||
1. 登录系统,进入 **菜单管理**
|
||||
2. 点击 **新增菜单**
|
||||
3. 填写菜单信息:
|
||||
- 菜单名称:`用户管理`
|
||||
- 路由路径:`/system/users`
|
||||
- 组件路径:`system/users/Index`
|
||||
- **权限编码**:`user:read` ⭐
|
||||
- 父菜单:选择 `系统管理`
|
||||
4. 保存菜单
|
||||
|
||||
### 示例 2:创建公开菜单(所有用户可见)
|
||||
|
||||
1. 在菜单管理中新增菜单
|
||||
2. **权限编码字段留空**
|
||||
3. 这样所有用户都可以看到此菜单
|
||||
|
||||
### 示例 3:为用户分配权限
|
||||
|
||||
1. 进入 **权限管理**,创建权限:
|
||||
- 权限名称:`查看用户`
|
||||
- 权限编码:`user:read`
|
||||
- 资源:`user`
|
||||
- 操作:`read`
|
||||
|
||||
2. 进入 **角色管理**,编辑角色:
|
||||
- 为角色分配 `user:read` 权限
|
||||
|
||||
3. 进入 **用户管理**,编辑用户:
|
||||
- 为用户分配该角色
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **权限编码必须唯一**:每个权限编码在系统中是唯一的
|
||||
2. **菜单权限为空则公开**:如果菜单的 `权限编码` 字段为空,所有用户都可以看到
|
||||
3. **子菜单继承父菜单权限**:子菜单会独立检查权限,不会自动继承父菜单权限
|
||||
4. **路由和菜单双重控制**:
|
||||
- 菜单显示控制:控制菜单是否在侧边栏显示
|
||||
- 路由访问控制:控制用户是否可以直接访问页面(通过 URL)
|
||||
5. **权限变更后需重新登录**:权限变更后,用户需要重新登录才能看到新的菜单
|
||||
|
||||
## 🔍 调试技巧
|
||||
|
||||
### 1. 查看用户权限
|
||||
|
||||
在浏览器控制台执行:
|
||||
|
||||
```javascript
|
||||
// 查看当前用户权限
|
||||
console.log(useAuthStore().user?.permissions);
|
||||
|
||||
// 检查是否有特定权限
|
||||
console.log(useAuthStore().hasPermission("user:read"));
|
||||
```
|
||||
|
||||
### 2. 查看用户菜单
|
||||
|
||||
```javascript
|
||||
// 查看当前用户的菜单
|
||||
console.log(useAuthStore().menus);
|
||||
```
|
||||
|
||||
### 3. 后端调试
|
||||
|
||||
在后端日志中查看:
|
||||
|
||||
- 用户权限列表
|
||||
- 菜单过滤结果
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [RBAC 权限控制详解](./RBAC_GUIDE.md)
|
||||
- [菜单管理使用说明](./MENU_MANAGEMENT.md)
|
||||
- [权限管理使用说明](./PERMISSION_MANAGEMENT.md)
|
||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
4
frontend/.npmrc
Normal file
4
frontend/.npmrc
Normal file
@ -0,0 +1,4 @@
|
||||
# 前端 pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user