Compare commits

...

No commits in common. "main" and "5d34307a698d7187d006385a41fd926641a66e25" have entirely different histories.

270 changed files with 43939 additions and 2 deletions

View File

@ -0,0 +1,156 @@
# Chrome DevTools MCP 安装和配置说明
## ✅ 安装状态
chrome-devtools-mcp 已通过 pnpm 全局安装完成。
## 📝 配置步骤
### ✅ 已配置(推荐方式)
项目已配置使用包装脚本,确保使用正确的 Node.js 版本。配置文件位于 `.cursor/mcp.json`
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "/Users/wwzh/Awesome/runfast/competition-management-system/.cursor/scripts/chrome-devtools-mcp.sh"
}
}
}
```
### 方法 1在 Cursor 用户设置中配置(备选)
如果项目配置不工作,可以在 Cursor 用户设置中添加:
1. 打开 Cursor
2. 按 `Cmd + Shift + P` (macOS) 或 `Ctrl + Shift + P` (Windows/Linux) 打开命令面板
3. 输入 "Preferences: Open User Settings (JSON)"
4. 在 `settings.json` 文件中添加以下配置:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "/Users/wwzh/Awesome/runfast/competition-management-system/.cursor/scripts/chrome-devtools-mcp.sh"
}
}
}
```
### 方法 2使用 npx需要修复 npm 缓存权限)
如果 npm 缓存权限问题已修复,可以使用:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["chrome-devtools-mcp@latest"]
}
}
}
```
**修复 npm 缓存权限**(需要 sudo 权限):
```bash
sudo chown -R $(whoami) ~/.npm
```
## 🔍 验证安装
安装完成后,重启 Cursor然后在 Chat 中应该可以看到 Chrome DevTools MCP 相关的工具。
## 📚 使用说明
Chrome DevTools MCP 提供了以下功能:
- 浏览器导航和页面快照
- 控制台消息查看
- 网络请求监控
- 页面元素交互(点击、输入等)
- 截图功能
## ⚠️ 注意事项
1. **Node.js 版本要求**:建议使用 Node.js 22.12.0 或更高版本当前版本20.19.0 LTS
2. **Chrome 浏览器**:需要安装最新版本的 Google Chrome
3. **首次使用**:可能需要登录配置,之后会保存登录状态
## 🔧 故障排除
### 问题MCP 服务器启动失败
**检查步骤:**
1. **验证包装脚本是否可执行**
```bash
cd /Users/wwzh/Awesome/runfast/competition-management-system
source ~/.nvm/nvm.sh
.cursor/scripts/chrome-devtools-mcp.sh --version
```
应该输出:`0.10.2`
2. **检查 Node.js 版本**
```bash
source ~/.nvm/nvm.sh
node --version
```
应该显示:`v20.19.0`
3. **检查配置文件**
```bash
cat .cursor/mcp.json
```
确认路径正确
4. **手动测试运行**
```bash
source ~/.nvm/nvm.sh
node /Users/wwzh/Library/pnpm/global/5/.pnpm/chrome-devtools-mcp@0.10.2/node_modules/chrome-devtools-mcp/build/src/index.js --version
```
5. **检查 Cursor 日志**
- 打开 Cursor
- 查看输出面板View → Output
- 选择 "MCP" 或 "Chrome DevTools" 查看错误信息
6. **重启 Cursor IDE**
配置更改后需要完全重启 Cursor
### 常见错误
**错误command not found**
- 确保包装脚本路径正确
- 检查脚本是否有执行权限:`chmod +x .cursor/scripts/chrome-devtools-mcp.sh`
**错误Node.js version mismatch**
- 确保使用 Node.js 20.19.0`nvm use 20.19.0`
- 检查包装脚本中的 nvm 路径是否正确
**错误npm cache permission denied**
- 如果使用 npx 方式,需要修复权限:
```bash
sudo chown -R $(whoami) ~/.npm
```
### 重新安装
如果问题持续存在:
```bash
# 1. 卸载旧版本
pnpm remove -g chrome-devtools-mcp
# 2. 清理缓存
pnpm store prune
# 3. 重新安装
source ~/.nvm/nvm.sh
pnpm add -g chrome-devtools-mcp@latest
# 4. 验证安装
pnpm list -g chrome-devtools-mcp
```

View File

@ -0,0 +1,167 @@
# Cursor Rules 迁移总结
## ✅ 完成的改进
根据 [Cursor 官方文档](https://cursor.com/cn/docs/context/rules) 的最佳实践,已将项目规则系统现代化。
### 1. 规则拆分 ✨
原来的 283 行单一文件 `.cursorrules` 已拆分为 **8 个模块化规则**
#### 主规则(`.cursor/rules/`
| 规则文件 | 类型 | 大小 | 说明 |
|---------|------|------|------|
| `project-overview.mdc` | Always Apply | ~50 行 | 项目概述和技术栈 |
| `multi-tenant.mdc` | Always Apply | ~100 行 | ⚠️ 多租户隔离规范(核心安全) |
| `backend-architecture.mdc` | Apply to Files | ~200 行 | NestJS 后端架构规范 |
| `frontend-architecture.mdc` | Apply to Files | ~250 行 | Vue 3 前端架构规范 |
| `database-design.mdc` | Apply to Files | ~200 行 | Prisma 数据库设计规范 |
| `code-review-checklist.mdc` | Manual | ~150 行 | 代码审查清单 |
#### 嵌套规则
| 规则文件 | 作用域 | 说明 |
|---------|--------|------|
| `backend/.cursor/rules/backend-specific.mdc` | backend/ | 后端特定规范和脚本 |
| `frontend/.cursor/rules/frontend-specific.mdc` | frontend/ | 前端特定规范和组件 |
### 2. 使用 MDC 格式 📝
所有规则文件使用标准 MDC 格式,支持元数据:
```md
---
description: 规则描述
globs:
- "backend/**/*.ts"
alwaysApply: false
---
# 规则内容...
```
### 3. 智能应用策略 🎯
- **Always Apply**: 关键规则(项目概述、多租户)始终生效
- **File Matching**: 后端/前端规则仅在相关文件时应用
- **Nested Rules**: 子目录规则只在该目录下生效
- **Manual**: 代码审查清单按需引用 `@code-review-checklist`
### 4. 创建 AGENTS.md 🚀
添加了简化版快速参考文件:
- 纯 Markdown 格式,无元数据
- 包含最重要的规则和快速参考
- 易于阅读和分享
### 5. 完整文档 📚
创建了详细的使用指南 `.cursor/RULES_README.md`
- 规则文件结构说明
- 使用方式指导
- 迁移指南
- 最佳实践
## 📊 改进效果
### 性能优化
- ✅ 每个规则 < 500 符合最佳实践
- ✅ 按需加载,减少不必要的上下文
- ✅ 嵌套规则提高针对性
### 可维护性
- ✅ 模块化设计,易于更新单个规则
- ✅ 版本控制友好
- ✅ 清晰的职责分离
### 可扩展性
- ✅ 轻松添加新规则
- ✅ 支持子目录特定规则
- ✅ 规则可以引用其他文件
## 🎯 使用建议
### 日常开发
```bash
# 开发时规则自动生效
# 不需要手动操作
# 需要代码审查时
在 Chat 中输入:@code-review-checklist
```
### 添加新规则
```bash
# 方法 1: 使用命令
Cmd/Ctrl + Shift + P → "New Cursor Rule"
# 方法 2: 手动创建
# 在 .cursor/rules/ 创建新的 .mdc 文件
```
### 查看规则状态
```bash
# 打开 Cursor Settings
Cmd/Ctrl + ,
# 进入 Rules 选项卡
查看所有规则的状态和类型
```
## ⚠️ 重要变更
### 1. 旧文件状态
- `.cursorrules` 已标记为 DEPRECATED
- 文件保留作为备份
- 所有功能已迁移到新系统
### 2. 多租户规则
- 设为 **Always Apply**
- 确保所有生成的代码都包含租户隔离检查
- 这是系统安全的核心保障
### 3. 嵌套规则生效
- 在 `backend/` 目录工作时,后端特定规则自动应用
- 在 `frontend/` 目录工作时,前端特定规则自动应用
## 📈 下一步
### 可选的进一步优化
1. **添加模块特定规则**
```
backend/src/contests/.cursor/rules/
└── contests-specific.mdc
```
2. **创建模板规则**
- 控制器模板
- 服务模板
- 组件模板
3. **团队规则(如果有 Team 计划)**
- 在 Cursor Dashboard 配置团队级规则
- 强制执行组织标准
## 🔗 相关资源
- 📖 [规则使用指南](./.cursor/RULES_README.md)
- 🚀 [快速参考](../AGENTS.md)
- 📚 [Cursor 官方文档](https://cursor.com/cn/docs/context/rules)
## 💬 反馈
如有问题或建议,可以:
1. 更新规则文件并测试
2. 查看官方文档获取最新功能
3. 分享最佳实践给团队
---
**迁移完成时间**: 2025-11-27
**符合标准**: Cursor Rules Best Practices v1.0

128
.cursor/RULES_README.md Normal file
View File

@ -0,0 +1,128 @@
# Cursor Rules 使用指南
本项目使用 Cursor 的新规则系统Project Rules + AGENTS.md遵循 [官方最佳实践](https://cursor.com/cn/docs/context/rules)。
## 📁 规则文件结构
```
competition-management-system/
├── .cursor/rules/ # 项目规则目录
│ ├── project-overview.mdc # 项目概述Always Apply
│ ├── multi-tenant.mdc # 多租户规范Always Apply
│ ├── backend-architecture.mdc # 后端架构Apply to backend files
│ ├── frontend-architecture.mdc # 前端架构Apply to frontend files
│ ├── database-design.mdc # 数据库设计Apply to prisma files
│ └── code-review-checklist.mdc # 代码审查清单Manual
├── backend/.cursor/rules/
│ └── backend-specific.mdc # 后端特定规范(嵌套规则)
├── frontend/.cursor/rules/
│ └── frontend-specific.mdc # 前端特定规范(嵌套规则)
├── AGENTS.md # 简化版指令Quick Reference
└── .cursorrules # 已废弃,保留作为备份
```
## 🎯 规则类型说明
### 1. Always Apply总是应用
- `project-overview.mdc` - 项目技术栈和基本信息
- `multi-tenant.mdc` - **多租户数据隔离规范(最重要)**
### 2. Apply to Specific Files文件匹配
- `backend-architecture.mdc` - 匹配 `backend/**/*.ts`
- `frontend-architecture.mdc` - 匹配 `frontend/**/*.vue``frontend/**/*.ts`
- `database-design.mdc` - 匹配 `backend/prisma/**/*.prisma`
### 3. Nested Rules嵌套规则
- `backend/.cursor/rules/backend-specific.mdc` - 仅作用于 backend 目录
- `frontend/.cursor/rules/frontend-specific.mdc` - 仅作用于 frontend 目录
### 4. Apply Manually手动触发
- `code-review-checklist.mdc` - 在 Chat 中使用 `@code-review-checklist` 引用
## 🚀 使用方式
### 在 Chat 中引用规则
```
# 自动应用
规则会根据上下文自动应用
# 手动引用
@code-review-checklist 请检查我的代码
# 引用特定文件
@backend-architecture 如何创建一个新的模块?
```
### 查看和管理规则
1. 打开 Cursor SettingsCmd/Ctrl + ,
2. 进入 **Rules** 选项卡
3. 查看所有规则的状态和类型
### 编辑规则
直接编辑 `.cursor/rules/` 目录中的 `.mdc` 文件Cursor 会自动重新加载。
## 📖 快速参考
### 对于快速查阅
使用 `AGENTS.md`(纯 Markdown无元数据:
```bash
cat AGENTS.md
```
### 对于详细规范
查看 `.cursor/rules/` 中的具体规则文件。
## 🔄 从旧版本迁移
旧的 `.cursorrules` 文件已被拆分为多个小规则文件:
| 旧内容 | 新位置 |
|-------|--------|
| 项目概述 | `project-overview.mdc` |
| 后端规范 | `backend-architecture.mdc` + `backend-specific.mdc` |
| 前端规范 | `frontend-architecture.mdc` + `frontend-specific.mdc` |
| 数据库规范 | `database-design.mdc` |
| 多租户规范 | `multi-tenant.mdc` |
| 代码审查 | `code-review-checklist.mdc` |
## 💡 最佳实践
### 1. 规则大小
- 每个规则文件 < 500
- 聚焦单一主题
- 提供具体示例
### 2. 嵌套规则
- 在子目录创建 `.cursor/rules/` 针对特定区域
- 子规则会与父规则合并
- 更具体的规则优先级更高
### 3. 规则复用
- 将重复的提示词转换为规则
- 使用 `@rule-name` 在对话中引用
- 避免每次重复输入相同指令
## ⚠️ 重要提醒
### 多租户隔离
`multi-tenant.mdc` 规则设为 **Always Apply**,确保所有代码生成都包含租户隔离检查。这是系统安全的核心!
### 规则优先级
规则应用顺序:**Team Rules → Project Rules → User Rules**
## 🔗 参考链接
- [Cursor Rules 官方文档](https://cursor.com/cn/docs/context/rules)
- [MDC 格式说明](https://cursor.com/cn/docs/context/rules#规则结构)
- [最佳实践](https://cursor.com/cn/docs/context/rules#最佳实践)
## 📝 更新日志
- **2025-11-27**: 从 `.cursorrules` 迁移到新的 Project Rules 系统
- 拆分为 6 个主规则 + 2 个嵌套规则
- 添加 AGENTS.md 作为快速参考
- 遵循 Cursor 官方最佳实践

7
.cursor/mcp.json Normal file
View File

@ -0,0 +1,7 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "/Users/wwzh/Awesome/runfast/competition-management-system/.cursor/scripts/chrome-devtools-mcp.sh"
}
}
}

View File

@ -0,0 +1,221 @@
---
description: NestJS 后端架构规范和模块结构
globs:
- "backend/**/*.ts"
alwaysApply: false
---
# 后端架构规范
## 模块结构
每个功能模块应包含:
- `module.ts` - 模块定义
- `controller.ts` - 控制器
- `service.ts` - 服务层
- `dto/` - 数据传输对象目录
### 命名规范
- 模块命名使用复数形式:`users`, `roles`, `contests`
- 子模块放在父模块目录下:`contests/works/`, `contests/teams/`
### 目录结构示例
```
src/
├── contests/
│ ├── contests.module.ts
│ ├── contests/
│ │ ├── contests.module.ts
│ │ ├── contests.controller.ts
│ │ ├── contests.service.ts
│ │ └── dto/
│ ├── works/
│ │ ├── works.module.ts
│ │ ├── works.controller.ts
│ │ ├── works.service.ts
│ │ └── dto/
│ └── teams/
│ └── ...
```
## 服务层 (Service)
### 基本规范
- 使用 `@Injectable()` 装饰器
- 构造函数注入依赖,使用 `private readonly`
- 所有数据库操作通过 PrismaService
- **禁止直接使用 SQL**
### 标准方法命名
- `create` - 创建
- `findAll` - 查询列表(支持分页)
- `findOne` - 查询单个
- `update` - 更新
- `remove` - 删除(软删除或级联)
### 示例
```typescript
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(createDto: CreateUserDto, tenantId: number) {
return this.prisma.user.create({
data: {
...createDto,
tenantId,
},
});
}
async findAll(tenantId: number, skip?: number, take?: number) {
return this.prisma.user.findMany({
where: { tenantId, validState: 1 },
skip,
take,
include: {
roles: {
include: { role: true },
},
},
});
}
}
```
## 控制器层 (Controller)
### 基本规范
- 使用 `@Controller()` 装饰器,路径使用复数形式
- 所有路由默认需要认证(除非使用 `@Public()` 装饰器)
- 使用 REST 风格的 HTTP 方法装饰器
### 装饰器使用
```typescript
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@RequirePermission("user:create")
async create(
@Body() createDto: CreateUserDto,
@CurrentTenantId() tenantId: number,
@CurrentUser() user: any
) {
return this.usersService.create(createDto, tenantId);
}
@Get()
@RequirePermission("user:read")
async findAll(
@CurrentTenantId() tenantId: number,
@Query("skip") skip?: number,
@Query("take") take?: number
) {
return this.usersService.findAll(tenantId, skip, take);
}
@Public()
@Get("public-info")
async getPublicInfo() {
return { version: "1.0.0" };
}
}
```
### 常用装饰器
- `@CurrentTenantId()` - 获取当前租户ID
- `@CurrentUser()` - 获取当前用户信息
- `@RequirePermission('module:action')` - 权限检查
- `@Public()` - 公开接口,无需认证
## DTO 规范
### 命名规范
- 创建:`CreateXxxDto`
- 更新:`UpdateXxxDto`
- 查询:`QueryXxxDto`
### 验证规则
使用 `class-validator` 装饰器:
```typescript
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;
@IsArray()
@IsNumber({}, { each: true })
@IsOptional()
roleIds?: number[];
}
```
## 错误处理
使用 NestJS 内置异常,消息使用中文:
```typescript
import {
NotFoundException,
BadRequestException,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
// 示例
if (!user) {
throw new NotFoundException("用户不存在");
}
if (!isValid) {
throw new BadRequestException("数据验证失败");
}
```
## 权限控制
权限字符串格式:`模块:操作`
```typescript
@RequirePermission('contest:create') // 创建竞赛
@RequirePermission('user:update') // 更新用户
@RequirePermission('role:delete') // 删除角色
```
## 代码风格
- 导入顺序NestJS 核心 → 第三方库 → 本地模块
- 使用 async/await避免 Promise.then()
- 使用解构赋值提高代码可读性
- 复杂逻辑必须添加注释

View File

@ -0,0 +1,112 @@
---
description: 代码审查检查清单(手动应用)
globs:
alwaysApply: false
---
# 代码审查检查清单
在提交代码前,请确保以下各项都已检查:
## 多租户数据隔离
- [ ] 所有数据库查询包含 `tenantId` 条件
- [ ] 创建数据时设置了 `tenantId`
- [ ] 更新/删除操作验证了 `tenantId`
- [ ] 新的 Prisma 模型包含了必需的审计字段
## 数据验证
- [ ] DTO 验证规则完整
- [ ] 前端和后端都进行了数据验证
- [ ] 使用了 TypeScript 类型定义
- [ ] 处理了所有必填字段
## 错误处理
- [ ] 所有异步操作都有错误处理
- [ ] 错误信息清晰明确
- [ ] 使用了合适的 HTTP 状态码
- [ ] 前端显示了友好的错误提示
## 权限控制
- [ ] 后端使用了 `@RequirePermission()` 装饰器
- [ ] 前端路由配置了 `permissions` meta
- [ ] 权限验证失败返回 403
- [ ] 遵循最小权限原则
## 代码质量
- [ ] 代码格式符合 ESLint/Prettier 规范
- [ ] 复杂逻辑添加了注释
- [ ] 变量和函数命名清晰
- [ ] 无硬编码配置(使用环境变量)
- [ ] 无调试代码console.log 等)
## 性能优化
- [ ] 数据库查询使用了 `include` 预加载
- [ ] 使用了 `select` 精简字段
- [ ] 实现了分页查询
- [ ] 避免了 N+1 查询
- [ ] 前端组件按需加载
## 安全性
- [ ] 敏感数据加密存储
- [ ] API 需要认证(除非 `@Public()`
- [ ] 防止了 SQL 注入(使用 Prisma
- [ ] 防止了 XSS 攻击
- [ ] Token 过期时间合理
## 测试
- [ ] 核心业务逻辑有单元测试
- [ ] 测试覆盖率 > 80%
- [ ] 所有测试通过
## Git 提交
- [ ] 提交信息清晰(使用中文)
- [ ] 提交信息格式:`类型: 描述`
- [ ] 一次提交只做一件事
- [ ] 不包含敏感信息
## 文档
- [ ] 复杂功能有文档说明
- [ ] API 接口有注释
- [ ] README 更新(如有必要)
## 使用建议
### 在提交前运行
```bash
# 后端
cd backend
pnpm lint # 代码检查
pnpm test # 运行测试
pnpm build # 确保能成功构建
# 前端
cd frontend
pnpm lint # 代码检查
pnpm build # 确保能成功构建
```
### 自动化检查
考虑使用 Git hooks如 husky自动执行检查
- pre-commit: 运行 lint
- pre-push: 运行测试
### Code Review 关注点
审查他人代码时,重点关注:
1. **数据安全**:租户隔离是否完整
2. **权限控制**:是否正确验证权限
3. **错误处理**:是否处理所有异常情况
4. **代码质量**:是否易于理解和维护
5. **性能**:是否有明显的性能问题

View File

@ -0,0 +1,278 @@
---
description: Prisma 数据库设计规范和最佳实践
globs:
- "backend/prisma/**/*.prisma"
alwaysApply: false
---
# 数据库设计规范
## Prisma Schema 规范
### 表结构要求
所有业务表必须包含以下字段:
```prisma
model YourModel {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID必填
// 业务字段...
name 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)
creatorUser User? @relation("YourModelCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("YourModelModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@map("your_table_name")
}
```
### 字段命名规范
- Prisma 模型使用 camelCase`tenantId`, `createTime`
- 数据库列使用 snake_case`tenant_id`, `create_time`
- 使用 `@map()` 映射字段名
- 使用 `@@map()` 映射表名
### 状态字段
使用 `validState` 表示数据有效性:
- `1` - 有效
- `2` - 失效(软删除)
```prisma
validState Int @default(1) @map("valid_state")
```
## 关系设计
### 一对多关系
```prisma
model Tenant {
id Int @id @default(autoincrement())
users User[]
}
model User {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}
```
### 多对多关系
使用显式中间表:
```prisma
model User {
id Int @id @default(autoincrement())
roles UserRole[]
}
model Role {
id Int @id @default(autoincrement())
users UserRole[]
}
model UserRole {
userId Int @map("user_id")
roleId Int @map("role_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@map("user_roles")
}
```
### 一对一关系
```prisma
model User {
id Int @id @default(autoincrement())
teacher Teacher?
}
model Teacher {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```
### 级联删除规则
- 强依赖关系:`onDelete: Cascade`
- 弱依赖关系:`onDelete: SetNull`(字段必须可选)
- 保护性关系:`onDelete: Restrict`
## 索引设计
### 自动索引
- 主键自动创建索引
- 外键字段自动创建索引
- `@unique` 字段自动创建唯一索引
### 复合索引
```prisma
model User {
tenantId Int
username String
@@unique([tenantId, username])
@@index([tenantId, validState])
}
```
### 性能优化索引
为频繁查询的字段添加索引:
```prisma
model Contest {
tenantId Int
status Int
startTime DateTime
@@index([tenantId, status])
@@index([tenantId, startTime])
}
```
## Prisma 查询最佳实践
### 使用 include 预加载关联
避免 N+1 查询问题:
```typescript
// ✅ 好的做法 - 使用 include 预加载
const users = await prisma.user.findMany({
where: { tenantId },
include: {
roles: {
include: {
role: true,
},
},
},
});
// ❌ 不好的做法 - N+1 查询
const users = await prisma.user.findMany({
where: { tenantId },
});
for (const user of users) {
user.roles = await prisma.userRole.findMany({
where: { userId: user.id },
});
}
```
### 使用 select 精简字段
只查询需要的字段:
```typescript
const users = await prisma.user.findMany({
where: { tenantId },
select: {
id: true,
username: true,
nickname: true,
// 不查询 password 等敏感字段
},
});
```
### 分页查询
```typescript
const users = await prisma.user.findMany({
where: { tenantId, validState: 1 },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createTime: "desc" },
});
const total = await prisma.user.count({
where: { tenantId, validState: 1 },
});
```
### 事务处理
使用 `$transaction` 确保数据一致性:
```typescript
await prisma.$transaction(async (tx) => {
// 创建用户
const user = await tx.user.create({
data: {
username: "test",
tenantId,
},
});
// 创建用户角色关联
await tx.userRole.createMany({
data: roleIds.map((roleId) => ({
userId: user.id,
roleId,
})),
});
});
```
## 数据迁移
### 创建迁移
```bash
# 开发环境 - 创建并应用迁移
pnpm prisma:migrate:dev
# 生产环境 - 只应用迁移
pnpm prisma:migrate:deploy
```
### 迁移命名
使用描述性的迁移名称:
```bash
prisma migrate dev --name add_contest_module
prisma migrate dev --name add_user_avatar_field
```
### 迁移注意事项
- 迁移前备份数据库
- 测试迁移在开发环境的执行
- 生产环境使用 `migrate deploy` 而不是 `migrate dev`
- 不要手动修改已应用的迁移文件
## 性能优化清单
- [ ] 频繁查询的字段添加了索引
- [ ] 使用 `include` 预加载关联数据
- [ ] 使用 `select` 只查询需要的字段
- [ ] 实现了分页查询
- [ ] 复杂操作使用了事务
- [ ] 避免了 N+1 查询问题

View File

@ -0,0 +1,348 @@
---
description: Vue 3 前端架构规范和组件开发
globs:
- "frontend/**/*.vue"
- "frontend/**/*.ts"
alwaysApply: false
---
# 前端架构规范
## 组件结构
### 目录组织
- 页面组件放在 `views/` 目录下,按模块组织
- 公共组件放在 `components/` 目录下
- 组件命名使用 PascalCase
### 组件语法
使用 Vue 3 Composition API 的 `<script setup lang="ts">` 语法:
```vue
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
import { getUsers, type User } from "@/api/users";
const loading = ref(false);
const users = ref<User[]>([]);
const activeUsers = computed(() =>
users.value.filter((u) => u.validState === 1)
);
onMounted(async () => {
await fetchUsers();
});
const fetchUsers = async () => {
try {
loading.value = true;
users.value = await getUsers();
} catch (error) {
message.error("获取用户列表失败");
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="p-4">
<a-spin :spinning="loading">
<a-table :dataSource="activeUsers" />
</a-spin>
</div>
</template>
```
## API 调用规范
### 目录结构
所有 API 调用放在 `api/` 目录下,按模块组织:
```
api/
├── users.ts
├── roles.ts
├── contests.ts
└── auth.ts
```
### API 函数命名
- `getXxx` - 获取数据
- `createXxx` - 创建数据
- `updateXxx` - 更新数据
- `deleteXxx` - 删除数据
### 示例
```typescript
// api/users.ts
import request from "@/utils/request";
export interface User {
id: number;
username: string;
nickname: string;
email?: string;
validState: number;
}
export interface CreateUserDto {
username: string;
password: string;
nickname: string;
email?: string;
roleIds?: number[];
}
export const getUsers = (params?: { skip?: number; take?: number }) => {
return request.get<User[]>("/users", { params });
};
export const createUser = (data: CreateUserDto) => {
return request.post<User>("/users", data);
};
export const updateUser = (id: number, data: Partial<CreateUserDto>) => {
return request.put<User>(`/users/${id}`, data);
};
export const deleteUser = (id: number) => {
return request.delete(`/users/${id}`);
};
```
## 状态管理 (Pinia)
### Store 规范
- Store 文件放在 `stores/` 目录下
- 使用 `defineStore()` 定义 store
- Store 命名使用 camelCase + Store 后缀
### 示例
```typescript
// stores/auth.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { login, getUserInfo, type LoginDto, type User } from "@/api/auth";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem("token"));
const user = ref<User | null>(null);
const menus = ref<any[]>([]);
const isAuthenticated = computed(() => !!token.value && !!user.value);
const loginAction = async (loginDto: LoginDto) => {
const {
accessToken,
user: userInfo,
menus: userMenus,
} = await login(loginDto);
token.value = accessToken;
user.value = userInfo;
menus.value = userMenus;
localStorage.setItem("token", accessToken);
};
const logout = () => {
token.value = null;
user.value = null;
menus.value = [];
localStorage.removeItem("token");
};
return {
token,
user,
menus,
isAuthenticated,
loginAction,
logout,
};
});
```
## 路由管理
### 路由规范
- 路由配置在 `router/index.ts`
- 支持动态路由(基于菜单权限)
- 路由路径包含租户编码:`/:tenantCode/xxx`
- 路由 meta 包含权限信息
### 示例
```typescript
{
path: '/:tenantCode/users',
name: 'Users',
component: () => import('@/views/users/Index.vue'),
meta: {
requiresAuth: true,
permissions: ['user:read'],
roles: ['admin'],
},
}
```
## 表单验证
### 使用 VeeValidate + Zod
```vue
<script setup lang="ts">
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
const schema = z.object({
username: z.string().min(3, "用户名至少3个字符"),
password: z.string().min(6, "密码至少6个字符"),
email: z.string().email("邮箱格式不正确").optional(),
});
const { defineField, handleSubmit, errors } = useForm({
validationSchema: toTypedSchema(schema),
});
const [username] = defineField("username");
const [password] = defineField("password");
const [email] = defineField("email");
const onSubmit = handleSubmit(async (values) => {
try {
await createUser(values);
message.success("创建成功");
} catch (error) {
message.error("创建失败");
}
});
</script>
<template>
<a-form @submit.prevent="onSubmit">
<a-form-item
:help="errors.username"
:validateStatus="errors.username ? 'error' : ''"
>
<a-input v-model:value="username" placeholder="用户名" />
</a-form-item>
<a-form-item
:help="errors.password"
:validateStatus="errors.password ? 'error' : ''"
>
<a-input-password v-model:value="password" placeholder="密码" />
</a-form-item>
<a-button type="primary" html-type="submit">提交</a-button>
</a-form>
</template>
```
## UI 组件规范
### Ant Design Vue
- 使用 Ant Design Vue 组件库
- 遵循 Ant Design 设计规范
### 样式
- 使用 Tailwind CSS 工具类
- 复杂样式使用 SCSS
- 响应式设计,移动端优先
### 状态管理
组件必须有 loading 和 error 状态:
```vue
<template>
<div class="p-4">
<a-spin :spinning="loading">
<a-alert
v-if="error"
type="error"
:message="error"
closable
@close="error = null"
/>
<div v-else>
<!-- 内容 -->
</div>
</a-spin>
</div>
</template>
```
## TypeScript 类型定义
### 类型文件组织
- TypeScript 类型定义放在 `types/` 目录下
- 接口类型使用 `interface`
- 数据模型使用 `type`
- 导出类型供其他模块使用
### 示例
```typescript
// types/user.ts
export interface User {
id: number;
username: string;
nickname: string;
email?: string;
tenantId: number;
validState: number;
createTime: string;
modifyTime: string;
}
export type CreateUserParams = Omit<
User,
"id" | "tenantId" | "createTime" | "modifyTime"
>;
export type UserRole = {
userId: number;
roleId: number;
role: Role;
};
```
## 性能优化
### 路由懒加载
```typescript
const routes = [
{
path: "/users",
component: () => import("@/views/users/Index.vue"),
},
];
```
### 组件按需加载
```typescript
import { defineAsyncComponent } from "vue";
const AsyncComponent = defineAsyncComponent(
() => import("@/components/HeavyComponent.vue")
);
```
### 避免不必要的重新渲染
使用 `computed`、`watchEffect` 和 `memo` 优化性能。

View File

@ -0,0 +1,101 @@
---
description: 多租户数据隔离规范(所有涉及数据库操作的代码必须遵守)
globs:
alwaysApply: true
---
# 多租户处理规范
⚠️ **极其重要**:所有业务数据查询必须包含 `tenantId` 条件!
## 核心原则
### 1. 数据库查询
- **必须**:所有业务表查询必须包含 `tenantId` 条件
- 超级租户(`isSuper = 1`)可以访问所有租户数据
```typescript
// ✅ 正确示例
const users = await this.prisma.user.findMany({
where: {
tenantId,
validState: 1,
},
});
// ❌ 错误示例 - 缺少 tenantId
const users = await this.prisma.user.findMany({
where: {
validState: 1,
},
});
```
### 2. 获取租户ID
在控制器中使用 `@CurrentTenantId()` 装饰器:
```typescript
@Get()
async findAll(@CurrentTenantId() tenantId: number) {
return this.service.findAll(tenantId);
}
```
### 3. 创建数据
创建数据时自动设置 `tenantId`
```typescript
async create(createDto: CreateDto, tenantId: number) {
return this.prisma.model.create({
data: {
...createDto,
tenantId,
},
});
}
```
### 4. 更新/删除数据
更新或删除前验证数据属于当前租户:
```typescript
async update(id: number, updateDto: UpdateDto, tenantId: number) {
// 先验证数据属于当前租户
const existing = await this.prisma.model.findFirst({
where: { id, tenantId },
});
if (!existing) {
throw new NotFoundException('数据不存在或不属于当前租户');
}
return this.prisma.model.update({
where: { id },
data: updateDto,
});
}
```
## 数据库表设计
所有业务表必须包含:
- `tenantId`: Int - 租户ID必填
- `creator`: Int? - 创建人ID
- `modifier`: Int? - 修改人ID
- `createTime`: DateTime @default(now())
- `modifyTime`: DateTime @updatedAt
- `validState`: Int @default(1) - 有效状态1-有效2-失效)
## 审查清单
在代码审查时,重点检查:
- [ ] 所有 `findMany`、`findFirst`、`findUnique` 包含 `tenantId` 条件
- [ ] 创建操作设置了 `tenantId`
- [ ] 更新/删除操作验证了 `tenantId`
- [ ] 新的 Prisma 模型包含了 `tenantId` 字段

View File

@ -0,0 +1,41 @@
---
description: 项目概述和技术栈信息
globs:
alwaysApply: true
---
# 项目概述
这是一个多租户的竞赛管理系统,采用前后端分离架构。
## 技术栈
### 后端
- **框架**: NestJS + TypeScript
- **数据库**: MySQL 8.0
- **ORM**: Prisma
- **认证**: JWT + RBAC (基于角色的访问控制)
### 前端
- **框架**: Vue 3 + TypeScript
- **构建工具**: Vite
- **UI 组件库**: Ant Design Vue
- **状态管理**: Pinia
- **路由**: Vue Router 4
- **表单验证**: VeeValidate + Zod
## 核心特性
- **多租户架构**: 数据完全隔离,每个租户使用独立的 tenantId
- **RBAC 权限系统**: 基于角色的细粒度权限控制
- **动态菜单系统**: 基于权限的动态路由和菜单
- **审计日志**: 完整的操作审计追踪
## 代码风格
- 使用 TypeScript 严格模式
- 使用 ESLint 和 Prettier 格式化代码
- 注释使用中文
- Git 提交信息使用中文,格式:`类型: 描述`

View File

@ -0,0 +1,14 @@
#!/bin/bash
# Chrome DevTools MCP wrapper script
# Ensures correct Node.js version is used
# Load nvm if available
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Use Node.js 20.19.0 (or default)
nvm use default 2>/dev/null || nvm use 20.19.0 2>/dev/null || true
# Run chrome-devtools-mcp
exec node "/Users/wwzh/Library/pnpm/global/5/.pnpm/chrome-devtools-mcp@0.10.2/node_modules/chrome-devtools-mcp/build/src/index.js" "$@"

100
.cursorignore Normal file
View File

@ -0,0 +1,100 @@
# Dependencies
node_modules/
*/node_modules/
.pnpm-store/
# Build outputs
dist/
*/dist/
build/
*/build/
*.tsbuildinfo
# Environment variables (may contain sensitive information)
.env
.env.local
.env.*.local
*/.env
*/.env.local
*/.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing coverage (keep test files for context)
coverage/
.nyc_output/
# Database files
*.db
*.sqlite
*.sqlite3
# Lock files (too large and not needed for context)
pnpm-lock.yaml
package-lock.json
yarn.lock
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
*.tmp
# Prisma migrations SQL files (generated, but may be useful for context)
# Uncomment if you want to ignore migration SQL files:
# backend/prisma/migrations/**/*.sql
# Compiled JavaScript files (keep TypeScript source and config files)
*.js
*.js.map
*.d.ts
!*.config.js
!*.config.ts
!vite.config.js
!tailwind.config.js
!postcss.config.js
# Frontend build artifacts
frontend/dist/
frontend/dist-ssr/
frontend/.vite/
# Backend build artifacts
backend/dist/
# Large binary files
*.zip
*.tar.gz
*.rar
*.7z
# Documentation build outputs (if any)
docs/_build/
docs/.vuepress/dist/
# Cache directories
.cache/
.parcel-cache/
.next/
.nuxt/
.vuepress/dist/
# Temporary files
tmp/
temp/
*.tmp
*.temp

293
.cursorrules Normal file
View File

@ -0,0 +1,293 @@
# Competition Management System - Cursor User Rules (DEPRECATED)
⚠️ **此文件已废弃** - 请使用新的规则系统:
- 项目规则:`.cursor/rules/*.mdc`
- 快速参考:`AGENTS.md`
- 说明文档:`.cursor/RULES_README.md`
---
以下内容保留作为备份,但不再使用:
# Competition Management System - Cursor User Rules
## 项目概述
这是一个多租户的竞赛管理系统,采用前后端分离架构:
- **后端**: NestJS + TypeScript + Prisma + MySQL
- **前端**: Vue 3 + TypeScript + Vite + Ant Design Vue + Pinia
- **认证**: JWT + RBAC (基于角色的访问控制)
- **架构**: 多租户架构,数据完全隔离
## 后端开发规范
### 1. 模块结构
- 每个功能模块应包含:`module.ts`, `controller.ts`, `service.ts`, `dto/` 目录
- 模块命名使用复数形式(如 `users`, `roles`, `contests`
- 子模块放在父模块目录下(如 `contests/works/`, `contests/teams/`
### 2. 服务层 (Service)
- 所有数据库操作必须通过 PrismaService禁止直接使用 SQL
- 服务方法必须处理租户隔离:所有查询必须包含 `tenantId` 条件
- 使用 `@Injectable()` 装饰器
- 构造函数注入依赖,使用 private readonly
- 方法命名:`create`, `findAll`, `findOne`, `update`, `remove`
- 查询方法应支持分页:使用 `skip` 和 `take` 参数
### 3. 控制器层 (Controller)
- 使用 `@Controller()` 装饰器,路径使用复数形式
- 所有路由默认需要认证(除非使用 `@Public()` 装饰器)
- 使用 `@Get()`, `@Post()`, `@Put()`, `@Delete()`, `@Patch()` 装饰器
- 从请求中获取租户ID使用 `@CurrentTenantId()` 装饰器或从 JWT token 中提取
- 使用 `@CurrentUser()` 装饰器获取当前用户信息
- 权限控制:使用 `@RequirePermission()` 装饰器
- 返回统一响应格式:使用 TransformInterceptor自动处理
### 4. DTO (Data Transfer Object)
- 所有 DTO 放在 `dto/` 目录下
- 使用 `class-validator` 进行验证
- 命名规范:
- 创建:`CreateXxxDto`
- 更新:`UpdateXxxDto`
- 查询:`QueryXxxDto`
- 必填字段使用验证装饰器(如 `@IsString()`, `@IsNumber()`
- 可选字段使用 `@IsOptional()`
- 数组字段使用 `@IsArray()` 和 `@IsNumber({}, { each: true })`
### 5. 数据库操作 (Prisma)
- 所有表必须包含 `tenantId` 字段(租户隔离)
- 所有表必须包含审计字段:`creator`, `modifier`, `createTime`, `modifyTime`
- 使用 Prisma 的 `include` 和 `select` 优化查询
- 关联查询使用嵌套 include避免 N+1 问题
- 删除操作使用软删除(`validState` 字段)或级联删除
- 事务操作使用 `prisma.$transaction()`
### 6. 多租户处理
- **必须**:所有业务数据查询必须包含 `tenantId` 条件
- 从 JWT token 或请求头中获取租户ID
- 创建数据时自动设置 `tenantId`
- 更新/删除时验证数据属于当前租户
- 超级租户(`isSuper = 1`)可以访问所有租户数据
### 7. 权限控制
- 使用 `@RequirePermission()` 装饰器进行权限检查
- 权限字符串格式:`模块:操作`(如 `contest:create`, `user:update`
- 角色权限通过 RolesGuard 自动检查
- 权限验证失败返回 403 Forbidden
### 8. 错误处理
- 使用 NestJS 内置异常:`NotFoundException`, `BadRequestException`, `UnauthorizedException`, `ForbiddenException`
- 自定义异常消息使用中文
- 错误信息要清晰明确,便于调试
### 9. 代码风格
- 使用 TypeScript 严格模式
- 使用 ESLint 和 Prettier 格式化代码
- 导入顺序NestJS 核心 → 第三方库 → 本地模块
- 使用 async/await避免 Promise.then()
- 使用解构赋值提高代码可读性
## 前端开发规范
### 1. 组件结构
- 页面组件放在 `views/` 目录下,按模块组织
- 公共组件放在 `components/` 目录下
- 使用 `<script setup lang="ts">` 语法
- 组件命名使用 PascalCase
### 2. API 调用
- 所有 API 调用放在 `api/` 目录下,按模块组织
- 使用 axios 实例(已配置拦截器)
- API 函数命名:`getXxx`, `createXxx`, `updateXxx`, `deleteXxx`
- 使用 TypeScript 类型定义请求和响应
### 3. 状态管理 (Pinia)
- Store 文件放在 `stores/` 目录下
- 使用 `defineStore()` 定义 store
- Store 命名使用 camelCase + Store 后缀(如 `authStore`
### 4. 路由管理
- 路由配置在 `router/index.ts`
- 支持动态路由(基于菜单权限)
- 路由路径包含租户编码:`/:tenantCode/xxx`
- 路由 meta 包含权限信息:`permissions`, `roles`
### 5. 表单验证
- 使用 VeeValidate + Zod 进行表单验证
- 验证规则定义在组件内或单独的 schema 文件
- 错误提示使用中文
### 6. UI 组件
- 使用 Ant Design Vue 组件库
- 样式使用 Tailwind CSS + SCSS
- 响应式设计:移动端优先
- 组件要有 loading 和 error 状态
### 7. 类型定义
- TypeScript 类型定义放在 `types/` 目录下
- 接口类型使用 `interface`,数据模型使用 `type`
- 导出类型供其他模块使用
## 数据库设计规范
### 1. 表结构
- 所有业务表必须包含 `tenantId` 字段
- 所有表必须包含审计字段:
- `creator`: Int? - 创建人ID
- `modifier`: Int? - 修改人ID
- `createTime`: DateTime @default(now()) - 创建时间
- `modifyTime`: DateTime @updatedAt - 修改时间
- 状态字段使用 `validState`: Int @default(1)1-有效2-失效)
- 表名使用复数形式,映射使用 `@@map("table_name")`
### 2. 关系设计
- 使用 Prisma 关系定义外键
- 级联删除:`onDelete: Cascade`
- 可选关联:`onDelete: SetNull`
- 一对一关系:使用 `?` 标记可选
### 3. 索引
- 主键自动索引
- 外键字段自动索引
- 查询频繁的字段添加索引
- 唯一约束使用 `@unique`
### 4. 迁移
- 使用 Prisma Migrate 管理数据库迁移
- 迁移文件命名:`YYYYMMDDHHMMSS_description`
- 迁移前备份数据库
- 生产环境使用 `prisma migrate deploy`
## 通用开发规范
### 1. Git 提交
- 提交信息使用中文
- 格式:`类型: 描述`(如 `feat: 添加竞赛管理功能`
- 类型:`feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
### 2. 注释
- 复杂逻辑必须添加注释
- 函数注释说明参数和返回值
- 使用中文注释
### 3. 测试
- 单元测试文件命名:`*.spec.ts`
- 测试覆盖率要求:核心业务逻辑 > 80%
- 使用 Jest 进行测试
### 4. 环境配置
- 使用 `.env.development` 和 `.env.production`
- 敏感信息不要提交到 Git
- 配置项通过 `@nestjs/config` 管理
### 5. 日志
- 使用 NestJS Logger
- 日志级别:`error`, `warn`, `log`, `debug`, `verbose`
- 记录关键操作和错误信息
## 安全规范
### 1. 认证授权
- 所有 API 默认需要 JWT 认证
- 密码使用 bcrypt 加密salt rounds: 10
- Token 过期时间合理设置
- 敏感操作需要额外验证
### 2. 数据验证
- 前端和后端都要进行数据验证
- 使用 DTO 和 class-validator 验证输入
- 防止 SQL 注入:使用 Prisma参数化查询
- 防止 XSS前端转义用户输入
### 3. 权限控制
- 最小权限原则
- 前端显示控制 + 后端权限验证
- 租户数据隔离必须严格检查
## 性能优化
### 1. 数据库查询
- 避免 N+1 查询,使用 `include` 预加载
- 使用 `select` 只查询需要的字段
- 分页查询必须实现
- 大表查询添加索引
### 2. API 响应
- 响应数据精简,避免返回不必要字段
- 使用分页减少单次数据量
- 长时间操作使用异步处理
### 3. 前端优化
- 路由懒加载
- 组件按需加载
- 图片使用 CDN 或压缩
- 避免不必要的重新渲染
## 代码审查检查清单
- [ ] 所有数据库查询包含 `tenantId` 条件
- [ ] DTO 验证规则完整
- [ ] 错误处理完善
- [ ] 权限检查正确
- [ ] 代码格式符合规范
- [ ] 注释清晰
- [ ] 无硬编码配置
- [ ] 类型定义完整
- [ ] 无控制台日志(生产环境)
## 常见问题
### Q: 如何获取当前租户ID
A: 在控制器中使用 `@CurrentTenantId()` 装饰器,或在服务中从 JWT token 提取。
### Q: 如何创建新的业务模块?
A:
1. 在 Prisma schema 中定义模型
2. 运行 `prisma migrate dev`
3. 创建模块目录和文件module, controller, service, dto
4. 在 `app.module.ts` 中注册模块
5. 创建前端 API 和页面
### Q: 如何处理多租户数据隔离?
A:
- 查询时始终包含 `where: { tenantId }`
- 创建时自动设置 `tenantId`
- 更新/删除前验证数据属于当前租户
### Q: 如何添加新的权限?
A:
1. 在数据库中创建权限记录
2. 在路由或控制器方法上使用 `@RequirePermission('module:action')`
3. 在前端路由 meta 中添加 `permissions` 字段

46
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,8 @@
# pnpm 配置
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true
# 使用国内镜像(可选,根据需要取消注释)
# registry=https://registry.npmmirror.com

2
.nvmrc Normal file
View File

@ -0,0 +1,2 @@
18.0.0

128
AGENTS.md Normal file
View File

@ -0,0 +1,128 @@
# Competition Management System - Agent Instructions
这是一个多租户的竞赛管理系统,采用 NestJS + Vue 3 技术栈。
## 🚨 最重要的规则
### 多租户数据隔离
**所有数据库查询必须包含 `tenantId` 条件!** 这是系统安全的核心。
```typescript
// ✅ 正确
const users = await prisma.user.findMany({
where: { tenantId, validState: 1 }
});
// ❌ 错误 - 绝对不允许
const users = await prisma.user.findMany();
```
## 后端开发
### 模块结构
- 使用 NestJS 标准模块结构module、controller、service、dto
- 所有数据操作通过 Prisma禁止直接 SQL
- 使用 `@Injectable()`、`@Controller()` 装饰器
### 权限控制
- 使用 `@RequirePermission('module:action')` 装饰器
- 格式:`user:create`、`contest:update`、`role:delete`
- 所有路由默认需要认证,公开接口使用 `@Public()`
### DTO 验证
```typescript
export class CreateUserDto {
@IsString()
username: string;
@IsEmail()
@IsOptional()
email?: string;
}
```
### 错误处理
- 使用 NestJS 内置异常,消息用中文
- `NotFoundException`、`BadRequestException`、`ForbiddenException`
## 前端开发
### 组件开发
- 使用 Vue 3 `<script setup lang="ts">` 语法
- 页面组件放在 `views/` 目录,按模块组织
- 使用 Ant Design Vue 组件库
### API 调用
- API 文件放在 `api/` 目录,按模块组织
- 函数命名:`getXxx`、`createXxx`、`updateXxx`、`deleteXxx`
- 使用 TypeScript 类型定义
### 路由
- 路由路径必须包含租户编码:`/:tenantCode/users`
- 使用动态路由(基于菜单权限)
### 状态管理
- 使用 Piniastore 命名:`useAuthStore`、`useUserStore`
## 数据库设计
### 必需字段
所有业务表必须包含:
```prisma
model YourModel {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id")
validState Int @default(1) @map("valid_state")
creator Int?
modifier Int?
createTime DateTime @default(now()) @map("create_time")
modifyTime DateTime @updatedAt @map("modify_time")
}
```
### 性能优化
- 使用 `include` 预加载,避免 N+1 查询
- 使用 `select` 精简字段
- 实现分页查询
## 代码风格
- TypeScript 严格模式
- 使用 async/await避免 Promise.then()
- 使用中文注释
- Git 提交信息:`类型: 描述`(如 `feat: 添加用户管理`
## 快速参考
### 创建新模块
1. 在 Prisma schema 定义模型
2. 运行 `pnpm prisma:migrate:dev`
3. 创建 NestJS 模块module、controller、service、dto
4. 在 `app.module.ts` 注册
5. 创建前端 API 和页面
### 常用装饰器
- `@CurrentTenantId()` - 获取租户ID
- `@CurrentUser()` - 获取当前用户
- `@RequirePermission()` - 权限检查
- `@Public()` - 公开接口
### 开发命令
```bash
# 后端
cd backend
pnpm start:dev # 启动开发服务器
pnpm prisma:migrate:dev # 数据库迁移
pnpm init:admin # 初始化管理员
# 前端
cd frontend
pnpm dev # 启动开发服务器
pnpm build # 构建生产版本
```
---
💡 **记住**:租户隔离是系统的核心安全机制,所有数据操作都必须验证 `tenantId`

304
README.md
View File

@ -1,3 +1,303 @@
# library-picturebook-workshop
# 比赛管理系统
一个基于 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
# 或使用 corepackNode.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
图书馆绘本创作活动 - 幼儿绘本创作与展示平台

View File

@ -0,0 +1,122 @@
---
description: 后端特定的开发规范(仅作用于 backend 目录)
globs:
alwaysApply: true
---
# 后端特定规范
本规则仅作用于 `backend/` 目录。
## NestJS 最佳实践
### 依赖注入
始终使用构造函数注入:
```typescript
@Injectable()
export class MyService {
constructor(
private readonly prisma: PrismaService,
private readonly otherService: OtherService,
) {}
}
```
### 全局模块
PrismaModule 已设为全局模块,无需在每个模块中导入。
### 环境变量
使用 `@nestjs/config` 的 ConfigService
```typescript
constructor(private configService: ConfigService) {
const jwtSecret = this.configService.get<string>('JWT_SECRET');
}
```
## 测试规范
### 单元测试
```typescript
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UsersService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
prisma = module.get<PrismaService>(PrismaService);
});
it('should create a user', async () => {
const dto = { username: 'test', password: 'pass123' };
const result = await service.create(dto, 1);
expect(result).toBeDefined();
});
});
```
## 日志记录
使用 NestJS Logger
```typescript
import { Logger } from '@nestjs/common';
export class MyService {
private readonly logger = new Logger(MyService.name);
async someMethod() {
this.logger.log('执行某操作');
this.logger.warn('警告信息');
this.logger.error('错误信息', error.stack);
}
}
```
## 常用脚本
```bash
# 开发
pnpm start:dev
# 数据库迁移
pnpm prisma:migrate:dev
# 初始化管理员
pnpm init:admin
# 初始化菜单
pnpm init:menus
```
## 项目结构
```
src/
├── auth/ # 认证模块
├── users/ # 用户管理
├── roles/ # 角色管理
├── permissions/ # 权限管理
├── menus/ # 菜单管理
├── tenants/ # 租户管理
├── school/ # 学校管理
├── contests/ # 竞赛管理
├── common/ # 公共模块
├── prisma/ # Prisma 服务
└── main.ts # 入口文件
```

26
backend/.eslintrc.js Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
# 后端 pnpm 配置
shamefully-hoist=true
strict-peer-dependencies=false

5
backend/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all"
}

172
backend/data/menus.json Normal file
View File

@ -0,0 +1,172 @@
[
{
"name": "工作台",
"path": "/workbench",
"icon": "DashboardOutlined",
"component": "workbench/Index",
"parentId": null,
"sort": 1,
"permission": null
},
{
"name": "学校管理",
"path": "/school",
"icon": "BankOutlined",
"component": null,
"parentId": null,
"sort": 5,
"permission": null,
"children": [
{
"name": "学校信息",
"path": "/school/schools",
"icon": "BankOutlined",
"component": "school/schools/Index",
"sort": 1,
"permission": "school:read"
},
{
"name": "部门管理",
"path": "/school/departments",
"icon": "ApartmentOutlined",
"component": "school/departments/Index",
"sort": 2,
"permission": "department:read"
},
{
"name": "年级管理",
"path": "/school/grades",
"icon": "AppstoreOutlined",
"component": "school/grades/Index",
"sort": 3,
"permission": "grade:read"
},
{
"name": "班级管理",
"path": "/school/classes",
"icon": "TeamOutlined",
"component": "school/classes/Index",
"sort": 4,
"permission": "class:read"
},
{
"name": "教师管理",
"path": "/school/teachers",
"icon": "UserOutlined",
"component": "school/teachers/Index",
"sort": 5,
"permission": "teacher:read"
},
{
"name": "学生管理",
"path": "/school/students",
"icon": "UsergroupAddOutlined",
"component": "school/students/Index",
"sort": 6,
"permission": "student:read"
}
]
},
{
"name": "赛事管理",
"path": "/contests",
"icon": "TrophyOutlined",
"component": null,
"parentId": null,
"sort": 6,
"permission": null,
"children": [
{
"name": "赛事列表",
"path": "/contests",
"icon": "UnorderedListOutlined",
"component": "contests/Index",
"sort": 1,
"permission": "contest:read"
},
{
"name": "报名管理",
"path": "/contests/registrations",
"icon": "FormOutlined",
"component": "contests/registrations/Index",
"sort": 2,
"permission": "contest:registration:read"
},
{
"name": "作品管理",
"path": "/contests/works",
"icon": "FileTextOutlined",
"component": "contests/works/Index",
"sort": 3,
"permission": "contest:work:read"
},
{
"name": "评审管理",
"path": "/contests/reviews",
"icon": "CheckCircleOutlined",
"component": "contests/reviews/Index",
"sort": 4,
"permission": "contest:review:read"
}
]
},
{
"name": "系统管理",
"path": "/system",
"icon": "SettingOutlined",
"component": null,
"parentId": null,
"sort": 10,
"permission": null,
"children": [
{
"name": "用户管理",
"path": "/system/users",
"icon": "UserOutlined",
"component": "system/users/Index",
"sort": 1,
"permission": "user:read"
},
{
"name": "角色管理",
"path": "/system/roles",
"icon": "TeamOutlined",
"component": "system/roles/Index",
"sort": 2,
"permission": "role:read"
},
{
"name": "菜单管理",
"path": "/system/menus",
"icon": "MenuOutlined",
"component": "system/menus/Index",
"sort": 3,
"permission": "menu:read"
},
{
"name": "数据字典",
"path": "/system/dict",
"icon": "BookOutlined",
"component": "system/dict/Index",
"sort": 4,
"permission": "dict:read"
},
{
"name": "系统配置",
"path": "/system/config",
"icon": "ToolOutlined",
"component": "system/config/Index",
"sort": 5,
"permission": "config:read"
},
{
"name": "日志记录",
"path": "/system/logs",
"icon": "FileTextOutlined",
"component": "system/logs/Index",
"sort": 6,
"permission": "log:read"
}
]
}
]

View File

@ -0,0 +1,618 @@
[
{
"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": "user:password:update",
"resource": "user",
"action": "password:update",
"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": "school:create",
"resource": "school",
"action": "create",
"name": "创建学校",
"description": "允许创建学校信息"
},
{
"code": "school:read",
"resource": "school",
"action": "read",
"name": "查看学校",
"description": "允许查看学校信息"
},
{
"code": "school:update",
"resource": "school",
"action": "update",
"name": "更新学校",
"description": "允许更新学校信息"
},
{
"code": "school:delete",
"resource": "school",
"action": "delete",
"name": "删除学校",
"description": "允许删除学校信息"
},
{
"code": "grade:create",
"resource": "grade",
"action": "create",
"name": "创建年级",
"description": "允许创建年级"
},
{
"code": "grade:read",
"resource": "grade",
"action": "read",
"name": "查看年级",
"description": "允许查看年级列表和详情"
},
{
"code": "grade:update",
"resource": "grade",
"action": "update",
"name": "更新年级",
"description": "允许更新年级信息"
},
{
"code": "grade:delete",
"resource": "grade",
"action": "delete",
"name": "删除年级",
"description": "允许删除年级"
},
{
"code": "class:create",
"resource": "class",
"action": "create",
"name": "创建班级",
"description": "允许创建班级"
},
{
"code": "class:read",
"resource": "class",
"action": "read",
"name": "查看班级",
"description": "允许查看班级列表和详情"
},
{
"code": "class:update",
"resource": "class",
"action": "update",
"name": "更新班级",
"description": "允许更新班级信息"
},
{
"code": "class:delete",
"resource": "class",
"action": "delete",
"name": "删除班级",
"description": "允许删除班级"
},
{
"code": "department:create",
"resource": "department",
"action": "create",
"name": "创建部门",
"description": "允许创建部门"
},
{
"code": "department:read",
"resource": "department",
"action": "read",
"name": "查看部门",
"description": "允许查看部门列表和详情"
},
{
"code": "department:update",
"resource": "department",
"action": "update",
"name": "更新部门",
"description": "允许更新部门信息"
},
{
"code": "department:delete",
"resource": "department",
"action": "delete",
"name": "删除部门",
"description": "允许删除部门"
},
{
"code": "teacher:create",
"resource": "teacher",
"action": "create",
"name": "创建教师",
"description": "允许创建教师"
},
{
"code": "teacher:read",
"resource": "teacher",
"action": "read",
"name": "查看教师",
"description": "允许查看教师列表和详情"
},
{
"code": "teacher:update",
"resource": "teacher",
"action": "update",
"name": "更新教师",
"description": "允许更新教师信息"
},
{
"code": "teacher:delete",
"resource": "teacher",
"action": "delete",
"name": "删除教师",
"description": "允许删除教师"
},
{
"code": "student:create",
"resource": "student",
"action": "create",
"name": "创建学生",
"description": "允许创建学生"
},
{
"code": "student:read",
"resource": "student",
"action": "read",
"name": "查看学生",
"description": "允许查看学生列表和详情"
},
{
"code": "student:update",
"resource": "student",
"action": "update",
"name": "更新学生",
"description": "允许更新学生信息"
},
{
"code": "student:delete",
"resource": "student",
"action": "delete",
"name": "删除学生",
"description": "允许删除学生"
},
{
"code": "contest:create",
"resource": "contest",
"action": "create",
"name": "创建比赛",
"description": "允许创建比赛"
},
{
"code": "contest:read",
"resource": "contest",
"action": "read",
"name": "查看比赛",
"description": "允许查看比赛列表和详情"
},
{
"code": "contest:update",
"resource": "contest",
"action": "update",
"name": "更新比赛",
"description": "允许更新比赛信息"
},
{
"code": "contest:delete",
"resource": "contest",
"action": "delete",
"name": "删除比赛",
"description": "允许删除比赛"
},
{
"code": "contest:publish",
"resource": "contest",
"action": "publish",
"name": "发布比赛",
"description": "允许发布比赛"
},
{
"code": "contest:team:create",
"resource": "contest:team",
"action": "create",
"name": "创建团队",
"description": "允许创建比赛团队"
},
{
"code": "contest:team:read",
"resource": "contest:team",
"action": "read",
"name": "查看团队",
"description": "允许查看团队列表和详情"
},
{
"code": "contest:team:update",
"resource": "contest:team",
"action": "update",
"name": "更新团队",
"description": "允许更新团队信息"
},
{
"code": "contest:team:delete",
"resource": "contest:team",
"action": "delete",
"name": "删除团队",
"description": "允许删除团队"
},
{
"code": "contest:team:manage",
"resource": "contest:team",
"action": "manage",
"name": "管理团队成员",
"description": "允许管理团队成员"
},
{
"code": "contest:review:create",
"resource": "contest:review",
"action": "create",
"name": "创建评审规则",
"description": "允许创建评审规则"
},
{
"code": "contest:review:read",
"resource": "contest:review",
"action": "read",
"name": "查看评审",
"description": "允许查看评审规则和评审记录"
},
{
"code": "contest:review:update",
"resource": "contest:review",
"action": "update",
"name": "更新评审规则",
"description": "允许更新评审规则"
},
{
"code": "contest:review:delete",
"resource": "contest:review",
"action": "delete",
"name": "删除评审规则",
"description": "允许删除评审规则"
},
{
"code": "contest:review:assign",
"resource": "contest:review",
"action": "assign",
"name": "分配评审任务",
"description": "允许分配评审任务给评委"
},
{
"code": "contest:review:score",
"resource": "contest:review",
"action": "score",
"name": "评审打分",
"description": "允许对作品进行评审打分"
},
{
"code": "contest:judge:create",
"resource": "contest:judge",
"action": "create",
"name": "添加评委",
"description": "允许添加比赛评委"
},
{
"code": "contest:judge:read",
"resource": "contest:judge",
"action": "read",
"name": "查看评委",
"description": "允许查看评委列表"
},
{
"code": "contest:judge:update",
"resource": "contest:judge",
"action": "update",
"name": "更新评委",
"description": "允许更新评委信息"
},
{
"code": "contest:judge:delete",
"resource": "contest:judge",
"action": "delete",
"name": "删除评委",
"description": "允许删除评委"
},
{
"code": "contest:work:create",
"resource": "contest:work",
"action": "create",
"name": "创建作品",
"description": "允许创建参赛作品"
},
{
"code": "contest:work:read",
"resource": "contest:work",
"action": "read",
"name": "查看作品",
"description": "允许查看作品列表和详情"
},
{
"code": "contest:work:update",
"resource": "contest:work",
"action": "update",
"name": "更新作品",
"description": "允许更新作品信息"
},
{
"code": "contest:work:delete",
"resource": "contest:work",
"action": "delete",
"name": "删除作品",
"description": "允许删除作品"
},
{
"code": "contest:work:submit",
"resource": "contest:work",
"action": "submit",
"name": "提交作品",
"description": "允许提交作品"
},
{
"code": "contest:work:review",
"resource": "contest:work",
"action": "review",
"name": "审核作品",
"description": "允许审核作品状态"
},
{
"code": "contest:registration:create",
"resource": "contest:registration",
"action": "create",
"name": "创建报名",
"description": "允许创建报名记录"
},
{
"code": "contest:registration:read",
"resource": "contest:registration",
"action": "read",
"name": "查看报名",
"description": "允许查看报名列表和详情"
},
{
"code": "contest:registration:update",
"resource": "contest:registration",
"action": "update",
"name": "更新报名",
"description": "允许更新报名信息"
},
{
"code": "contest:registration:delete",
"resource": "contest:registration",
"action": "delete",
"name": "删除报名",
"description": "允许删除报名记录"
},
{
"code": "contest:registration:approve",
"resource": "contest:registration",
"action": "approve",
"name": "审核报名",
"description": "允许审核报名(通过/拒绝)"
},
{
"code": "contest:notice:create",
"resource": "contest:notice",
"action": "create",
"name": "创建公告",
"description": "允许创建比赛公告"
},
{
"code": "contest:notice:read",
"resource": "contest:notice",
"action": "read",
"name": "查看公告",
"description": "允许查看公告列表和详情"
},
{
"code": "contest:notice:update",
"resource": "contest:notice",
"action": "update",
"name": "更新公告",
"description": "允许更新公告信息"
},
{
"code": "contest:notice:delete",
"resource": "contest:notice",
"action": "delete",
"name": "删除公告",
"description": "允许删除公告"
},
{
"code": "contest:notice:publish",
"resource": "contest:notice",
"action": "publish",
"name": "发布公告",
"description": "允许发布公告"
}
]

View 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. ✅ 开始使用系统

View File

@ -0,0 +1,271 @@
# 比赛评委存储设计说明
## 📋 设计决策
### 问题:是否需要专门的评委表?
**结论:需要创建 `ContestJudge` 关联表,但不需要单独的评委信息表。**
## 🎯 设计分析
### 1. 为什么需要 `ContestJudge` 关联表?
#### 1.1 业务需求
1. **比赛与评委的多对多关系**
- 一个比赛可以有多个评委
- 一个评委可以评审多个比赛
- 需要管理这种多对多关系
2. **评委管理功能**
- 查询某个比赛的所有评委列表
- 批量添加/删除比赛的评委
- 管理评委在特定比赛中的特殊信息
3. **评委特殊属性**
- 评审专业领域specialty如"创意设计"、"技术实现"等
- 评审权重weight用于加权平均计算最终得分
- 评委说明description在该比赛中的特殊说明
#### 1.2 当前设计的不足
**之前的设计**
- 评委信息只存储在 `ContestWorkJudgeAssignment` 表中
- 无法直接查询某个比赛有哪些评委
- 无法批量管理比赛的评委列表
- 无法存储评委在特定比赛中的特殊信息
**问题场景**
```typescript
// ❌ 无法直接查询比赛的评委列表
// 需要从作品分配表中去重查询,效率低且不准确
// ✅ 有了 ContestJudge 表后
const judges = await prisma.contestJudge.findMany({
where: { contestId: contestId }
});
```
### 2. 为什么不需要单独的评委信息表?
#### 2.1 评委就是用户
- 评委本身就是系统中的 `User`
- 基本信息(姓名、账号等)存储在 `User`
- 如果是教师,详细信息存储在 `Teacher`
- 如果是外部专家,可以创建普通 `User` 账号
#### 2.2 统一用户体系
- 系统采用统一的用户体系
- 通过角色和权限区分用户身份
- 评委通过角色(如 `judge`)和权限(如 `review:score`)控制
## 📊 数据模型设计
### 1. 表结构
```prisma
model ContestJudge {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 比赛id
judgeId Int @map("judge_id") /// 评委用户id
specialty String? /// 评审专业领域(可选)
weight Decimal? @db.Decimal(3, 2) /// 评审权重(可选)
description String? @db.Text /// 评委在该比赛中的说明
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time")
modifyTime DateTime @updatedAt @map("modify_time")
validState Int @default(1) @map("valid_state")
contest Contest @relation(fields: [contestId], references: [id])
judge User @relation(fields: [judgeId], references: [id])
@@unique([contestId, judgeId])
@@index([contestId])
@@index([judgeId])
}
```
### 2. 数据关系
```
Contest (比赛)
↓ (1:N)
ContestJudge (比赛评委关联)
↓ (N:1)
User (用户/评委)
↓ (1:1, 可选)
Teacher (教师信息,如果是教师评委)
```
### 3. 与其他表的关系
```
ContestJudge (比赛评委)
ContestWorkJudgeAssignment (作品分配)
ContestWorkScore (作品评分)
```
**关系说明**
- `ContestJudge`:定义哪些评委可以评审某个比赛
- `ContestWorkJudgeAssignment`:将具体作品分配给评委
- `ContestWorkScore`:记录评委的评分结果
## 🔄 业务流程
### 1. 添加评委到比赛
```typescript
// 1. 创建比赛评委关联
const contestJudge = await prisma.contestJudge.create({
data: {
contestId: contestId,
judgeId: userId,
specialty: "创意设计",
weight: 1.2, // 权重1.2倍
description: "专业设计领域评委"
}
});
```
### 2. 查询比赛的评委列表
```typescript
// 查询某个比赛的所有评委
const judges = await prisma.contestJudge.findMany({
where: {
contestId: contestId,
validState: 1
},
include: {
judge: {
include: {
teacher: true // 如果是教师,获取教师信息
}
}
}
});
```
### 3. 分配作品给评委
```typescript
// 分配作品时,验证评委是否属于该比赛
const contestJudge = await prisma.contestJudge.findFirst({
where: {
contestId: contestId,
judgeId: judgeId,
validState: 1
}
});
if (!contestJudge) {
throw new Error('该评委不属于此比赛');
}
// 创建作品分配记录
const assignment = await prisma.contestWorkJudgeAssignment.create({
data: {
contestId: contestId,
workId: workId,
judgeId: judgeId
}
});
```
### 4. 计算加权平均分
```typescript
// 获取所有评委的评分和权重
const scores = await prisma.contestWorkScore.findMany({
where: { workId: workId },
include: {
judge: {
include: {
contestJudges: {
where: { contestId: contestId }
}
}
}
}
});
// 计算加权平均分
let totalWeightedScore = 0;
let totalWeight = 0;
for (const score of scores) {
const contestJudge = score.judge.contestJudges[0];
const weight = contestJudge?.weight || 1.0;
totalWeightedScore += score.totalScore * weight;
totalWeight += weight;
}
const finalScore = totalWeightedScore / totalWeight;
```
## ✅ 设计优势
### 1. 清晰的业务逻辑
- **比赛评委管理**:通过 `ContestJudge` 表统一管理
- **作品分配**:通过 `ContestWorkJudgeAssignment` 表管理
- **评分记录**:通过 `ContestWorkScore` 表记录
### 2. 灵活的扩展性
- 支持评委专业领域分类
- 支持评审权重设置
- 支持评委说明信息
### 3. 高效的查询
- 直接查询比赛的评委列表
- 支持评委维度的统计分析
- 支持权重计算
### 4. 数据一致性
- 通过外键约束保证数据完整性
- 删除比赛时,级联删除评委关联
- 删除用户时,级联删除评委关联
## 📝 使用建议
### 1. 评委添加流程
```
1. 确保用户已创建User 表)
2. 为用户分配评委角色和权限
3. 创建 ContestJudge 记录,关联比赛和用户
4. 可选:设置专业领域、权重等信息
```
### 2. 评委验证
在分配作品给评委时,应该验证:
- 该评委是否属于该比赛(查询 `ContestJudge` 表)
- 该评委是否有效(`validState = 1`
### 3. 权限控制
- 只有比赛管理员可以添加/删除评委
- 评委只能查看和评分分配给自己的作品
- 通过 RBAC 权限系统控制访问
## 🔍 总结
**评委存储方案**
- ✅ **需要** `ContestJudge` 关联表:管理比赛与评委的多对多关系
- ❌ **不需要** 单独的评委信息表:评委信息通过 `User``Teacher` 表存储
**核心设计原则**
1. 评委就是用户,统一用户体系
2. 通过关联表管理比赛与评委的关系
3. 支持评委在特定比赛中的特殊属性
4. 保证数据一致性和查询效率

View 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
```
**注意:** 这会删除所有数据,仅用于开发环境!

View 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` 接口

View 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
```

View 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
View 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)

View 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) - 数据库设置指南

View 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)

View 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
View File

@ -0,0 +1,397 @@
# RBAC 权限控制详解
## 📚 什么是 RBAC
**RBACRole-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 TokenToken 中包含用户 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
View 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: 创建文档索引,归档所有指南文件

View 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` 更新类型

View File

@ -0,0 +1,301 @@
# 学校模块数据库设计文档
## 概述
本文档描述了学校管理系统的数据库表设计,包括学校信息、年级、班级、部门、教师和学生等核心实体。
## 设计原则
1. **租户隔离**:所有表都通过 `tenantId` 关联到 `Tenant` 表,实现多租户数据隔离
2. **用户统一**:教师和学生都基于 `User` 表,通过一对一关系扩展特定信息
3. **数据完整性**:使用外键约束和级联删除保证数据一致性
4. **审计追踪**:所有表都包含创建人、修改人、创建时间、修改时间字段
## 表结构设计
### 1. 学校信息表 (School)
**说明**:扩展租户信息,存储学校的详细资料。与 `Tenant` 表一对一关系。
**字段说明**
- `id`: 主键
- `tenantId`: 租户ID唯一一对一关联Tenant
- `address`: 学校地址
- `phone`: 联系电话
- `principal`: 校长姓名
- `established`: 建校时间
- `description`: 学校描述
- `logo`: 学校Logo URL
- `website`: 学校网站
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**关系**
- 一对一关联 `Tenant`
- 多对一关联 `User` (创建人/修改人)
---
### 2. 年级表 (Grade)
**说明**:管理学校的年级信息,如一年级、二年级等。
**字段说明**
- `id`: 主键
- `tenantId`: 租户ID
- `name`: 年级名称(如:一年级、二年级)
- `code`: 年级编码在租户内唯一grade_1, grade_2
- `level`: 年级级别用于排序1, 2, 3
- `description`: 年级描述
- `validState`: 有效状态1-有效2-失效)
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**唯一约束**
- `[tenantId, code]`: 租户内年级编码唯一
- `[tenantId, level]`: 租户内年级级别唯一
**关系**
- 多对一关联 `Tenant`
- 一对多关联 `Class` (班级)
- 多对一关联 `User` (创建人/修改人)
---
### 3. 部门表 (Department)
**说明**:管理学校的部门信息,支持树形结构(如:教务处 > 语文组)。
**字段说明**
- `id`: 主键
- `tenantId`: 租户ID
- `name`: 部门名称
- `code`: 部门编码(在租户内唯一)
- `parentId`: 父部门ID支持树形结构
- `description`: 部门描述
- `sort`: 排序
- `validState`: 有效状态1-有效2-失效)
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**唯一约束**
- `[tenantId, code]`: 租户内部门编码唯一
**关系**
- 多对一关联 `Tenant`
- 自关联(树形结构):`parent` 和 `children`
- 一对多关联 `Teacher` (教师)
- 多对一关联 `User` (创建人/修改人)
---
### 4. 班级表 (Class)
**说明**:管理班级信息,支持行政班级(教学班级)和兴趣班两种类型。
**字段说明**
- `id`: 主键
- `tenantId`: 租户ID
- `gradeId`: 年级ID
- `name`: 班级名称一年级1班、二年级2班
- `code`: 班级编码(在租户内唯一)
- `type`: 班级类型1-行政班级/教学班级2-兴趣班)
- `capacity`: 班级容量(可选)
- `description`: 班级描述
- `validState`: 有效状态1-有效2-失效)
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**唯一约束**
- `[tenantId, code]`: 租户内班级编码唯一
**关系**
- 多对一关联 `Tenant`
- 多对一关联 `Grade` (年级)
- 一对多关联 `Student` (学生,仅行政班级)
- 一对多关联 `StudentInterestClass` (学生兴趣班关联)
- 多对一关联 `User` (创建人/修改人)
**注意事项**
- `students` 关系仅用于行政班级type=1需要在应用层验证
- 兴趣班通过 `StudentInterestClass` 表关联学生
---
### 5. 教师表 (Teacher)
**说明**:存储教师的详细信息,与 `User` 表一对一关系。
**字段说明**
- `id`: 主键
- `userId`: 用户ID唯一一对一关联User
- `tenantId`: 租户ID
- `departmentId`: 部门ID
- `employeeNo`: 工号(在租户内唯一)
- `phone`: 联系电话
- `idCard`: 身份证号
- `gender`: 性别1-男2-女)
- `birthDate`: 出生日期
- `hireDate`: 入职日期
- `subject`: 任教科目(可选,如:语文、数学)
- `title`: 职称(可选,如:高级教师、一级教师)
- `description`: 教师描述
- `validState`: 有效状态1-有效2-失效)
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**唯一约束**
- `userId`: 用户ID唯一一对一
- `[tenantId, employeeNo]`: 租户内工号唯一
**关系**
- 一对一关联 `User`
- 多对一关联 `Tenant`
- 多对一关联 `Department` (部门)
- 多对一关联 `User` (创建人/修改人)
---
### 6. 学生表 (Student)
**说明**:存储学生的详细信息,与 `User` 表一对一关系。
**字段说明**
- `id`: 主键
- `userId`: 用户ID唯一一对一关联User
- `tenantId`: 租户ID
- `classId`: 行政班级ID
- `studentNo`: 学号(在租户内唯一)
- `phone`: 联系电话
- `idCard`: 身份证号
- `gender`: 性别1-男2-女)
- `birthDate`: 出生日期
- `enrollmentDate`: 入学日期
- `parentName`: 家长姓名
- `parentPhone`: 家长电话
- `address`: 家庭地址
- `description`: 学生描述
- `validState`: 有效状态1-有效2-失效)
- `creator/modifier`: 创建人/修改人ID
- `createTime/modifyTime`: 创建/修改时间
**唯一约束**
- `userId`: 用户ID唯一一对一
- `[tenantId, studentNo]`: 租户内学号唯一
**关系**
- 一对一关联 `User`
- 多对一关联 `Tenant`
- 多对一关联 `Class` (行政班级)
- 一对多关联 `StudentInterestClass` (兴趣班关联)
- 多对一关联 `User` (创建人/修改人)
**注意事项**
- `classId` 必须关联行政班级type=1需要在应用层验证
- 兴趣班通过 `StudentInterestClass` 表关联
---
### 7. 学生兴趣班关联表 (StudentInterestClass)
**说明**:学生和兴趣班的多对多关联表。
**字段说明**
- `id`: 主键
- `studentId`: 学生ID
- `classId`: 兴趣班IDtype=2的Class
**唯一约束**
- `[studentId, classId]`: 学生和兴趣班组合唯一
**关系**
- 多对一关联 `Student`
- 多对一关联 `Class` (兴趣班)
**注意事项**
- `classId` 必须关联兴趣班type=2需要在应用层验证
---
## 数据关系图
```
Tenant (租户/学校)
├── School (学校信息) [1:1]
├── Grade (年级) [1:N]
│ └── Class (班级) [1:N]
│ ├── Student (学生) [1:N, 仅行政班级]
│ └── StudentInterestClass [N:M, 仅兴趣班]
├── Department (部门) [1:N, 树形结构]
│ └── Teacher (教师) [1:N]
└── User (用户) [1:N]
├── Teacher [1:1]
└── Student [1:1]
```
## 业务规则
1. **学校与租户**:每个租户对应一个学校,通过 `School` 表扩展学校信息
2. **年级管理**:年级按 `level` 排序,每个租户内级别唯一
3. **班级类型**
- 行政班级type=1学生必须属于一个行政班级
- 兴趣班type=2学生可以加入多个兴趣班
4. **部门树形结构**:部门支持多级嵌套,通过 `parentId` 实现
5. **教师归属**:教师必须归属于一个部门
6. **学生归属**:学生必须属于一个行政班级,可以加入多个兴趣班
## 数据完整性约束
1. **级联删除**
- 删除租户时,级联删除所有相关数据
- 删除年级时,级联删除所有班级
- 删除用户时,级联删除教师/学生信息
- 删除班级时,级联删除学生兴趣班关联
2. **限制删除**
- 删除部门时如果存在教师不允许删除Restrict
- 删除班级时如果存在学生不允许删除Restrict
3. **唯一性约束**
- 租户内年级编码唯一
- 租户内年级级别唯一
- 租户内部门编码唯一
- 租户内班级编码唯一
- 租户内教师工号唯一
- 租户内学生学号唯一
## 应用层验证建议
1. **班级类型验证**
- 创建学生时,`classId` 必须关联 `type=1` 的班级
- 创建学生兴趣班关联时,`classId` 必须关联 `type=2` 的班级
2. **数据一致性**
- 教师/学生的 `tenantId` 必须与关联的 `User.tenantId` 一致
- 班级的 `tenantId` 必须与关联的 `Grade.tenantId` 一致
3. **业务逻辑**
- 删除部门前,需要先转移或删除该部门下的所有教师
- 删除班级前,需要先转移或删除该班级下的所有学生
## 迁移建议
1. 运行 Prisma 迁移生成 SQL
```bash
npx prisma migrate dev --name add_school_module
```
2. 数据初始化:
- 为现有租户创建对应的 `School` 记录
- 根据业务需求初始化年级数据
3. 数据迁移(如需要):
- 如果已有教师/学生数据,需要创建对应的 `User` 记录并关联
## 后续扩展建议
1. **课程管理**:可以添加课程表、课程安排等
2. **成绩管理**:可以添加成绩表、考试表等
3. **考勤管理**:可以添加考勤记录表
4. **通知公告**:可以添加通知表、公告表等

View 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. 租户数据导出和备份

View 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. **租户信息显示**:在用户界面显示当前租户信息,让用户知道自己在哪个租户下操作

View File

@ -0,0 +1 @@
##

9
backend/nest-cli.json Normal file
View File

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

102
backend/package.json Normal file
View File

@ -0,0 +1,102 @@
{
"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:status:dev": "dotenv -e .env.development -- prisma migrate status",
"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_contest_module",
"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",
"init:tenant-permissions": "ts-node scripts/init-tenant-permissions.ts",
"init:tenant-menu-permissions": "ts-node scripts/init-tenant-menu-permissions.ts",
"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/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.3",
"@prisma/client": "^6.19.0",
"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": "^6.19.0",
"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"
}
}

View File

@ -0,0 +1,871 @@
// 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[]
school School? /// 学校信息(一对一)
grades Grade[] /// 年级
departments Department[] /// 部门
classes Class[] /// 班级
teachers Teacher[] /// 教师
students Student[] /// 学生
contestTeams ContestTeam[] /// 赛事团队
contestTeamMembers ContestTeamMember[] /// 团队成员
contestRegistrations ContestRegistration[] /// 赛事报名
contestWorks ContestWork[] /// 参赛作品
contestWorkAttachments ContestWorkAttachment[] /// 作品附件
contestWorkScores ContestWorkScore[] /// 作品评分
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")
teacher Teacher? /// 教师信息(一对一)
student Student? /// 学生信息(一对一)
createdSchools School[] @relation("SchoolCreator")
modifiedSchools School[] @relation("SchoolModifier")
createdGrades Grade[] @relation("GradeCreator")
modifiedGrades Grade[] @relation("GradeModifier")
createdDepartments Department[] @relation("DepartmentCreator")
modifiedDepartments Department[] @relation("DepartmentModifier")
createdClasses Class[] @relation("ClassCreator")
modifiedClasses Class[] @relation("ClassModifier")
createdTeachers Teacher[] @relation("TeacherCreator")
modifiedTeachers Teacher[] @relation("TeacherModifier")
createdStudents Student[] @relation("StudentCreator")
modifiedStudents Student[] @relation("StudentModifier")
// 赛事相关关联
createdContests Contest[] @relation("ContestCreator")
modifiedContests Contest[] @relation("ContestModifier")
createdContestAttachments ContestAttachment[] @relation("ContestAttachmentCreator")
modifiedContestAttachments ContestAttachment[] @relation("ContestAttachmentModifier")
createdContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleCreator")
modifiedContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleModifier")
createdContestTeams ContestTeam[] @relation("ContestTeamCreator")
modifiedContestTeams ContestTeam[] @relation("ContestTeamModifier")
ledContestTeams ContestTeam[] @relation("ContestTeamLeader")
createdContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberCreator")
modifiedContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberModifier")
contestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberUser")
createdContestRegistrations ContestRegistration[] @relation("ContestRegistrationCreator")
modifiedContestRegistrations ContestRegistration[] @relation("ContestRegistrationModifier")
contestRegistrations ContestRegistration[] @relation("ContestRegistrationUser")
createdContestWorks ContestWork[] @relation("ContestWorkCreator")
modifiedContestWorks ContestWork[] @relation("ContestWorkModifier")
createdContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentCreator")
modifiedContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentModifier")
createdContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentCreator")
modifiedContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentModifier")
assignedContestWorks ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentJudge")
createdContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreCreator")
modifiedContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreModifier")
scoredContestWorks ContestWorkScore[] @relation("ContestWorkScoreJudge")
createdContestNotices ContestNotice[] @relation("ContestNoticeCreator")
modifiedContestNotices ContestNotice[] @relation("ContestNoticeModifier")
contestJudges ContestJudge[] @relation("ContestJudgeUser")
createdContestJudges ContestJudge[] @relation("ContestJudgeCreator")
modifiedContestJudges ContestJudge[] @relation("ContestJudgeModifier")
@@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")
}
/// 学校信息表(扩展租户信息)
model School {
id Int @id @default(autoincrement())
tenantId Int @unique @map("tenant_id") /// 租户ID一对一
address String? /// 学校地址
phone String? /// 联系电话
principal String? /// 校长姓名
established DateTime? /// 建校时间
description String? @db.Text /// 学校描述
logo String? /// 学校Logo URL
website 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("SchoolCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("SchoolModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@map("schools")
}
/// 年级表
model Grade {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
name String /// 年级名称(如:一年级、二年级)
code String /// 年级编码在租户内唯一grade_1, grade_2
level Int /// 年级级别用于排序1, 2, 3
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)
classes Class[] /// 班级
creatorUser User? @relation("GradeCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("GradeModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@unique([tenantId, level])
@@map("grades")
}
/// 部门表(支持树形结构)
model Department {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
name String /// 部门名称
code String /// 部门编码(在租户内唯一)
parentId Int? @map("parent_id") /// 父部门ID支持树形结构
description String? /// 部门描述
sort Int @default(0) /// 排序
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)
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
teachers Teacher[] /// 教师
creatorUser User? @relation("DepartmentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("DepartmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@map("departments")
}
/// 班级表
model Class {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
gradeId Int @map("grade_id") /// 年级ID
name String /// 班级名称一年级1班、二年级2班
code String /// 班级编码(在租户内唯一)
type Int @default(1) @map("type") /// 班级类型1-行政班级教学班级2-兴趣班
capacity Int? /// 班级容量(可选)
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)
grade Grade @relation(fields: [gradeId], references: [id], onDelete: Cascade)
students Student[] /// 学生(行政班级)
studentInterestClasses StudentInterestClass[] /// 学生兴趣班关联
creatorUser User? @relation("ClassCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ClassModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@map("classes")
}
/// 教师表
model Teacher {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id") /// 用户ID一对一
tenantId Int @map("tenant_id") /// 租户ID
departmentId Int @map("department_id") /// 部门ID
employeeNo String? @map("employee_no") /// 工号(在租户内唯一)
phone String? /// 联系电话
idCard String? @map("id_card") /// 身份证号
gender Int? /// 性别1-男2-女
birthDate DateTime? @map("birth_date") /// 出生日期
hireDate DateTime? @map("hire_date") /// 入职日期
subject String? /// 任教科目(可选,如:语文、数学)
title String? /// 职称(可选,如:高级教师、一级教师)
description String? @db.Text /// 教师描述
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") /// 修改时间
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict)
creatorUser User? @relation("TeacherCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("TeacherModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, employeeNo])
@@map("teachers")
}
/// 学生表
model Student {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id") /// 用户ID一对一
tenantId Int @map("tenant_id") /// 租户ID
classId Int @map("class_id") /// 行政班级ID
studentNo String? @map("student_no") /// 学号(在租户内唯一)
phone String? /// 联系电话
idCard String? @map("id_card") /// 身份证号
gender Int? /// 性别1-男2-女
birthDate DateTime? @map("birth_date") /// 出生日期
enrollmentDate DateTime? @map("enrollment_date") /// 入学日期
parentName String? @map("parent_name") /// 家长姓名
parentPhone String? @map("parent_phone") /// 家长电话
address String? /// 家庭地址
description String? @db.Text /// 学生描述
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") /// 修改时间
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Restrict)
interestClasses StudentInterestClass[] /// 兴趣班关联
creatorUser User? @relation("StudentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("StudentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, studentNo])
@@map("students")
}
/// 学生兴趣班关联表(多对多)
model StudentInterestClass {
id Int @id @default(autoincrement())
studentId Int @map("student_id") /// 学生ID
classId Int @map("class_id") /// 兴趣班ID
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([studentId, classId])
@@map("student_interest_classes")
}
// ============================================
// 赛事管理模块
// ============================================
/// 赛事表
model Contest {
id Int @id @default(autoincrement())
contestName String @map("contest_name") /// 赛事名称
contestType String @map("contest_type") /// 赛事类型individual/team
contestState String @default("unpublished") @map("contest_state") /// 赛事状态unpublished/published
startTime DateTime @map("start_time") /// 赛事开始时间
endTime DateTime @map("end_time") /// 赛事结束时间
address String? /// 线下地址
content String? @db.Text /// 赛事详情
contestTenants Json? @map("contest_tenants") /// 授权租户ID数组
coverUrl String? @map("cover_url") /// 封面url
posterUrl String? @map("poster_url") /// 海报url
contactName String? @map("contact_name") /// 联系人
contactPhone String? @map("contact_phone") /// 联系电话
contactQrcode String? @map("contact_qrcode") /// 联系人二维码
organizers Json? /// 主办单位数组
coOrganizers Json? @map("co_organizers") /// 协办单位数组
sponsors Json? /// 赞助单位数组
registerStartTime DateTime @map("register_start_time") /// 报名开始时间
registerEndTime DateTime @map("register_end_time") /// 报名结束时间
registerState String? @map("register_state") /// 报名任务状态started/closed
submitRule String @default("once") @map("submit_rule") /// 提交规则once/resubmit
submitStartTime DateTime @map("submit_start_time") /// 作品提交开始时间
submitEndTime DateTime @map("submit_end_time") /// 作品提交结束时间
reviewRuleId Int? @map("review_rule_id") /// 评审规则id
reviewStartTime DateTime @map("review_start_time") /// 评审开始时间
reviewEndTime DateTime @map("review_end_time") /// 评审结束时间
resultPublishTime DateTime? @map("result_publish_time") /// 结果发布时间
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
attachments ContestAttachment[] /// 赛事附件
reviewRule ContestReviewRule? @relation("ContestReviewRuleContest")
teams ContestTeam[] /// 赛事团队
registrations ContestRegistration[] /// 报名记录
works ContestWork[] /// 参赛作品
judges ContestJudge[] /// 比赛评委
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
notices ContestNotice[] /// 赛事公告
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([contestName])
@@index([contestState])
@@index([startTime, endTime])
@@index([reviewRuleId])
@@map("t_contest")
}
/// 赛事附件表
model ContestAttachment {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 赛事id
fileName String @map("file_name") /// 文件名
fileUrl String @map("file_url") /// 文件路径
format String? /// 文件类型png,mp4
fileType String? @map("file_type") /// 素材类型image,video
size String @default("0") /// 文件大小
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([contestId])
@@map("t_contest_attachment")
}
/// 评审规则表
model ContestReviewRule {
id Int @id @default(autoincrement())
contestId Int @unique @map("contest_id") /// 赛事id一对一关系
ruleName String @map("rule_name") /// 规则名称
dimensions Json /// 评分维度配置JSON
calculationRule String @default("average") @map("calculation_rule") /// 计算规则average/max/min/weighted
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
contest Contest @relation("ContestReviewRuleContest", fields: [contestId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestReviewRuleCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestReviewRuleModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([contestId])
@@map("t_contest_review_rule")
}
/// 赛事团队表
model ContestTeam {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 团队所属租户ID
contestId Int @map("contest_id") /// 赛事id
teamName String @map("team_name") /// 团队名称(租户内唯一)
leaderUserId Int @map("leader_user_id") /// 团队负责人用户id
maxMembers Int? @map("max_members") /// 团队最大成员数
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
leader User @relation("ContestTeamLeader", fields: [leaderUserId], references: [id], onDelete: Restrict)
members ContestTeamMember[] /// 团队成员
registrations ContestRegistration[] /// 报名记录
creatorUser User? @relation("ContestTeamCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestTeamModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, contestId, teamName])
@@index([contestId])
@@index([leaderUserId])
@@map("t_contest_team")
}
/// 团队成员表
model ContestTeamMember {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 成员所属租户ID
teamId Int @map("team_id") /// 团队id
userId Int @map("user_id") /// 成员用户id
role String @default("member") /// 成员角色member/leader/mentor
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)
team ContestTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation("ContestTeamMemberUser", fields: [userId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestTeamMemberCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestTeamMemberModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, teamId, userId])
@@index([teamId])
@@index([userId])
@@map("t_contest_team_member")
}
/// 赛事报名表
model ContestRegistration {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 赛事id
tenantId Int @map("tenant_id") /// 所属租户ID
registrationType String? @map("registration_type") /// 报名类型individual/team
teamId Int? @map("team_id") /// 团队id
teamName String? @map("team_name") /// 团队名称快照(团队赛)
userId Int @map("user_id") /// 账号id
accountNo String @map("account_no") /// 报名账号(记录报名快照)
accountName String @map("account_name") /// 报名账号名称(记录报名快照)
role String? /// 报名角色快照leader/member/mentor
registrationState String @default("pending") @map("registration_state") /// 报名状态pending/passed/rejected/withdrawn
registrant Int? /// 实际报名人用户ID
registrationTime DateTime @map("registration_time") /// 报名时间
reason String? @db.VarChar(1023) /// 审核理由
operator Int? /// 审核人用户ID
operationDate DateTime? @map("operation_date") /// 审核时间
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
team ContestTeam? @relation(fields: [teamId], references: [id], onDelete: SetNull)
user User @relation("ContestRegistrationUser", fields: [userId], references: [id], onDelete: Restrict)
works ContestWork[] /// 参赛作品
creatorUser User? @relation("ContestRegistrationCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestRegistrationModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([contestId, tenantId])
@@index([userId, contestId])
@@index([teamId])
@@index([registrationState])
@@map("t_contest_registration")
}
/// 参赛作品表
model ContestWork {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 作品所属租户ID
contestId Int @map("contest_id") /// 赛事id
registrationId Int @map("registration_id") /// 报名记录id
workNo String? @unique @map("work_no") /// 作品编号(展示用唯一编号)
title String /// 作品标题
description String? @db.Text /// 作品说明
files Json? /// 作品文件列表(简易场景)
version Int @default(1) /// 作品版本号(递增)
isLatest Boolean @default(true) @map("is_latest") /// 是否最新版本
status String @default("submitted") /// 作品状态submitted/locked/reviewing/rejected/accepted
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
submitterUserId Int? @map("submitter_user_id") /// 提交人用户id
submitterAccountNo String? @map("submitter_account_no") /// 提交人账号
submitSource String @default("teacher") @map("submit_source") /// 提交来源teacher/student/team_leader
previewUrl String? @map("preview_url") /// 作品预览URL
aiModelMeta Json? @map("ai_model_meta") /// AI建模元数据
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
registration ContestRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
attachments ContestWorkAttachment[] /// 作品附件
assignments ContestWorkJudgeAssignment[] /// 作品分配
scores ContestWorkScore[] /// 作品评分
creatorUser User? @relation("ContestWorkCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestWorkModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([tenantId, contestId, isLatest])
@@index([registrationId])
@@index([tenantId, contestId, submitTime, status])
@@index([contestId, status])
@@map("t_contest_work")
}
/// 作品附件文件表
model ContestWorkAttachment {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 所属租户ID
contestId Int @map("contest_id") /// 赛事id
workId Int @map("work_id") /// 作品id
fileName String @map("file_name") /// 文件名
fileUrl String @map("file_url") /// 文件路径
format String? /// 文件类型png,mp4
fileType String? @map("file_type") /// 素材类型image,video
size String @default("0") /// 文件大小
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestWorkAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestWorkAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([tenantId, contestId, workId])
@@map("t_contest_work_attachment")
}
/// 比赛评委关联表(比赛与评委的多对多关系)
model ContestJudge {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 比赛id
judgeId Int @map("judge_id") /// 评委用户id
specialty String? /// 评审专业领域(可选)
weight Decimal? @db.Decimal(3, 2) /// 评审权重(可选,用于加权平均计算)
description String? @db.Text /// 评委在该比赛中的说明
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
judge User @relation("ContestJudgeUser", fields: [judgeId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestJudgeCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestJudgeModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([contestId, judgeId])
@@index([contestId])
@@index([judgeId])
@@map("t_contest_judge")
}
/// 作品分配表(评委分配作品)
model ContestWorkJudgeAssignment {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 赛事id
workId Int @map("work_id") /// 作品id
judgeId Int @map("judge_id") /// 评委用户id
assignmentTime DateTime @default(now()) @map("assignment_time") /// 分配时间
status String @default("assigned") /// 分配状态assigned/reviewing/completed
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
contest Contest @relation("ContestWorkJudgeAssignmentContest", fields: [contestId], references: [id], onDelete: Cascade)
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
judge User @relation("ContestWorkJudgeAssignmentJudge", fields: [judgeId], references: [id], onDelete: Restrict)
scores ContestWorkScore[] /// 评分记录
creatorUser User? @relation("ContestWorkJudgeAssignmentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestWorkJudgeAssignmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([workId, judgeId])
@@index([contestId, judgeId])
@@index([workId])
@@index([status])
@@map("t_contest_work_judge_assignment")
}
/// 作品评分表
model ContestWorkScore {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 所属租户ID
contestId Int @map("contest_id") /// 赛事id
workId Int @map("work_id") /// 作品id
assignmentId Int @map("assignment_id") /// 分配记录id
judgeId Int @map("judge_id") /// 评委用户id
judgeName String @map("judge_name") /// 评委姓名
dimensionScores Json @map("dimension_scores") /// 各维度评分JSON
totalScore Decimal @map("total_score") @db.Decimal(10, 2) /// 总分
comments String? @db.Text /// 评语
scoreTime DateTime @map("score_time") /// 评分时间
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contest Contest @relation("ContestWorkScoreContest", fields: [contestId], references: [id], onDelete: Cascade)
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
assignment ContestWorkJudgeAssignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict)
judge User @relation("ContestWorkScoreJudge", fields: [judgeId], references: [id], onDelete: Restrict)
creatorUser User? @relation("ContestWorkScoreCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestWorkScoreModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([contestId, workId, judgeId])
@@index([workId])
@@index([assignmentId])
@@map("t_contest_work_score")
}
/// 赛事公告表
model ContestNotice {
id Int @id @default(autoincrement())
contestId Int @map("contest_id") /// 赛事id
title String /// 公告标题
content String @db.Text /// 公告内容
noticeType String @default("manual") @map("notice_type") /// 公告类型system/manual/urgent
priority Int @default(0) /// 优先级(数字越大优先级越高)
publishTime DateTime? @map("publish_time") /// 发布时间
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
creatorUser User? @relation("ContestNoticeCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ContestNoticeModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@index([contestId])
@@index([publishTime])
@@index([noticeType])
@@map("t_contest_notice")
}

View 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);
});

View 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);
});

View File

@ -0,0 +1,184 @@
// 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 fs from 'fs';
const prisma = new PrismaClient();
// 从 JSON 文件加载菜单数据
const menusFilePath = path.resolve(backendDir, 'data', 'menus.json');
if (!fs.existsSync(menusFilePath)) {
console.error(`❌ 错误: 菜单数据文件不存在: ${menusFilePath}`);
process.exit(1);
}
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
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,
permission: menuFields.permission || 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,
permission: menuFields.permission || 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);
});

View File

@ -0,0 +1,322 @@
// 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('🚀 开始初始化超级租户...\n');
// 检查是否已存在超级租户
let superTenant = await prisma.tenant.findFirst({
where: { isSuper: 1 },
});
if (superTenant) {
console.log('⚠️ 超级租户已存在,将更新菜单分配');
console.log(` 租户编码: ${superTenant.code}\n`);
} else {
// 创建超级租户
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}\n`);
}
// 创建或获取超级管理员用户
console.log('📋 步骤 2: 创建或获取超级管理员用户...\n');
let superAdmin = await prisma.user.findFirst({
where: {
tenantId: superTenant.id,
username: 'admin',
},
});
if (!superAdmin) {
const hashedPassword = await bcrypt.hash('admin@super', 10);
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(` 密码: admin@super`);
console.log(` 用户ID: ${superAdmin.id}\n`);
} else {
console.log('✅ 超级管理员用户已存在');
console.log(` 用户名: ${superAdmin.username}`);
console.log(` 用户ID: ${superAdmin.id}\n`);
}
// 创建或获取超级管理员角色
console.log('📋 步骤 3: 创建或获取超级管理员角色...\n');
let superAdminRole = await prisma.role.findFirst({
where: {
tenantId: superTenant.id,
code: 'super_admin',
},
});
if (!superAdminRole) {
superAdminRole = await prisma.role.create({
data: {
tenantId: superTenant.id,
name: '超级管理员',
code: 'super_admin',
description: '超级管理员角色,拥有所有权限',
validState: 1,
},
});
console.log('✅ 超级管理员角色创建成功!');
console.log(` 角色编码: ${superAdminRole.code}\n`);
} else {
console.log('✅ 超级管理员角色已存在');
console.log(` 角色编码: ${superAdminRole.code}\n`);
}
// 将超级管理员角色分配给用户
const existingUserRole = await prisma.userRole.findUnique({
where: {
userId_roleId: {
userId: superAdmin.id,
roleId: superAdminRole.id,
},
},
});
if (!existingUserRole) {
await prisma.userRole.create({
data: {
userId: superAdmin.id,
roleId: superAdminRole.id,
},
});
console.log('✅ 超级管理员角色已分配给用户');
} else {
console.log('✅ 超级管理员角色已分配给用户,跳过');
}
console.log('💡 提示: 权限初始化请使用 init:admin:permissions 脚本\n');
// 为超级租户分配所有菜单
console.log('📋 步骤 4: 为超级租户分配所有菜单...\n');
// 获取所有有效菜单
const allMenus = await prisma.menu.findMany({
where: {
validState: 1,
},
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
});
if (allMenus.length === 0) {
console.log('⚠️ 警告: 数据库中没有任何菜单');
console.log(' 请先运行 pnpm init:menus 初始化菜单');
} else {
console.log(` 找到 ${allMenus.length} 个菜单\n`);
// 获取超级租户已分配的菜单
const existingTenantMenus = await prisma.tenantMenu.findMany({
where: {
tenantId: superTenant.id,
},
select: {
menuId: true,
},
});
const existingMenuIds = new Set(existingTenantMenus.map((tm) => tm.menuId));
// 为超级租户分配所有菜单
let addedCount = 0;
const menuNames: string[] = [];
for (const menu of allMenus) {
if (!existingMenuIds.has(menu.id)) {
await prisma.tenantMenu.create({
data: {
tenantId: superTenant.id,
menuId: menu.id,
},
});
addedCount++;
menuNames.push(menu.name);
}
}
if (addedCount > 0) {
console.log(`✅ 为超级租户添加了 ${addedCount} 个菜单:`);
menuNames.forEach((name) => {
console.log(`${name}`);
});
console.log(`\n✅ 超级租户现在拥有 ${allMenus.length} 个菜单\n`);
} else {
console.log(`✅ 超级租户已拥有所有菜单(${allMenus.length} 个)\n`);
}
}
// 创建租户管理菜单(如果不存在)
console.log('📋 步骤 5: 创建租户管理菜单(如果不存在)...\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('✅ 租户管理菜单创建成功');
// 为超级租户分配租户管理菜单
await prisma.tenantMenu.create({
data: {
tenantId: superTenant.id,
menuId: tenantMenu.id,
},
});
console.log('✅ 租户管理菜单已分配给超级租户\n');
} else {
tenantMenu = existingTenantMenu;
console.log('✅ 租户管理菜单已存在');
// 检查是否已分配
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('✅ 租户管理菜单已分配给超级租户\n');
} else {
console.log('✅ 租户管理菜单已分配给超级租户,跳过\n');
}
}
} else {
console.log('⚠️ 警告:未找到系统管理菜单,无法创建租户管理菜单\n');
}
// 验证菜单分配结果
const finalMenus = await prisma.tenantMenu.findMany({
where: {
tenantId: superTenant.id,
},
include: {
menu: true,
},
});
console.log('📊 初始化结果:');
console.log('========================================');
console.log('超级租户信息:');
console.log(` 租户编码: ${superTenant.code}`);
console.log(` 租户名称: ${superTenant.name}`);
console.log(` 访问链接: http://your-domain.com/?tenant=${superTenant.code}`);
console.log('========================================');
console.log('超级管理员登录信息:');
console.log(` 用户名: ${superAdmin.username}`);
console.log(` 密码: admin@super`);
console.log(` 租户编码: ${superTenant.code}`);
console.log('========================================');
console.log('菜单分配情况:');
console.log(` 已分配菜单数: ${finalMenus.length}`);
if (finalMenus.length > 0) {
const topLevelMenus = finalMenus.filter((tm) => !tm.menu.parentId);
console.log(` 顶级菜单数: ${topLevelMenus.length}`);
}
console.log('========================================');
console.log('\n💡 提示:');
console.log(' 权限初始化请使用: pnpm init:admin:permissions');
console.log(' 菜单初始化请使用: pnpm init:menus');
console.log('========================================');
}
main()
.then(() => {
console.log('\n🎉 初始化脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 初始化脚本执行失败:', error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View 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);
});

View File

@ -0,0 +1,429 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// 加载环境变量(必须在其他导入之前)
import * as dotenv from 'dotenv';
import * as path from 'path';
import * as fs from 'fs';
// 根据 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();
// 从 JSON 文件加载权限数据
const permissionsFilePath = path.resolve(backendDir, 'data', 'permissions.json');
if (!fs.existsSync(permissionsFilePath)) {
console.error(`❌ 错误: 权限数据文件不存在: ${permissionsFilePath}`);
process.exit(1);
}
const permissions = JSON.parse(fs.readFileSync(permissionsFilePath, 'utf-8'));
async function initTenantMenuAndPermissions(tenantCode: string) {
try {
console.log(`🚀 开始为租户 "${tenantCode}" 初始化菜单和权限...\n`);
// 1. 查找或创建租户
console.log(`📋 步骤 1: 查找或创建租户 "${tenantCode}"...`);
let tenant = await prisma.tenant.findUnique({
where: { code: tenantCode },
});
if (!tenant) {
// 创建租户
tenant = await prisma.tenant.create({
data: {
name: `${tenantCode} 租户`,
code: tenantCode,
domain: tenantCode,
description: `租户 ${tenantCode}`,
isSuper: 0,
validState: 1,
},
});
console.log(`✅ 租户创建成功: ${tenant.name} (${tenant.code})\n`);
} else {
if (tenant.validState !== 1) {
console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`);
process.exit(1);
}
console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`);
}
// 2. 查找或创建 admin 用户
console.log(`👤 步骤 2: 查找或创建 admin 用户...`);
let adminUser = await prisma.user.findFirst({
where: {
tenantId: tenant.id,
username: 'admin',
},
});
const password = `admin@${tenantCode}`;
const hashedPassword = await bcrypt.hash(password, 10);
if (!adminUser) {
adminUser = await prisma.user.create({
data: {
tenantId: tenant.id,
username: 'admin',
password: hashedPassword,
nickname: '管理员',
email: `admin@${tenantCode}.com`,
validState: 1,
},
});
console.log(`✅ admin 用户创建成功: ${adminUser.username}\n`);
} else {
// 更新密码(确保密码是最新的)
adminUser = await prisma.user.update({
where: { id: adminUser.id },
data: {
password: hashedPassword,
nickname: '管理员',
email: `admin@${tenantCode}.com`,
validState: 1,
},
});
console.log(`✅ admin 用户已存在: ${adminUser.username}\n`);
}
// 3. 查找或创建 admin 角色
console.log(`👤 步骤 3: 查找或创建 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`);
}
// 4. 为 admin 用户分配 admin 角色
console.log(`🔗 步骤 4: 为 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`);
}
// 5. 初始化租户权限(如果不存在则创建)
console.log(`📝 步骤 5: 初始化租户权限...`);
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,
},
});
// 6. 为 admin 角色分配所有权限
console.log(`🔗 步骤 6: 为 admin 角色分配所有权限...`);
const existingRolePermissions = await prisma.rolePermission.findMany({
where: { roleId: adminRole.id },
select: { permissionId: true },
});
const existingPermissionIds = new Set(
existingRolePermissions.map((rp) => rp.permissionId),
);
let addedPermissionCount = 0;
for (const permission of tenantPermissions) {
if (!existingPermissionIds.has(permission.id)) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: permission.id,
},
});
addedPermissionCount++;
}
}
if (addedPermissionCount > 0) {
console.log(`✅ 为 admin 角色添加了 ${addedPermissionCount} 个权限`);
console.log(`✅ admin 角色现在拥有 ${tenantPermissions.length} 个权限\n`);
} else {
console.log(
`✅ admin 角色已拥有所有权限(${tenantPermissions.length} 个)\n`,
);
}
// 7. 为租户分配所有菜单
console.log(`📋 步骤 7: 为租户分配所有菜单...`);
// 获取所有有效菜单
const allMenus = await prisma.menu.findMany({
where: {
validState: 1,
},
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
});
if (allMenus.length === 0) {
console.log('⚠️ 警告: 数据库中没有任何菜单');
console.log(' 请先运行 pnpm init:menus 初始化菜单\n');
} else {
console.log(` 找到 ${allMenus.length} 个菜单\n`);
// 获取租户已分配的菜单
const existingTenantMenus = await prisma.tenantMenu.findMany({
where: {
tenantId: tenant.id,
},
select: {
menuId: true,
},
});
const existingMenuIds = new Set(existingTenantMenus.map((tm) => tm.menuId));
// 为租户分配所有菜单
let addedMenuCount = 0;
const menuNames: string[] = [];
for (const menu of allMenus) {
if (!existingMenuIds.has(menu.id)) {
await prisma.tenantMenu.create({
data: {
tenantId: tenant.id,
menuId: menu.id,
},
});
addedMenuCount++;
menuNames.push(menu.name);
}
}
if (addedMenuCount > 0) {
console.log(`✅ 为租户添加了 ${addedMenuCount} 个菜单:`);
menuNames.forEach((name) => {
console.log(`${name}`);
});
console.log(`\n✅ 租户现在拥有 ${allMenus.length} 个菜单\n`);
} else {
console.log(`✅ 租户已拥有所有菜单(${allMenus.length} 个)\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);
});
});
const finalMenus = await prisma.tenantMenu.findMany({
where: {
tenantId: tenant.id,
},
include: {
menu: true,
},
});
console.log(`\n📊 初始化结果:`);
console.log('========================================');
console.log('租户信息:');
console.log(` 租户名称: ${tenant.name}`);
console.log(` 租户编码: ${tenant.code}`);
console.log(` 访问链接: http://your-domain.com/?tenant=${tenant.code}`);
console.log('========================================');
console.log('管理员登录信息:');
console.log(` 用户名: ${adminUser.username}`);
console.log(` 密码: ${password}`);
console.log(` 昵称: ${adminUser.nickname}`);
console.log(` 邮箱: ${adminUser.email}`);
console.log('========================================');
console.log('角色和权限:');
console.log(` 角色: ${roleCodes.join(', ')}`);
console.log(` 权限数量: ${permissionCodes.size}`);
if (permissionCodes.size > 0 && permissionCodes.size <= 20) {
console.log(` 权限列表:`);
Array.from(permissionCodes)
.sort()
.forEach((code) => {
console.log(` - ${code}`);
});
} else if (permissionCodes.size > 20) {
console.log(` 权限列表前20个:`);
Array.from(permissionCodes)
.sort()
.slice(0, 20)
.forEach((code) => {
console.log(` - ${code}`);
});
console.log(` ... 还有 ${permissionCodes.size - 20} 个权限`);
}
console.log('========================================');
console.log('菜单分配:');
console.log(` 已分配菜单数: ${finalMenus.length}`);
if (finalMenus.length > 0) {
const topLevelMenus = finalMenus.filter((tm) => !tm.menu.parentId);
console.log(` 顶级菜单数: ${topLevelMenus.length}`);
}
console.log('========================================');
console.log(`\n✅ 租户菜单和权限初始化完成!`);
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();
}
}
// 获取命令行参数
const tenantCode = process.argv[2];
if (!tenantCode) {
console.error('❌ 错误: 请提供租户编码作为参数');
console.error(' 使用方法:');
console.error(' pnpm init:tenant-menu-permissions <租户编码>');
console.error(' 示例:');
console.error(' pnpm init:tenant-menu-permissions tenant1');
process.exit(1);
}
// 执行初始化
initTenantMenuAndPermissions(tenantCode)
.then(() => {
console.log('\n🎉 初始化脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 初始化脚本执行失败:', error);
process.exit(1);
});

View 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);
});

View 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));

276
backend/sql/competition.sql Normal file
View File

@ -0,0 +1,276 @@
-- ============================================
-- 赛事管理模块数据库表结构
-- ============================================
-- 1. 赛事表
CREATE TABLE `t_contest` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_name` varchar(127) NOT NULL COMMENT '赛事名称',
`contest_type` varchar(31) NOT NULL COMMENT '赛事类型字典contest_typeindividual/team',
`contest_state` varchar(31) NOT NULL DEFAULT 'unpublished' 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_tenants` json DEFAULT NULL COMMENT '赛事参赛范围授权租户ID数组',
`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` json DEFAULT NULL COMMENT '主办单位数组',
`co_organizers` json DEFAULT NULL COMMENT '协办单位数组',
`sponsors` json 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` int 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` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_contest_name` (`contest_name`),
KEY `idx_contest_state` (`contest_state`),
KEY `idx_contest_time` (`start_time`, `end_time`),
KEY `idx_review_rule` (`review_rule_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事表';
-- 2. 赛事附件表
CREATE TABLE `t_contest_attachment` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int NOT NULL COMMENT '赛事id',
`file_name` varchar(100) NOT NULL COMMENT '文件名',
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
`format` varchar(255) DEFAULT NULL COMMENT '文件类型png,mp4',
`file_type` varchar(255) DEFAULT NULL COMMENT '素材类型image,video',
`size` varchar(255) DEFAULT '0' COMMENT '文件大小',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
KEY `idx_contest` (`contest_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事附件';
-- 3. 评审规则表
CREATE TABLE `t_contest_review_rule` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int 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` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
KEY `idx_contest` (`contest_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='评审规则表';
-- 4. 赛事团队表
CREATE TABLE `t_contest_team` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`tenant_id` int NOT NULL COMMENT '团队所属租户ID',
`contest_id` int NOT NULL COMMENT '赛事id',
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
`leader_user_id` int NOT NULL COMMENT '团队负责人用户id',
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_team_name` (`tenant_id`,`contest_id`,`team_name`),
KEY `idx_contest` (`contest_id`),
KEY `idx_leader` (`leader_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队';
-- 5. 团队成员表
CREATE TABLE `t_contest_team_member` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`tenant_id` int NOT NULL COMMENT '成员所属租户ID',
`team_id` int NOT NULL COMMENT '团队id',
`user_id` int NOT NULL COMMENT '成员用户id',
`role` varchar(31) NOT NULL DEFAULT 'member' COMMENT '成员角色member/leader/mentor',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`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_id`,`team_id`,`user_id`),
KEY `idx_team` (`team_id`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='团队成员';
-- 6. 赛事报名表
CREATE TABLE `t_contest_registration` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int NOT NULL COMMENT '赛事id',
`tenant_id` int NOT NULL COMMENT '所属租户ID学校/机构)',
`registration_type` varchar(20) DEFAULT NULL COMMENT '报名类型individual个人/team团队',
`team_id` int DEFAULT NULL COMMENT '团队id',
`team_name` varchar(255) DEFAULT NULL COMMENT '团队名称快照(团队赛)',
`user_id` int 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 DEFAULT 'pending' COMMENT '报名状态pending待审核、passed已通过、rejected已拒绝、withdrawn已撤回',
`registrant` int DEFAULT NULL COMMENT '实际报名人用户ID老师报名填老师用户ID',
`registration_time` datetime NOT NULL COMMENT '报名时间',
`reason` varchar(1023) DEFAULT NULL COMMENT '审核理由',
`operator` int DEFAULT NULL COMMENT '审核人用户ID',
`operation_date` datetime DEFAULT NULL COMMENT '审核时间',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_contest_tenant` (`contest_id`, `tenant_id`),
KEY `idx_user_contest` (`user_id`, `contest_id`),
KEY `idx_team` (`team_id`),
KEY `idx_registration_state` (`registration_state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事报名人员记录表';
-- 7. 参赛作品表
CREATE TABLE `t_contest_work` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`tenant_id` int NOT NULL COMMENT '作品所属租户ID',
`contest_id` int NOT NULL COMMENT '赛事id',
`registration_id` int NOT NULL COMMENT '报名记录id关联t_contest_registration.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` int 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 '作品预览URL3D/视频)',
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据模型类型、版本、参数',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_work_no` (`work_no`),
KEY `idx_work_contest_latest` (`tenant_id`,`contest_id`,`is_latest`),
KEY `idx_work_registration` (`registration_id`),
KEY `idx_submit_filter` (`tenant_id`,`contest_id`,`submit_time`,`status`),
KEY `idx_contest_status` (`contest_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品';
-- 8. 作品附件文件表
CREATE TABLE `t_contest_work_attachment` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`tenant_id` int NOT NULL COMMENT '所属租户ID',
`contest_id` int NOT NULL COMMENT '赛事id',
`work_id` int NOT NULL COMMENT '作品id',
`file_name` varchar(255) NOT NULL COMMENT '文件名',
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
`format` varchar(255) DEFAULT NULL COMMENT '文件类型png,mp4',
`file_type` varchar(255) DEFAULT NULL COMMENT '素材类型image,video',
`size` varchar(255) DEFAULT '0' COMMENT '文件大小',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`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_id`,`contest_id`,`work_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表';
-- 9. 比赛评委关联表(比赛与评委的多对多关系)
CREATE TABLE `t_contest_judge` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int NOT NULL COMMENT '比赛id',
`judge_id` int NOT NULL COMMENT '评委用户id',
`specialty` varchar(255) DEFAULT NULL COMMENT '评审专业领域(可选)',
`weight` decimal(3,2) DEFAULT NULL COMMENT '评审权重(可选,用于加权平均计算)',
`description` text DEFAULT NULL COMMENT '评委在该比赛中的说明',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_contest_judge` (`contest_id`, `judge_id`),
KEY `idx_contest` (`contest_id`),
KEY `idx_judge` (`judge_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='比赛评委关联表';
-- 10. 作品分配表(评委分配作品)
CREATE TABLE `t_contest_work_judge_assignment` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int NOT NULL COMMENT '赛事id',
`work_id` int NOT NULL COMMENT '作品id',
`judge_id` int NOT NULL COMMENT '评委用户id',
`assignment_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '分配时间',
`status` varchar(31) NOT NULL DEFAULT 'assigned' COMMENT '分配状态assigned/reviewing/completed',
`creator` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`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`),
KEY `idx_work` (`work_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作品分配表';
-- 11. 作品评分表
CREATE TABLE `t_contest_work_score` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`tenant_id` int NOT NULL COMMENT '所属租户ID',
`contest_id` int NOT NULL COMMENT '赛事id',
`work_id` int NOT NULL COMMENT '作品id',
`assignment_id` int NOT NULL COMMENT '分配记录id关联t_contest_work_judge_assignment',
`judge_id` int 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` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
KEY `idx_contest_work_judge` (`contest_id`, `work_id`, `judge_id`),
KEY `idx_work` (`work_id`),
KEY `idx_assignment` (`assignment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
-- 12. 赛事公告表
CREATE TABLE `t_contest_notice` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`contest_id` int 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` int DEFAULT NULL COMMENT '创建人ID',
`modifier` int DEFAULT NULL COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效)',
PRIMARY KEY (`id`),
KEY `idx_contest` (`contest_id`),
KEY `idx_publish_time` (`publish_time`),
KEY `idx_notice_type` (`notice_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事公告表';

69
backend/src/app.module.ts Normal file
View File

@ -0,0 +1,69 @@
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 { SchoolModule } from './school/school.module';
import { ContestsModule } from './contests/contests.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,
SchoolModule,
ContestsModule,
],
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 {}

View 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: '登出成功' };
}
}

View 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 {}

View File

@ -0,0 +1,120 @@
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;
password;
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);
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permission';
export const RequirePermission = (permission: string) =>
SetMetadata(PERMISSION_KEY, permission);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View 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; // 租户编码(可选,如果未提供则从请求头获取)
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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,
};
}
}

View 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;
}
}

View 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...(内容过长,已截断)';
}
}

View 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...(内容过长,已截断)';
}
}

View 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,
})),
);
}
}

View 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'),
},
},
};
}
}

View 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);
}
}

View 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 {}

View 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 },
});
}
}

View File

@ -0,0 +1,13 @@
import { IsString, IsOptional } from 'class-validator';
export class CreateConfigDto {
@IsString()
key: string;
@IsString()
value: string;
@IsString()
@IsOptional()
description?: string;
}

View 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;
}

View File

@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { AttachmentsService } from './attachments.service';
import { CreateAttachmentDto } from './dto/create-attachment.dto';
import { UpdateAttachmentDto } from './dto/update-attachment.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
@Controller('contests/attachments')
@UseGuards(JwtAuthGuard)
export class AttachmentsController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@Post()
@RequirePermission('contest:update')
create(@Body() createAttachmentDto: CreateAttachmentDto, @Request() req) {
const creatorId = req.user?.id;
return this.attachmentsService.create(createAttachmentDto, creatorId);
}
@Get('contest/:contestId')
@RequirePermission('contest:read')
findAll(@Param('contestId', ParseIntPipe) contestId: number) {
return this.attachmentsService.findAll(contestId);
}
@Get(':id')
@RequirePermission('contest:read')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.attachmentsService.findOne(id);
}
@Patch(':id')
@RequirePermission('contest:update')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateAttachmentDto: UpdateAttachmentDto,
@Request() req,
) {
const modifierId = req.user?.id;
return this.attachmentsService.update(id, updateAttachmentDto, modifierId);
}
@Delete(':id')
@RequirePermission('contest:update')
remove(@Param('id', ParseIntPipe) id: number) {
return this.attachmentsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AttachmentsService } from './attachments.service';
import { AttachmentsController } from './attachments.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AttachmentsController],
providers: [AttachmentsService],
exports: [AttachmentsService],
})
export class AttachmentsModule {}

View File

@ -0,0 +1,108 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateAttachmentDto } from './dto/create-attachment.dto';
import { UpdateAttachmentDto } from './dto/update-attachment.dto';
@Injectable()
export class AttachmentsService {
constructor(private prisma: PrismaService) {}
async create(createAttachmentDto: CreateAttachmentDto, creatorId?: number) {
// 验证比赛是否存在
const contest = await this.prisma.contest.findUnique({
where: { id: createAttachmentDto.contestId },
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
const data: any = {
...createAttachmentDto,
size: createAttachmentDto.size || '0',
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.contestAttachment.create({
data,
include: {
contest: true,
},
});
}
async findAll(contestId: number) {
return this.prisma.contestAttachment.findMany({
where: {
contestId,
validState: 1,
},
orderBy: {
createTime: 'desc',
},
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
},
});
}
async findOne(id: number) {
const attachment = await this.prisma.contestAttachment.findFirst({
where: {
id,
validState: 1,
},
include: {
contest: true,
},
});
if (!attachment) {
throw new NotFoundException('附件不存在');
}
return attachment;
}
async update(id: number, updateAttachmentDto: UpdateAttachmentDto, modifierId?: number) {
const attachment = await this.findOne(id);
const data: any = { ...updateAttachmentDto };
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.contestAttachment.update({
where: { id },
data,
include: {
contest: true,
},
});
}
async remove(id: number) {
await this.findOne(id);
// 软删除
return this.prisma.contestAttachment.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -0,0 +1,25 @@
import { IsString, IsInt, IsOptional } from 'class-validator';
export class CreateAttachmentDto {
@IsInt()
contestId: number;
@IsString()
fileName: string;
@IsString()
fileUrl: string;
@IsString()
@IsOptional()
format?: string;
@IsString()
@IsOptional()
fileType?: string;
@IsString()
@IsOptional()
size?: string;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAttachmentDto } from './create-attachment.dto';
export class UpdateAttachmentDto extends PartialType(CreateAttachmentDto) {}

View File

@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ContestsModule as ContestsCoreModule } from './contests/contests.module';
import { AttachmentsModule } from './attachments/attachments.module';
import { ReviewRulesModule } from './review-rules/review-rules.module';
import { RegistrationsModule } from './registrations/registrations.module';
import { TeamsModule } from './teams/teams.module';
import { WorksModule } from './works/works.module';
import { ReviewsModule } from './reviews/reviews.module';
import { NoticesModule } from './notices/notices.module';
import { JudgesModule } from './judges/judges.module';
@Module({
imports: [
ContestsCoreModule,
AttachmentsModule,
ReviewRulesModule,
RegistrationsModule,
TeamsModule,
WorksModule,
ReviewsModule,
NoticesModule,
JudgesModule,
],
exports: [
ContestsCoreModule,
AttachmentsModule,
ReviewRulesModule,
RegistrationsModule,
TeamsModule,
WorksModule,
ReviewsModule,
NoticesModule,
JudgesModule,
],
})
export class ContestsModule {}

View File

@ -0,0 +1,76 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { ContestsService } from './contests.service';
import { CreateContestDto } from './dto/create-contest.dto';
import { UpdateContestDto } from './dto/update-contest.dto';
import { QueryContestDto } from './dto/query-contest.dto';
import { PublishContestDto } from './dto/publish-contest.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
@Controller('contests')
@UseGuards(JwtAuthGuard)
export class ContestsController {
constructor(private readonly contestsService: ContestsService) {}
@Post()
@RequirePermission('contest:create')
create(@Body() createContestDto: CreateContestDto, @Request() req) {
const creatorId = req.user?.id;
return this.contestsService.create(createContestDto, creatorId);
}
@Get()
@RequirePermission('contest:read')
findAll(@Query() queryDto: QueryContestDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.contestsService.findAll(queryDto, tenantId);
}
@Get(':id')
@RequirePermission('contest:read')
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.contestsService.findOne(id, tenantId);
}
@Patch(':id')
@RequirePermission('contest:update')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateContestDto: UpdateContestDto,
@Request() req,
) {
const modifierId = req.user?.id;
return this.contestsService.update(id, updateContestDto, modifierId);
}
@Patch(':id/publish')
@RequirePermission('contest:publish')
publish(
@Param('id', ParseIntPipe) id: number,
@Body() publishDto: PublishContestDto,
@Request() req,
) {
const modifierId = req.user?.id;
return this.contestsService.publish(id, publishDto.contestState, modifierId);
}
@Delete(':id')
@RequirePermission('contest:delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.contestsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ContestsService } from './contests.service';
import { ContestsController } from './contests.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ContestsController],
providers: [ContestsService],
exports: [ContestsService],
})
export class ContestsModule {}

View File

@ -0,0 +1,550 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateContestDto } from './dto/create-contest.dto';
import { UpdateContestDto } from './dto/update-contest.dto';
import { QueryContestDto } from './dto/query-contest.dto';
@Injectable()
export class ContestsService {
constructor(private prisma: PrismaService) {}
/**
*
*/
private isContestVisibleToTenant(contest: any, tenantId: number): boolean {
// 如果contestTenants为null表示所有租户可见
if (!contest.contestTenants) {
return true;
}
// 解析JSON数组
try {
const tenantIds = Array.isArray(contest.contestTenants)
? contest.contestTenants
: JSON.parse(contest.contestTenants as string);
return tenantIds.includes(tenantId);
} catch {
return false;
}
}
/**
*
*/
private validateTimeOrder(dto: CreateContestDto | UpdateContestDto) {
const dtoAny = dto as any;
// 对于 UpdateContestDto某些字段可能是 undefined需要检查
if (
!dtoAny.registerStartTime ||
!dtoAny.registerEndTime ||
!dtoAny.submitStartTime ||
!dtoAny.submitEndTime ||
!dtoAny.reviewStartTime ||
!dtoAny.reviewEndTime
) {
// 如果缺少必需的时间字段跳过验证UpdateContestDto 可能只更新部分字段)
return;
}
const times = {
registerStart: new Date(dtoAny.registerStartTime),
registerEnd: new Date(dtoAny.registerEndTime),
submitStart: new Date(dtoAny.submitStartTime),
submitEnd: new Date(dtoAny.submitEndTime),
reviewStart: new Date(dtoAny.reviewStartTime),
reviewEnd: new Date(dtoAny.reviewEndTime),
};
if (times.registerStart >= times.registerEnd) {
throw new BadRequestException('报名开始时间必须早于报名结束时间');
}
if (times.registerEnd >= times.submitStart) {
throw new BadRequestException('报名结束时间必须早于作品提交开始时间');
}
if (times.submitStart >= times.submitEnd) {
throw new BadRequestException('作品提交开始时间必须早于作品提交结束时间');
}
if (times.submitEnd >= times.reviewStart) {
throw new BadRequestException('作品提交结束时间必须早于评审开始时间');
}
if (times.reviewStart >= times.reviewEnd) {
throw new BadRequestException('评审开始时间必须早于评审结束时间');
}
if (dtoAny.resultPublishTime) {
const resultPublish = new Date(dtoAny.resultPublishTime);
if (times.reviewEnd >= resultPublish) {
throw new BadRequestException('评审结束时间必须早于结果发布时间');
}
}
if (dtoAny.startTime && dtoAny.endTime) {
const startTime = new Date(dtoAny.startTime);
const endTime = new Date(dtoAny.endTime);
if (startTime >= endTime) {
throw new BadRequestException('比赛开始时间必须早于比赛结束时间');
}
}
}
async create(createContestDto: CreateContestDto, creatorId?: number) {
// 检查比赛名称是否已存在
const existing = await this.prisma.contest.findUnique({
where: { contestName: createContestDto.contestName },
});
if (existing) {
throw new ConflictException('比赛名称已存在');
}
// 验证时间顺序
this.validateTimeOrder(createContestDto);
const data: any = {
contestName: createContestDto.contestName,
contestType: createContestDto.contestType,
startTime: new Date(createContestDto.startTime),
endTime: new Date(createContestDto.endTime),
address: createContestDto.address,
content: createContestDto.content,
contestTenants: createContestDto.contestTenants
? JSON.stringify(createContestDto.contestTenants)
: null,
coverUrl: createContestDto.coverUrl,
posterUrl: createContestDto.posterUrl,
contactName: createContestDto.contactName,
contactPhone: createContestDto.contactPhone,
contactQrcode: createContestDto.contactQrcode,
organizers: createContestDto.organizers
? JSON.stringify(createContestDto.organizers)
: null,
coOrganizers: createContestDto.coOrganizers
? JSON.stringify(createContestDto.coOrganizers)
: null,
sponsors: createContestDto.sponsors
? JSON.stringify(createContestDto.sponsors)
: null,
registerStartTime: new Date(createContestDto.registerStartTime),
registerEndTime: new Date(createContestDto.registerEndTime),
submitRule: createContestDto.submitRule || 'once',
submitStartTime: new Date(createContestDto.submitStartTime),
submitEndTime: new Date(createContestDto.submitEndTime),
reviewStartTime: new Date(createContestDto.reviewStartTime),
reviewEndTime: new Date(createContestDto.reviewEndTime),
resultPublishTime: createContestDto.resultPublishTime
? new Date(createContestDto.resultPublishTime)
: null,
contestState: 'unpublished',
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.contest.create({
data,
include: {
attachments: {
where: { validState: 1 },
},
reviewRule: true,
_count: {
select: {
registrations: true,
works: true,
teams: true,
},
},
},
});
}
async findAll(queryDto: QueryContestDto, tenantId?: number) {
const {
page = 1,
pageSize = 10,
contestName,
contestState,
contestType,
} = queryDto;
const skip = (page - 1) * pageSize;
const where: any = {
validState: 1,
};
if (contestName) {
where.contestName = {
contains: contestName,
};
}
if (contestState) {
where.contestState = contestState;
}
if (contestType) {
where.contestType = contestType;
}
// 先查询所有符合条件的比赛
const [allList, allTotal] = await Promise.all([
this.prisma.contest.findMany({
where,
skip,
take: pageSize * 2, // 多查询一些,以便过滤后仍有足够数据
orderBy: {
createTime: 'desc',
},
include: {
attachments: {
where: { validState: 1 },
take: 5,
},
reviewRule: true,
_count: {
select: {
registrations: true,
works: true,
teams: true,
},
},
},
}),
this.prisma.contest.count({ where }),
]);
// 如果指定了租户ID进行应用层过滤
let filteredList = allList;
let filteredTotal = allTotal;
if (tenantId) {
filteredList = allList.filter((contest) =>
this.isContestVisibleToTenant(contest, tenantId),
);
// 重新计算总数简化处理实际应该用原生SQL
filteredTotal = filteredList.length;
// 限制返回数量
filteredList = filteredList.slice(0, pageSize);
}
return {
list: filteredList,
total: filteredTotal,
page,
pageSize,
};
}
async findOne(id: number, tenantId?: number) {
const where: any = {
id,
validState: 1,
};
const contest = await this.prisma.contest.findFirst({
where,
include: {
attachments: {
where: { validState: 1 },
},
reviewRule: true,
notices: {
where: { validState: 1 },
orderBy: [{ priority: 'desc' }, { publishTime: 'desc' }],
take: 10,
},
_count: {
select: {
registrations: true,
works: true,
teams: true,
judges: true,
},
},
},
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
// 租户过滤:检查比赛是否对租户可见
if (tenantId && !this.isContestVisibleToTenant(contest, tenantId)) {
throw new NotFoundException('比赛不存在或无权访问');
}
return contest;
}
async update(
id: number,
updateContestDto: UpdateContestDto,
modifierId?: number,
) {
const contest = await this.prisma.contest.findUnique({
where: { id },
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
// 如果比赛已发布,检查是否有报名记录
if (contest.contestState === 'published') {
const registrationCount = await this.prisma.contestRegistration.count({
where: { contestId: id },
});
if (
registrationCount > 0 &&
(updateContestDto as any).contestState === 'unpublished'
) {
throw new BadRequestException('比赛已有报名记录,无法撤回');
}
}
// 如果更新了比赛名称,检查是否重复
if (
(updateContestDto as any).contestName &&
(updateContestDto as any).contestName !== contest.contestName
) {
const existing = await this.prisma.contest.findUnique({
where: { contestName: (updateContestDto as any).contestName },
});
if (existing) {
throw new ConflictException('比赛名称已存在');
}
}
// 验证时间顺序(如果提供了时间字段)
if (
(updateContestDto as any).registerStartTime ||
(updateContestDto as any).registerEndTime ||
(updateContestDto as any).submitStartTime ||
(updateContestDto as any).submitEndTime ||
(updateContestDto as any).reviewStartTime ||
(updateContestDto as any).reviewEndTime
) {
// 合并现有数据和更新数据,确保所有必需字段都存在
const mergedDto = {
registerStartTime:
(updateContestDto as any).registerStartTime ||
contest.registerStartTime.toISOString(),
registerEndTime:
(updateContestDto as any).registerEndTime ||
contest.registerEndTime.toISOString(),
submitStartTime:
(updateContestDto as any).submitStartTime ||
contest.submitStartTime.toISOString(),
submitEndTime:
(updateContestDto as any).submitEndTime ||
contest.submitEndTime.toISOString(),
reviewStartTime:
(updateContestDto as any).reviewStartTime ||
contest.reviewStartTime.toISOString(),
reviewEndTime:
(updateContestDto as any).reviewEndTime ||
contest.reviewEndTime.toISOString(),
resultPublishTime:
(updateContestDto as any).resultPublishTime ||
contest.resultPublishTime?.toISOString(),
startTime:
(updateContestDto as any).startTime ||
contest.startTime.toISOString(),
endTime:
(updateContestDto as any).endTime || contest.endTime.toISOString(),
} as CreateContestDto;
this.validateTimeOrder(mergedDto);
}
const data: any = {};
const dto = updateContestDto as any;
if (dto.contestName !== undefined) {
data.contestName = dto.contestName;
}
if (dto.contestType !== undefined) {
data.contestType = dto.contestType;
}
if (dto.startTime !== undefined) {
data.startTime = new Date(dto.startTime);
}
if (dto.endTime !== undefined) {
data.endTime = new Date(dto.endTime);
}
if (dto.address !== undefined) {
data.address = dto.address;
}
if (dto.content !== undefined) {
data.content = dto.content;
}
if (dto.contestTenants !== undefined) {
data.contestTenants = dto.contestTenants
? JSON.stringify(dto.contestTenants)
: null;
}
if (dto.coverUrl !== undefined) {
data.coverUrl = dto.coverUrl;
}
if (dto.posterUrl !== undefined) {
data.posterUrl = dto.posterUrl;
}
if (dto.contactName !== undefined) {
data.contactName = dto.contactName;
}
if (dto.contactPhone !== undefined) {
data.contactPhone = dto.contactPhone;
}
if (dto.contactQrcode !== undefined) {
data.contactQrcode = dto.contactQrcode;
}
if (dto.organizers !== undefined) {
data.organizers = dto.organizers ? JSON.stringify(dto.organizers) : null;
}
if (dto.coOrganizers !== undefined) {
data.coOrganizers = dto.coOrganizers
? JSON.stringify(dto.coOrganizers)
: null;
}
if (dto.sponsors !== undefined) {
data.sponsors = dto.sponsors ? JSON.stringify(dto.sponsors) : null;
}
if (dto.registerStartTime !== undefined) {
data.registerStartTime = new Date(dto.registerStartTime);
}
if (dto.registerEndTime !== undefined) {
data.registerEndTime = new Date(dto.registerEndTime);
}
if (dto.submitRule !== undefined) {
data.submitRule = dto.submitRule;
}
if (dto.submitStartTime !== undefined) {
data.submitStartTime = new Date(dto.submitStartTime);
}
if (dto.submitEndTime !== undefined) {
data.submitEndTime = new Date(dto.submitEndTime);
}
if (dto.reviewStartTime !== undefined) {
data.reviewStartTime = new Date(dto.reviewStartTime);
}
if (dto.reviewEndTime !== undefined) {
data.reviewEndTime = new Date(dto.reviewEndTime);
}
if (dto.resultPublishTime !== undefined) {
data.resultPublishTime = dto.resultPublishTime
? new Date(dto.resultPublishTime)
: null;
}
if (dto.contestState !== undefined) {
data.contestState = dto.contestState;
}
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.contest.update({
where: { id },
data,
include: {
attachments: {
where: { validState: 1 },
},
reviewRule: true,
_count: {
select: {
registrations: true,
works: true,
teams: true,
},
},
},
});
}
async publish(id: number, contestState: string, modifierId?: number) {
const contest = await this.prisma.contest.findUnique({
where: { id },
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
// 如果撤回比赛,检查是否有报名记录
if (
contestState === 'unpublished' &&
contest.contestState === 'published'
) {
const registrationCount = await this.prisma.contestRegistration.count({
where: { contestId: id },
});
if (registrationCount > 0) {
throw new BadRequestException('比赛已有报名记录,无法撤回');
}
}
const data: any = {
contestState,
};
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.contest.update({
where: { id },
data,
include: {
attachments: {
where: { validState: 1 },
},
reviewRule: true,
},
});
}
async remove(id: number) {
const contest = await this.prisma.contest.findUnique({
where: { id },
include: {
_count: {
select: {
registrations: true,
works: true,
teams: true,
},
},
},
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
// 检查是否有报名记录
if (contest._count.registrations > 0) {
throw new BadRequestException('比赛已有报名记录,无法删除');
}
// 软删除
return this.prisma.contest.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -0,0 +1,106 @@
import {
IsString,
IsDateString,
IsOptional,
IsEnum,
IsArray,
IsInt,
} from 'class-validator';
export enum ContestType {
INDIVIDUAL = 'individual',
TEAM = 'team',
}
export enum SubmitRule {
ONCE = 'once',
RESUBMIT = 'resubmit',
}
export class CreateContestDto {
@IsString()
contestName: string;
@IsEnum(ContestType)
contestType: ContestType;
@IsDateString()
startTime: string;
@IsDateString()
endTime: string;
@IsString()
@IsOptional()
address?: string;
@IsString()
@IsOptional()
content?: string;
@IsArray()
@IsInt({ each: true })
@IsOptional()
contestTenants?: number[];
@IsString()
@IsOptional()
coverUrl?: string;
@IsString()
@IsOptional()
posterUrl?: string;
@IsString()
@IsOptional()
contactName?: string;
@IsString()
@IsOptional()
contactPhone?: string;
@IsString()
@IsOptional()
contactQrcode?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
organizers?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
coOrganizers?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
sponsors?: string[];
@IsDateString()
registerStartTime: string;
@IsDateString()
registerEndTime: string;
@IsEnum(SubmitRule)
@IsOptional()
submitRule?: SubmitRule;
@IsDateString()
submitStartTime: string;
@IsDateString()
submitEndTime: string;
@IsDateString()
reviewStartTime: string;
@IsDateString()
reviewEndTime: string;
@IsDateString()
@IsOptional()
resultPublishTime?: string;
}

View File

@ -0,0 +1,8 @@
import { IsEnum } from 'class-validator';
import { ContestState } from './query-contest.dto';
export class PublishContestDto {
@IsEnum(ContestState)
contestState: ContestState;
}

View File

@ -0,0 +1,34 @@
import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export enum ContestState {
UNPUBLISHED = 'unpublished',
PUBLISHED = 'published',
}
export class QueryContestDto {
@IsInt()
@Min(1)
@Type(() => Number)
@IsOptional()
page?: number = 1;
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
@IsOptional()
pageSize?: number = 10;
@IsString()
@IsOptional()
contestName?: string;
@IsEnum(ContestState)
@IsOptional()
contestState?: ContestState;
@IsString()
@IsOptional()
contestType?: string;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateContestDto } from './create-contest.dto';
export class UpdateContestDto extends PartialType(CreateContestDto) {}

View File

@ -0,0 +1,24 @@
import { IsInt, IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
export class CreateJudgeDto {
@IsInt()
contestId: number;
@IsInt()
judgeId: number;
@IsString()
@IsOptional()
specialty?: string;
@IsNumber()
@Min(0)
@Max(1)
@IsOptional()
weight?: number;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateJudgeDto } from './create-judge.dto';
export class UpdateJudgeDto extends PartialType(CreateJudgeDto) {}

View File

@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { JudgesService } from './judges.service';
import { CreateJudgeDto } from './dto/create-judge.dto';
import { UpdateJudgeDto } from './dto/update-judge.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
@Controller('contests/judges')
@UseGuards(JwtAuthGuard)
export class JudgesController {
constructor(private readonly judgesService: JudgesService) {}
@Post()
@RequirePermission('contest:update')
create(@Body() createJudgeDto: CreateJudgeDto, @Request() req) {
const creatorId = req.user?.id;
return this.judgesService.create(createJudgeDto, creatorId);
}
@Get('contest/:contestId')
@RequirePermission('contest:read')
findAll(@Param('contestId', ParseIntPipe) contestId: number) {
return this.judgesService.findAll(contestId);
}
@Get(':id')
@RequirePermission('contest:read')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.judgesService.findOne(id);
}
@Patch(':id')
@RequirePermission('contest:update')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateJudgeDto: UpdateJudgeDto,
@Request() req,
) {
const modifierId = req.user?.id;
return this.judgesService.update(id, updateJudgeDto, modifierId);
}
@Delete(':id')
@RequirePermission('contest:update')
remove(@Param('id', ParseIntPipe) id: number) {
return this.judgesService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { JudgesService } from './judges.service';
import { JudgesController } from './judges.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [JudgesController],
providers: [JudgesService],
exports: [JudgesService],
})
export class JudgesModule {}

View File

@ -0,0 +1,209 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateJudgeDto } from './dto/create-judge.dto';
import { UpdateJudgeDto } from './dto/update-judge.dto';
@Injectable()
export class JudgesService {
constructor(private prisma: PrismaService) {}
async create(createJudgeDto: CreateJudgeDto, creatorId?: number) {
// 验证比赛是否存在
const contest = await this.prisma.contest.findUnique({
where: { id: createJudgeDto.contestId },
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
// 验证用户是否存在
const user = await this.prisma.user.findUnique({
where: { id: createJudgeDto.judgeId },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
// 检查是否已是评委
const existing = await this.prisma.contestJudge.findFirst({
where: {
contestId: createJudgeDto.contestId,
judgeId: createJudgeDto.judgeId,
validState: 1,
},
});
if (existing) {
throw new ConflictException('该用户已是该比赛的评委');
}
const data: any = {
contestId: createJudgeDto.contestId,
judgeId: createJudgeDto.judgeId,
specialty: createJudgeDto.specialty,
weight: createJudgeDto.weight,
description: createJudgeDto.description,
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.contestJudge.create({
data,
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
judge: {
select: {
id: true,
username: true,
nickname: true,
email: true,
},
},
},
});
}
async findAll(contestId: number) {
const judges = await this.prisma.contestJudge.findMany({
where: {
contestId,
validState: 1,
},
include: {
judge: {
select: {
id: true,
username: true,
nickname: true,
email: true,
},
},
},
orderBy: {
createTime: 'desc',
},
});
// 为每个评委添加统计数据
const judgesWithCounts = await Promise.all(
judges.map(async (judge) => {
const [assignedCount, scoredCount] = await Promise.all([
this.prisma.contestWorkJudgeAssignment.count({
where: {
contestId,
judgeId: judge.judgeId,
},
}),
this.prisma.contestWorkScore.count({
where: {
contestId,
judgeId: judge.judgeId,
validState: 1,
},
}),
]);
return {
...judge,
_count: {
assignedContestWorks: assignedCount,
scoredContestWorks: scoredCount,
},
};
}),
);
return judgesWithCounts;
}
async findOne(id: number) {
const judge = await this.prisma.contestJudge.findFirst({
where: {
id,
validState: 1,
},
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
judge: {
select: {
id: true,
username: true,
nickname: true,
email: true,
},
},
},
});
if (!judge) {
throw new NotFoundException('评委记录不存在');
}
return judge;
}
async update(
id: number,
updateJudgeDto: UpdateJudgeDto,
modifierId?: number,
) {
await this.findOne(id);
const data: any = { ...updateJudgeDto };
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.contestJudge.update({
where: { id },
data,
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
judge: {
select: {
id: true,
username: true,
nickname: true,
email: true,
},
},
},
});
}
async remove(id: number) {
await this.findOne(id);
// 软删除
return this.prisma.contestJudge.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -0,0 +1,29 @@
import { IsString, IsInt, IsEnum, IsOptional, Min, Max } from 'class-validator';
export enum NoticeType {
SYSTEM = 'system',
MANUAL = 'manual',
URGENT = 'urgent',
}
export class CreateNoticeDto {
@IsInt()
contestId: number;
@IsString()
title: string;
@IsString()
content: string;
@IsEnum(NoticeType)
@IsOptional()
noticeType?: NoticeType;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
priority?: number;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateNoticeDto } from './create-notice.dto';
export class UpdateNoticeDto extends PartialType(CreateNoticeDto) {}

View File

@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
import { NoticesService } from './notices.service';
import { CreateNoticeDto } from './dto/create-notice.dto';
import { UpdateNoticeDto } from './dto/update-notice.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
@Controller('contests/notices')
@UseGuards(JwtAuthGuard)
export class NoticesController {
constructor(private readonly noticesService: NoticesService) {}
@Post()
@RequirePermission('notice:create')
create(@Body() createNoticeDto: CreateNoticeDto, @Request() req) {
const creatorId = req.user?.id;
return this.noticesService.create(createNoticeDto, creatorId);
}
@Get('contest/:contestId')
@RequirePermission('notice:read')
findAll(@Param('contestId', ParseIntPipe) contestId: number) {
return this.noticesService.findAll(contestId);
}
@Get(':id')
@RequirePermission('notice:read')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.noticesService.findOne(id);
}
@Patch(':id')
@RequirePermission('notice:update')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateNoticeDto: UpdateNoticeDto,
@Request() req,
) {
const modifierId = req.user?.id;
return this.noticesService.update(id, updateNoticeDto, modifierId);
}
@Delete(':id')
@RequirePermission('notice:delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.noticesService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { NoticesService } from './notices.service';
import { NoticesController } from './notices.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [NoticesController],
providers: [NoticesService],
exports: [NoticesService],
})
export class NoticesModule {}

View File

@ -0,0 +1,128 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateNoticeDto } from './dto/create-notice.dto';
import { UpdateNoticeDto } from './dto/update-notice.dto';
@Injectable()
export class NoticesService {
constructor(private prisma: PrismaService) {}
async create(createNoticeDto: CreateNoticeDto, creatorId?: number) {
// 验证比赛是否存在
const contest = await this.prisma.contest.findUnique({
where: { id: createNoticeDto.contestId },
});
if (!contest) {
throw new NotFoundException('比赛不存在');
}
const data: any = {
contestId: createNoticeDto.contestId,
title: createNoticeDto.title,
content: createNoticeDto.content,
noticeType: createNoticeDto.noticeType || 'manual',
priority: createNoticeDto.priority || 0,
publishTime: new Date(), // 创建即发布
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.contestNotice.create({
data,
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
},
});
}
async findAll(contestId: number) {
return this.prisma.contestNotice.findMany({
where: {
contestId,
validState: 1,
},
orderBy: [
{ priority: 'desc' },
{ publishTime: 'desc' },
],
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
},
});
}
async findOne(id: number) {
const notice = await this.prisma.contestNotice.findFirst({
where: {
id,
validState: 1,
},
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
},
});
if (!notice) {
throw new NotFoundException('公告不存在');
}
return notice;
}
async update(id: number, updateNoticeDto: UpdateNoticeDto, modifierId?: number) {
await this.findOne(id);
const data: any = { ...updateNoticeDto };
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.contestNotice.update({
where: { id },
data,
include: {
contest: {
select: {
id: true,
contestName: true,
},
},
},
});
}
async remove(id: number) {
await this.findOne(id);
// 软删除
return this.prisma.contestNotice.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -0,0 +1,22 @@
import { IsInt, IsString, IsOptional, IsEnum } from 'class-validator';
export enum RegistrationType {
INDIVIDUAL = 'individual',
TEAM = 'team',
}
export class CreateRegistrationDto {
@IsInt()
contestId: number;
@IsEnum(RegistrationType)
registrationType: RegistrationType;
@IsInt()
@IsOptional()
teamId?: number;
@IsInt()
userId: number;
}

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