feat: 学校模块与比赛模块
This commit is contained in:
parent
7800b7786d
commit
5d34307a69
156
.cursor/CHROME_DEVTOOLS_MCP_SETUP.md
Normal file
156
.cursor/CHROME_DEVTOOLS_MCP_SETUP.md
Normal 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
|
||||
```
|
||||
|
||||
167
.cursor/MIGRATION_SUMMARY.md
Normal file
167
.cursor/MIGRATION_SUMMARY.md
Normal 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
128
.cursor/RULES_README.md
Normal 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 Settings(Cmd/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
7
.cursor/mcp.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "/Users/wwzh/Awesome/runfast/competition-management-system/.cursor/scripts/chrome-devtools-mcp.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
221
.cursor/rules/backend-architecture.mdc
Normal file
221
.cursor/rules/backend-architecture.mdc
Normal 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()
|
||||
- 使用解构赋值提高代码可读性
|
||||
- 复杂逻辑必须添加注释
|
||||
112
.cursor/rules/code-review-checklist.mdc
Normal file
112
.cursor/rules/code-review-checklist.mdc
Normal 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. **性能**:是否有明显的性能问题
|
||||
278
.cursor/rules/database-design.mdc
Normal file
278
.cursor/rules/database-design.mdc
Normal 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 查询问题
|
||||
348
.cursor/rules/frontend-architecture.mdc
Normal file
348
.cursor/rules/frontend-architecture.mdc
Normal 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` 优化性能。
|
||||
101
.cursor/rules/multi-tenant.mdc
Normal file
101
.cursor/rules/multi-tenant.mdc
Normal 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` 字段
|
||||
41
.cursor/rules/project-overview.mdc
Normal file
41
.cursor/rules/project-overview.mdc
Normal 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 提交信息使用中文,格式:`类型: 描述`
|
||||
14
.cursor/scripts/chrome-devtools-mcp.sh
Executable file
14
.cursor/scripts/chrome-devtools-mcp.sh
Executable 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
100
.cursorignore
Normal 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
293
.cursorrules
Normal 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` 字段
|
||||
|
||||
128
AGENTS.md
Normal file
128
AGENTS.md
Normal 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`
|
||||
- 使用动态路由(基于菜单权限)
|
||||
|
||||
### 状态管理
|
||||
- 使用 Pinia,store 命名:`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`!
|
||||
|
||||
122
backend/.cursor/rules/backend-specific.mdc
Normal file
122
backend/.cursor/rules/backend-specific.mdc
Normal 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 # 入口文件
|
||||
```
|
||||
172
backend/data/menus.json
Normal file
172
backend/data/menus.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
618
backend/data/permissions.json
Normal file
618
backend/data/permissions.json
Normal 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": "允许发布公告"
|
||||
}
|
||||
]
|
||||
271
backend/docs/CONTEST_JUDGE_DESIGN.md
Normal file
271
backend/docs/CONTEST_JUDGE_DESIGN.md
Normal 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. 保证数据一致性和查询效率
|
||||
|
||||
301
backend/docs/SCHOOL_MODULE_SCHEMA.md
Normal file
301
backend/docs/SCHOOL_MODULE_SCHEMA.md
Normal 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`: 兴趣班ID(type=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. **通知公告**:可以添加通知表、公告表等
|
||||
|
||||
1
backend/docs/功能描述.md
Normal file
1
backend/docs/功能描述.md
Normal file
@ -0,0 +1 @@
|
||||
##
|
||||
@ -18,10 +18,11 @@
|
||||
"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_tenant_support",
|
||||
"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",
|
||||
@ -32,6 +33,8 @@
|
||||
"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": {
|
||||
@ -39,9 +42,10 @@
|
||||
"@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": "^5.9.1",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@ -70,7 +74,7 @@
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.9.1",
|
||||
"prisma": "^6.19.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
||||
@ -30,8 +30,20 @@ model Tenant {
|
||||
permissions Permission[]
|
||||
dicts Dict[]
|
||||
configs Config[]
|
||||
creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||
modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||
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")
|
||||
}
|
||||
@ -51,27 +63,72 @@ model User {
|
||||
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")
|
||||
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])
|
||||
@ -91,11 +148,11 @@ model Role {
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
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)
|
||||
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])
|
||||
@ -130,10 +187,10 @@ model Permission {
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
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)
|
||||
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])
|
||||
@ -169,11 +226,11 @@ model Menu {
|
||||
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)
|
||||
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")
|
||||
}
|
||||
@ -245,9 +302,9 @@ model Config {
|
||||
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)
|
||||
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")
|
||||
@ -258,7 +315,7 @@ model Log {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int? @map("user_id") /// 用户ID
|
||||
action String /// 操作类型
|
||||
content String? @db.Text /// 操作内容(使用 TEXT 类型支持长文本)
|
||||
content String? @db.Text /// 操作内容(使用 TEXT 类型支持长文本)
|
||||
ip String? /// IP地址
|
||||
userAgent String? @map("user_agent") /// 用户代理
|
||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||
@ -267,3 +324,548 @@ model Log {
|
||||
|
||||
@@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")
|
||||
}
|
||||
|
||||
@ -30,74 +30,19 @@ if (!process.env.DATABASE_URL) {
|
||||
}
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 根据路由配置定义的菜单数据
|
||||
const menus = [
|
||||
// 顶级菜单:仪表盘
|
||||
{
|
||||
name: '仪表盘',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
component: 'dashboard/Index',
|
||||
parentId: null,
|
||||
sort: 1,
|
||||
},
|
||||
// 父菜单:系统管理
|
||||
{
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
component: null, // 父菜单不需要组件
|
||||
parentId: null,
|
||||
sort: 10,
|
||||
children: [
|
||||
{
|
||||
name: '用户管理',
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
component: 'system/users/Index',
|
||||
sort: 1,
|
||||
},
|
||||
{
|
||||
name: '角色管理',
|
||||
path: '/system/roles',
|
||||
icon: 'TeamOutlined',
|
||||
component: 'system/roles/Index',
|
||||
sort: 2,
|
||||
},
|
||||
{
|
||||
name: '菜单管理',
|
||||
path: '/system/menus',
|
||||
icon: 'MenuOutlined',
|
||||
component: 'system/menus/Index',
|
||||
sort: 3,
|
||||
},
|
||||
{
|
||||
name: '数据字典',
|
||||
path: '/system/dict',
|
||||
icon: 'BookOutlined',
|
||||
component: 'system/dict/Index',
|
||||
sort: 4,
|
||||
},
|
||||
{
|
||||
name: '系统配置',
|
||||
path: '/system/config',
|
||||
icon: 'ToolOutlined',
|
||||
component: 'system/config/Index',
|
||||
sort: 5,
|
||||
},
|
||||
{
|
||||
name: '日志记录',
|
||||
path: '/system/logs',
|
||||
icon: 'FileTextOutlined',
|
||||
component: 'system/logs/Index',
|
||||
sort: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// 从 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 {
|
||||
@ -125,6 +70,7 @@ async function initMenus() {
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
permission: menuFields.permission || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
@ -138,6 +84,7 @@ async function initMenus() {
|
||||
path: menuFields.path || null,
|
||||
icon: menuFields.icon || null,
|
||||
component: menuFields.component || null,
|
||||
permission: menuFields.permission || null,
|
||||
parentId: parentId,
|
||||
sort: menuFields.sort || 0,
|
||||
validState: 1,
|
||||
|
||||
@ -35,241 +35,174 @@ import * as bcrypt from 'bcrypt';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始初始化超级租户...');
|
||||
console.log('🚀 开始初始化超级租户...\n');
|
||||
|
||||
// 检查是否已存在超级租户
|
||||
const existingSuperTenant = await prisma.tenant.findFirst({
|
||||
let superTenant = await prisma.tenant.findFirst({
|
||||
where: { isSuper: 1 },
|
||||
});
|
||||
|
||||
if (existingSuperTenant) {
|
||||
console.log('超级租户已存在,跳过创建');
|
||||
console.log(`租户编码: ${existingSuperTenant.code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建超级租户
|
||||
const superTenant = await prisma.tenant.create({
|
||||
data: {
|
||||
name: '超级租户',
|
||||
code: 'super',
|
||||
domain: 'super',
|
||||
description: '系统超级租户,拥有所有权限',
|
||||
isSuper: 1,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级租户创建成功!');
|
||||
console.log(`租户ID: ${superTenant.id}`);
|
||||
console.log(`租户编码: ${superTenant.code}`);
|
||||
console.log(`租户名称: ${superTenant.name}`);
|
||||
|
||||
// 创建超级管理员用户
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
|
||||
const superAdmin = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
nickname: '超级管理员',
|
||||
email: 'admin@super.com',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员用户创建成功!');
|
||||
console.log(`用户名: ${superAdmin.username}`);
|
||||
console.log(`密码: admin123`);
|
||||
console.log(`用户ID: ${superAdmin.id}`);
|
||||
|
||||
// 创建超级管理员角色
|
||||
const superAdminRole = await prisma.role.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '超级管理员角色,拥有所有权限',
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员角色创建成功!');
|
||||
console.log(`角色编码: ${superAdminRole.code}`);
|
||||
|
||||
// 将超级管理员角色分配给用户
|
||||
await prisma.userRole.create({
|
||||
data: {
|
||||
userId: superAdmin.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('超级管理员角色已分配给用户');
|
||||
|
||||
// 创建基础权限
|
||||
const permissions = [
|
||||
{
|
||||
name: '租户管理-创建',
|
||||
code: 'tenant:create',
|
||||
resource: 'tenant',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '租户管理-查看',
|
||||
code: 'tenant:read',
|
||||
resource: 'tenant',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '租户管理-更新',
|
||||
code: 'tenant:update',
|
||||
resource: 'tenant',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '租户管理-删除',
|
||||
code: 'tenant:delete',
|
||||
resource: 'tenant',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '用户管理-创建',
|
||||
code: 'user:create',
|
||||
resource: 'user',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '用户管理-查看',
|
||||
code: 'user:read',
|
||||
resource: 'user',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '用户管理-更新',
|
||||
code: 'user:update',
|
||||
resource: 'user',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '用户管理-删除',
|
||||
code: 'user:delete',
|
||||
resource: 'user',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '角色管理-创建',
|
||||
code: 'role:create',
|
||||
resource: 'role',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '角色管理-查看',
|
||||
code: 'role:read',
|
||||
resource: 'role',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '角色管理-更新',
|
||||
code: 'role:update',
|
||||
resource: 'role',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '角色管理-删除',
|
||||
code: 'role:delete',
|
||||
resource: 'role',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '权限管理-创建',
|
||||
code: 'permission:create',
|
||||
resource: 'permission',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '权限管理-查看',
|
||||
code: 'permission:read',
|
||||
resource: 'permission',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '权限管理-更新',
|
||||
code: 'permission:update',
|
||||
resource: 'permission',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '权限管理-删除',
|
||||
code: 'permission:delete',
|
||||
resource: 'permission',
|
||||
action: 'delete',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-创建',
|
||||
code: 'menu:create',
|
||||
resource: 'menu',
|
||||
action: 'create',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-查看',
|
||||
code: 'menu:read',
|
||||
resource: 'menu',
|
||||
action: 'read',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-更新',
|
||||
code: 'menu:update',
|
||||
resource: 'menu',
|
||||
action: 'update',
|
||||
},
|
||||
{
|
||||
name: '菜单管理-删除',
|
||||
code: 'menu:delete',
|
||||
resource: 'menu',
|
||||
action: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
const createdPermissions = [];
|
||||
for (const perm of permissions) {
|
||||
const existing = await prisma.permission.findFirst({
|
||||
where: {
|
||||
tenantId: superTenant.id,
|
||||
code: perm.code,
|
||||
if (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,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const permission = await prisma.permission.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
...perm,
|
||||
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}`);
|
||||
});
|
||||
createdPermissions.push(permission);
|
||||
console.log(`\n✅ 超级租户现在拥有 ${allMenus.length} 个菜单\n`);
|
||||
} else {
|
||||
createdPermissions.push(existing);
|
||||
console.log(`✅ 超级租户已拥有所有菜单(${allMenus.length} 个)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`创建了 ${createdPermissions.length} 个权限`);
|
||||
|
||||
// 将所有权限分配给超级管理员角色
|
||||
await prisma.rolePermission.createMany({
|
||||
data: createdPermissions.map((perm) => ({
|
||||
roleId: superAdminRole.id,
|
||||
permissionId: perm.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
console.log('所有权限已分配给超级管理员角色');
|
||||
|
||||
// 创建租户管理菜单(如果不存在)
|
||||
console.log('\n创建租户管理菜单...');
|
||||
console.log('📋 步骤 5: 创建租户管理菜单(如果不存在)...\n');
|
||||
|
||||
// 查找系统管理菜单(父菜单)
|
||||
const systemMenu = await prisma.menu.findFirst({
|
||||
@ -302,14 +235,21 @@ async function main() {
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
console.log('租户管理菜单创建成功');
|
||||
console.log('✅ 租户管理菜单创建成功');
|
||||
|
||||
// 为超级租户分配租户管理菜单
|
||||
await prisma.tenantMenu.create({
|
||||
data: {
|
||||
tenantId: superTenant.id,
|
||||
menuId: tenantMenu.id,
|
||||
},
|
||||
});
|
||||
console.log('✅ 租户管理菜单已分配给超级租户\n');
|
||||
} else {
|
||||
tenantMenu = existingTenantMenu;
|
||||
console.log('租户管理菜单已存在,跳过创建');
|
||||
}
|
||||
console.log('✅ 租户管理菜单已存在');
|
||||
|
||||
// 为超级租户分配租户管理菜单
|
||||
if (tenantMenu) {
|
||||
// 检查是否已分配
|
||||
const existingTenantMenuRelation = await prisma.tenantMenu.findFirst({
|
||||
where: {
|
||||
tenantId: superTenant.id,
|
||||
@ -324,26 +264,48 @@ async function main() {
|
||||
menuId: tenantMenu.id,
|
||||
},
|
||||
});
|
||||
console.log('租户管理菜单已分配给超级租户');
|
||||
console.log('✅ 租户管理菜单已分配给超级租户\n');
|
||||
} else {
|
||||
console.log('租户管理菜单已分配给超级租户,跳过');
|
||||
console.log('✅ 租户管理菜单已分配给超级租户,跳过\n');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('警告:未找到系统管理菜单,无法创建租户管理菜单');
|
||||
console.log('⚠️ 警告:未找到系统管理菜单,无法创建租户管理菜单\n');
|
||||
}
|
||||
|
||||
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(` 密码: admin123`);
|
||||
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()
|
||||
|
||||
429
backend/scripts/init-tenant-menu-permissions.ts
Normal file
429
backend/scripts/init-tenant-menu-permissions.ts
Normal 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);
|
||||
});
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function verifyAdmin() {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log('\n✅ 验证结果:');
|
||||
console.log(`用户名: ${user.username}`);
|
||||
console.log(`昵称: ${user.nickname}`);
|
||||
console.log(`邮箱: ${user.email || '未设置'}`);
|
||||
console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`);
|
||||
console.log(`\n角色列表:`);
|
||||
user.roles.forEach((ur) => {
|
||||
console.log(` - ${ur.role.name} (${ur.role.code})`);
|
||||
console.log(` 权限数量: ${ur.role.permissions.length}`);
|
||||
});
|
||||
|
||||
const allPermissions = new Set();
|
||||
user.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
allPermissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n总权限数: ${allPermissions.size}`);
|
||||
console.log(`\n权限列表 (前10个):`);
|
||||
Array.from(allPermissions).sort().slice(0, 10).forEach((perm) => {
|
||||
console.log(` - ${perm}`);
|
||||
});
|
||||
if (allPermissions.size > 10) {
|
||||
console.log(` ... 还有 ${allPermissions.size - 10} 个权限`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 未找到 admin 用户');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error.message);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
verifyAdmin();
|
||||
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function verifyAdmin() {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log('\n✅ 验证结果:');
|
||||
console.log(`用户名: ${user.username}`);
|
||||
console.log(`昵称: ${user.nickname}`);
|
||||
console.log(`邮箱: ${user.email || '未设置'}`);
|
||||
console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`);
|
||||
console.log(`\n角色列表:`);
|
||||
user.roles.forEach((ur) => {
|
||||
console.log(` - ${ur.role.name} (${ur.role.code})`);
|
||||
console.log(` 权限数量: ${ur.role.permissions.length}`);
|
||||
});
|
||||
|
||||
const allPermissions = new Set<string>();
|
||||
user.roles.forEach((ur) => {
|
||||
ur.role.permissions.forEach((rp) => {
|
||||
allPermissions.add(rp.permission.code);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n总权限数: ${allPermissions.size}`);
|
||||
console.log(`\n权限列表:`);
|
||||
Array.from(allPermissions)
|
||||
.sort()
|
||||
.forEach((perm) => {
|
||||
console.log(` - ${perm}`);
|
||||
});
|
||||
} else {
|
||||
console.log('❌ 未找到 admin 用户');
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
verifyAdmin();
|
||||
@ -1,61 +1,153 @@
|
||||
-- ============================================
|
||||
-- 赛事管理模块数据库表结构
|
||||
-- ============================================
|
||||
|
||||
-- 1. 赛事表
|
||||
CREATE TABLE `t_contest` (
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
||||
`contest_name` varchar(127) NOT NULL COMMENT '赛事名称',
|
||||
`contest_type` varchar(31) NOT NULL COMMENT '赛事类型,字典:contest_type:individual/team',
|
||||
`contest_state` varchar(31) NOT NULL COMMENT '赛事状态(未发布: unpublished 已发布: published'),
|
||||
`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_tenant` 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` text DEFAULT NULL COMMENT '主办单位数组',
|
||||
`co_organizers` text DEFAULT NULL COMMENT '协办单位数组',
|
||||
`sponsors` text 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` varchar(63) DEFAULT NULL COMMENT '评审规则id',
|
||||
`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` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' 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` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`contest_id`) USING BTREE,
|
||||
UNIQUE KEY `uk_contest_name` (`contest_name`) USING BTREE
|
||||
`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` varchar(63) NOT NULL,
|
||||
`contest_id` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '赛事id',
|
||||
`file_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名',
|
||||
`file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件路径',
|
||||
`format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`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` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`)
|
||||
`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` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id',
|
||||
`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 '作品说明',
|
||||
@ -64,137 +156,121 @@ CREATE TABLE `t_contest_work` (
|
||||
`is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否',
|
||||
`status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted',
|
||||
`submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
|
||||
`submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id',
|
||||
`submitter_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 '作品预览URL(3D/视频)',
|
||||
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`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` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
|
||||
`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_key`,`contest_id`,`is_latest`),
|
||||
KEY `idx_work_entry` (`entry_id`),
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`)
|
||||
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` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`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) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`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_key`,`contest_id`,`work_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` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)',
|
||||
`judge_id` varchar(63) NOT NULL COMMENT '评委账号id',
|
||||
`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` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' 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` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_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='作品评分表';
|
||||
|
||||
CREATE TABLE `t_contest_work_score` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)',
|
||||
`judge_id` varchar(63) NOT NULL COMMENT '评委账号id',
|
||||
`judge_name` varchar(127) NOT NULL COMMENT '评委姓名',
|
||||
`dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}',
|
||||
`total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)',
|
||||
`comments` text DEFAULT NULL COMMENT '评语',
|
||||
`score_time` datetime NOT NULL COMMENT '评分时间',
|
||||
`creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
-- 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` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
||||
|
||||
CREATE TABLE `t_contest_registration` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`tenant_key` varchar(64) NOT NULL COMMENT '所属租户键(学校/机构)',
|
||||
`registration_type` varchar(20) DEFAULT NULL COMMENT '报名类型:individual(个人)/team(团队)',
|
||||
`team_id` varchar(64) DEFAULT NULL COMMENT '团队id',
|
||||
`team_name` varchar(255) DEFAULT NULL COMMENT '团队名称快照(团队赛)',
|
||||
`account_id` varchar(64) NOT NULL COMMENT '账号id',
|
||||
`account_no` varchar(64) NOT NULL COMMENT '报名账号(记录报名快照)',
|
||||
`account_name` varchar(100) NOT NULL COMMENT '报名账号名称(记录报名快照)',
|
||||
`role` varchar(63) DEFAULT NULL COMMENT '报名角色快照:leader(队长)/member(队员)/mentor(指导教师)',
|
||||
`registration_state` varchar(31) NOT NULL COMMENT '报名状态:pending(待审核)、passed(已通过)、rejected(已拒绝)、withdrawn(已撤回)',
|
||||
`registrant` varchar(63) DEFAULT NULL COMMENT '实际报名人(老师报名填老师账号)',
|
||||
`registration_time` datetime NOT NULL COMMENT '报名时间',
|
||||
`reason` varchar(1023) DEFAULT NULL COMMENT '审核理由',
|
||||
`operator` varchar(64) DEFAULT NULL COMMENT '审核人',
|
||||
`operation_date` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事报名人员记录表';
|
||||
|
||||
CREATE TABLE `t_contest_team` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
|
||||
`leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id',
|
||||
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`),
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队';
|
||||
|
||||
CREATE TABLE `t_contest_team_member` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '成员所属租户键',
|
||||
`team_id` varchar(63) NOT NULL COMMENT '团队id',
|
||||
`account_id` varchar(63) NOT NULL COMMENT '成员用户id',
|
||||
`role` varchar(31) NOT NULL DEFAULT 'member' COMMENT '成员角色:member/leader/mentor',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_member_once` (`tenant_key`,`team_id`,`account_id`),
|
||||
KEY `idx_team` (`team_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='团队成员';
|
||||
KEY `idx_contest` (`contest_id`),
|
||||
KEY `idx_publish_time` (`publish_time`),
|
||||
KEY `idx_notice_type` (`notice_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事公告表';
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
# 赛事管理 SQL 文件修复清单
|
||||
|
||||
## ⚠️ 需要修复的问题
|
||||
|
||||
### 1. 删除重复的表定义
|
||||
|
||||
**问题位置:** 第125-144行
|
||||
**问题描述:** `t_contest_work_score` 表被定义了两次(第104-123行和第125-144行)
|
||||
**修复方案:** 删除第125-144行的重复定义
|
||||
|
||||
```sql
|
||||
-- 删除以下重复定义(第125-144行)
|
||||
CREATE TABLE `t_contest_work_score` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
...
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
||||
```
|
||||
|
||||
### 2. 修复表定义语法错误
|
||||
|
||||
**问题位置:** 第102行
|
||||
**问题描述:** `t_contest_work_attachment` 表定义末尾有多余的逗号
|
||||
**修复方案:** 删除第101行末尾的逗号
|
||||
|
||||
```sql
|
||||
-- 第101行,删除末尾逗号
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`), -- ❌ 错误:末尾有逗号
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 正确:删除逗号
|
||||
```
|
||||
|
||||
### 3. 修复索引字段名错误
|
||||
|
||||
**问题位置:** 第183行
|
||||
**问题描述:** `t_contest_team` 表的唯一索引引用了不存在的字段 `name`,实际字段名是 `team_name`
|
||||
**修复方案:** 将索引字段名改为 `team_name`
|
||||
|
||||
```sql
|
||||
-- 第183行,修改前:
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`), -- ❌ 错误:字段名错误
|
||||
|
||||
-- 修改后:
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 正确
|
||||
```
|
||||
|
||||
### 4. 修复索引字段不存在错误
|
||||
|
||||
**问题位置:** 第82行
|
||||
**问题描述:** `t_contest_work` 表的索引 `idx_submit_filter` 引用了不存在的字段 `review_status`
|
||||
**修复方案:** 删除该索引或修改为存在的字段
|
||||
|
||||
```sql
|
||||
-- 第82行,修改前:
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`) -- ❌ 错误:review_status 字段不存在
|
||||
|
||||
-- 修改方案1:删除该索引(如果不需要)
|
||||
-- 直接删除这一行
|
||||
|
||||
-- 修改方案2:修改为存在的字段(如果需要该索引)
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 使用 status 字段
|
||||
```
|
||||
|
||||
## 📝 修复后的完整 SQL(关键部分)
|
||||
|
||||
### t_contest_work_attachment 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_work_attachment` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`work_id` varchar(63) NOT NULL COMMENT '作品id',
|
||||
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
|
||||
`format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
||||
`file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)',
|
||||
`size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 已删除末尾逗号
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表';
|
||||
```
|
||||
|
||||
### t_contest_work 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_work` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id',
|
||||
`work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)',
|
||||
`title` varchar(255) NOT NULL COMMENT '作品标题',
|
||||
`description` text DEFAULT NULL COMMENT '作品说明',
|
||||
`files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)',
|
||||
`is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否',
|
||||
`status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted',
|
||||
`submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
|
||||
`submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id',
|
||||
`submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)',
|
||||
`submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader',
|
||||
`preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)',
|
||||
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_work_no` (`work_no`),
|
||||
KEY `idx_work_contest_latest` (`tenant_key`,`contest_id`,`is_latest`),
|
||||
KEY `idx_work_entry` (`entry_id`),
|
||||
KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 已修复:使用 status 字段
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品';
|
||||
```
|
||||
|
||||
### t_contest_team 表(修复后)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `t_contest_team` (
|
||||
`id` varchar(63) NOT NULL COMMENT '主键id',
|
||||
`tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键',
|
||||
`contest_id` varchar(63) NOT NULL COMMENT '赛事id',
|
||||
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
|
||||
`leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id',
|
||||
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 已修复:使用 team_name 字段
|
||||
KEY `idx_contest` (`contest_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队';
|
||||
```
|
||||
|
||||
## 🔍 验证步骤
|
||||
|
||||
修复完成后,请执行以下验证:
|
||||
|
||||
1. **语法检查**
|
||||
```bash
|
||||
# 使用 MySQL 客户端检查语法
|
||||
mysql -u root -p < competition.sql
|
||||
```
|
||||
|
||||
2. **表结构验证**
|
||||
```sql
|
||||
-- 检查表是否存在
|
||||
SHOW TABLES LIKE 't_contest%';
|
||||
|
||||
-- 检查表结构
|
||||
DESCRIBE t_contest_work;
|
||||
DESCRIBE t_contest_work_attachment;
|
||||
DESCRIBE t_contest_team;
|
||||
DESCRIBE t_contest_work_score;
|
||||
```
|
||||
|
||||
3. **索引验证**
|
||||
```sql
|
||||
-- 检查索引是否正确
|
||||
SHOW INDEX FROM t_contest_work;
|
||||
SHOW INDEX FROM t_contest_team;
|
||||
```
|
||||
|
||||
## 📌 建议
|
||||
|
||||
在修复 SQL 文件后,建议:
|
||||
|
||||
1. **创建数据库迁移文件**:使用 Prisma Migrate 或手动创建迁移
|
||||
2. **更新 Prisma Schema**:将修复后的表结构同步到 `schema.prisma`
|
||||
3. **测试数据插入**:插入测试数据验证表结构正确性
|
||||
4. **备份数据库**:在执行迁移前备份现有数据
|
||||
|
||||
@ -11,6 +11,8 @@ 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';
|
||||
@ -38,6 +40,8 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
SystemConfigModule,
|
||||
LogsModule,
|
||||
TenantsModule,
|
||||
SchoolModule,
|
||||
ContestsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@ -12,7 +16,11 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, password: string, tenantId?: number): Promise<any> {
|
||||
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))) {
|
||||
// 验证租户是否匹配
|
||||
@ -20,6 +28,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('用户不属于该租户');
|
||||
}
|
||||
const { password, ...result } = user;
|
||||
password;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
|
||||
60
backend/src/contests/attachments/attachments.controller.ts
Normal file
60
backend/src/contests/attachments/attachments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/attachments/attachments.module.ts
Normal file
13
backend/src/contests/attachments/attachments.module.ts
Normal 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 {}
|
||||
|
||||
108
backend/src/contests/attachments/attachments.service.ts
Normal file
108
backend/src/contests/attachments/attachments.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateAttachmentDto } from './create-attachment.dto';
|
||||
|
||||
export class UpdateAttachmentDto extends PartialType(CreateAttachmentDto) {}
|
||||
|
||||
37
backend/src/contests/contests.module.ts
Normal file
37
backend/src/contests/contests.module.ts
Normal 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 {}
|
||||
|
||||
76
backend/src/contests/contests/contests.controller.ts
Normal file
76
backend/src/contests/contests/contests.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/contests/contests.module.ts
Normal file
13
backend/src/contests/contests/contests.module.ts
Normal 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 {}
|
||||
|
||||
550
backend/src/contests/contests/contests.service.ts
Normal file
550
backend/src/contests/contests/contests.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
106
backend/src/contests/contests/dto/create-contest.dto.ts
Normal file
106
backend/src/contests/contests/dto/create-contest.dto.ts
Normal 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;
|
||||
}
|
||||
8
backend/src/contests/contests/dto/publish-contest.dto.ts
Normal file
8
backend/src/contests/contests/dto/publish-contest.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ContestState } from './query-contest.dto';
|
||||
|
||||
export class PublishContestDto {
|
||||
@IsEnum(ContestState)
|
||||
contestState: ContestState;
|
||||
}
|
||||
|
||||
34
backend/src/contests/contests/dto/query-contest.dto.ts
Normal file
34
backend/src/contests/contests/dto/query-contest.dto.ts
Normal 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;
|
||||
}
|
||||
5
backend/src/contests/contests/dto/update-contest.dto.ts
Normal file
5
backend/src/contests/contests/dto/update-contest.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateContestDto } from './create-contest.dto';
|
||||
|
||||
export class UpdateContestDto extends PartialType(CreateContestDto) {}
|
||||
|
||||
24
backend/src/contests/judges/dto/create-judge.dto.ts
Normal file
24
backend/src/contests/judges/dto/create-judge.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
5
backend/src/contests/judges/dto/update-judge.dto.ts
Normal file
5
backend/src/contests/judges/dto/update-judge.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateJudgeDto } from './create-judge.dto';
|
||||
|
||||
export class UpdateJudgeDto extends PartialType(CreateJudgeDto) {}
|
||||
|
||||
60
backend/src/contests/judges/judges.controller.ts
Normal file
60
backend/src/contests/judges/judges.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/judges/judges.module.ts
Normal file
13
backend/src/contests/judges/judges.module.ts
Normal 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 {}
|
||||
|
||||
209
backend/src/contests/judges/judges.service.ts
Normal file
209
backend/src/contests/judges/judges.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
29
backend/src/contests/notices/dto/create-notice.dto.ts
Normal file
29
backend/src/contests/notices/dto/create-notice.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
5
backend/src/contests/notices/dto/update-notice.dto.ts
Normal file
5
backend/src/contests/notices/dto/update-notice.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateNoticeDto } from './create-notice.dto';
|
||||
|
||||
export class UpdateNoticeDto extends PartialType(CreateNoticeDto) {}
|
||||
|
||||
60
backend/src/contests/notices/notices.controller.ts
Normal file
60
backend/src/contests/notices/notices.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/notices/notices.module.ts
Normal file
13
backend/src/contests/notices/notices.module.ts
Normal 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 {}
|
||||
|
||||
128
backend/src/contests/notices/notices.service.ts
Normal file
128
backend/src/contests/notices/notices.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { RegistrationState } from './review-registration.dto';
|
||||
|
||||
export class QueryRegistrationDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
page?: number = 1;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
pageSize?: number = 10;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
contestId?: number;
|
||||
|
||||
@IsEnum(RegistrationState)
|
||||
@IsOptional()
|
||||
registrationState?: RegistrationState;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
registrationType?: string;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { IsEnum, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export enum RegistrationState {
|
||||
PENDING = 'pending',
|
||||
PASSED = 'passed',
|
||||
REJECTED = 'rejected',
|
||||
WITHDRAWN = 'withdrawn',
|
||||
}
|
||||
|
||||
export class ReviewRegistrationDto {
|
||||
@IsEnum(RegistrationState)
|
||||
registrationState: RegistrationState;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { RegistrationsService } from './registrations.service';
|
||||
import { CreateRegistrationDto } from './dto/create-registration.dto';
|
||||
import { ReviewRegistrationDto } from './dto/review-registration.dto';
|
||||
import { QueryRegistrationDto } from './dto/query-registration.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('contests/registrations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RegistrationsController {
|
||||
constructor(private readonly registrationsService: RegistrationsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('contest:register')
|
||||
create(@Body() createRegistrationDto: CreateRegistrationDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.registrationsService.create(
|
||||
createRegistrationDto,
|
||||
tenantId,
|
||||
creatorId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('contest:read')
|
||||
findAll(@Query() queryDto: QueryRegistrationDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.registrationsService.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.registrationsService.findOne(id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id/review')
|
||||
@RequirePermission('contest:update')
|
||||
review(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() reviewDto: ReviewRegistrationDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const operatorId = req.user?.id;
|
||||
return this.registrationsService.review(id, reviewDto, operatorId, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('contest:update')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.registrationsService.remove(id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/registrations/registrations.module.ts
Normal file
13
backend/src/contests/registrations/registrations.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RegistrationsService } from './registrations.service';
|
||||
import { RegistrationsController } from './registrations.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [RegistrationsController],
|
||||
providers: [RegistrationsService],
|
||||
exports: [RegistrationsService],
|
||||
})
|
||||
export class RegistrationsModule {}
|
||||
|
||||
360
backend/src/contests/registrations/registrations.service.ts
Normal file
360
backend/src/contests/registrations/registrations.service.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateRegistrationDto } from './dto/create-registration.dto';
|
||||
import { ReviewRegistrationDto } from './dto/review-registration.dto';
|
||||
import { QueryRegistrationDto } from './dto/query-registration.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(
|
||||
createRegistrationDto: CreateRegistrationDto,
|
||||
tenantId: number,
|
||||
creatorId?: number,
|
||||
) {
|
||||
// 验证比赛是否存在
|
||||
const contest = await this.prisma.contest.findUnique({
|
||||
where: { id: createRegistrationDto.contestId },
|
||||
});
|
||||
|
||||
if (!contest) {
|
||||
throw new NotFoundException('比赛不存在');
|
||||
}
|
||||
|
||||
// 检查比赛是否已发布
|
||||
if (contest.contestState !== 'published') {
|
||||
throw new BadRequestException('比赛未发布,无法报名');
|
||||
}
|
||||
|
||||
// 检查报名时间
|
||||
const now = new Date();
|
||||
if (now < contest.registerStartTime || now > contest.registerEndTime) {
|
||||
throw new BadRequestException('不在报名时间范围内');
|
||||
}
|
||||
|
||||
// 验证用户是否存在
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: createRegistrationDto.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 检查用户是否已报名(个人赛)
|
||||
if (createRegistrationDto.registrationType === 'individual') {
|
||||
const existing = await this.prisma.contestRegistration.findFirst({
|
||||
where: {
|
||||
contestId: createRegistrationDto.contestId,
|
||||
userId: createRegistrationDto.userId,
|
||||
registrationType: 'individual',
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('您已报名该比赛');
|
||||
}
|
||||
}
|
||||
|
||||
// 团队赛验证
|
||||
if (createRegistrationDto.registrationType === 'team') {
|
||||
if (!createRegistrationDto.teamId) {
|
||||
throw new BadRequestException('团队赛必须指定团队ID');
|
||||
}
|
||||
|
||||
const team = await this.prisma.contestTeam.findUnique({
|
||||
where: { id: createRegistrationDto.teamId },
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException('团队不存在');
|
||||
}
|
||||
|
||||
if (team.tenantId !== tenantId) {
|
||||
throw new BadRequestException('团队不属于当前租户');
|
||||
}
|
||||
|
||||
if (team.contestId !== createRegistrationDto.contestId) {
|
||||
throw new BadRequestException('团队不属于该比赛');
|
||||
}
|
||||
|
||||
// 检查用户是否是团队成员
|
||||
const isMember = team.members.some(
|
||||
(m) => m.userId === createRegistrationDto.userId,
|
||||
);
|
||||
|
||||
if (!isMember) {
|
||||
throw new BadRequestException('您不是该团队成员');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户账号信息快照
|
||||
const accountNo = user.username;
|
||||
const accountName = user.nickname;
|
||||
|
||||
const data: any = {
|
||||
contestId: createRegistrationDto.contestId,
|
||||
tenantId,
|
||||
registrationType: createRegistrationDto.registrationType,
|
||||
teamId: createRegistrationDto.teamId || null,
|
||||
teamName:
|
||||
createRegistrationDto.registrationType === 'team'
|
||||
? (await this.prisma.contestTeam.findUnique({
|
||||
where: { id: createRegistrationDto.teamId },
|
||||
}))?.teamName
|
||||
: null,
|
||||
userId: createRegistrationDto.userId,
|
||||
accountNo,
|
||||
accountName,
|
||||
registrationState: 'pending',
|
||||
registrationTime: new Date(),
|
||||
registrant: creatorId || createRegistrationDto.userId,
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
return this.prisma.contestRegistration.create({
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
contestType: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
team: createRegistrationDto.registrationType === 'team'
|
||||
? {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(queryDto: QueryRegistrationDto, tenantId?: number) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
contestId,
|
||||
registrationState,
|
||||
registrationType,
|
||||
userId,
|
||||
} = queryDto;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (contestId) {
|
||||
where.contestId = contestId;
|
||||
}
|
||||
|
||||
if (registrationState) {
|
||||
where.registrationState = registrationState;
|
||||
}
|
||||
|
||||
if (registrationType) {
|
||||
where.registrationType = registrationType;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.contestRegistration.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {
|
||||
registrationTime: 'desc',
|
||||
},
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
contestType: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
works: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.contestRegistration.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const registration = await this.prisma.contestRegistration.findFirst({
|
||||
where,
|
||||
include: {
|
||||
contest: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
works: {
|
||||
where: { validState: 1 },
|
||||
orderBy: {
|
||||
submitTime: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new NotFoundException('报名记录不存在');
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
async review(
|
||||
id: number,
|
||||
reviewDto: ReviewRegistrationDto,
|
||||
operatorId: number,
|
||||
tenantId?: number,
|
||||
) {
|
||||
const registration = await this.findOne(id, tenantId);
|
||||
|
||||
if (registration.registrationState !== 'pending') {
|
||||
throw new BadRequestException('只能审核待审核状态的报名');
|
||||
}
|
||||
|
||||
return this.prisma.contestRegistration.update({
|
||||
where: { id },
|
||||
data: {
|
||||
registrationState: reviewDto.registrationState,
|
||||
reason: reviewDto.reason,
|
||||
operator: operatorId,
|
||||
operationDate: new Date(),
|
||||
modifier: operatorId,
|
||||
},
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
// 检查是否有作品
|
||||
const workCount = await this.prisma.contestWork.count({
|
||||
where: { registrationId: id },
|
||||
});
|
||||
|
||||
if (workCount > 0) {
|
||||
throw new BadRequestException('该报名已有作品,无法删除');
|
||||
}
|
||||
|
||||
return this.prisma.contestRegistration.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { IsString, IsInt, IsEnum, IsOptional, IsObject } from 'class-validator';
|
||||
|
||||
export enum CalculationRule {
|
||||
AVERAGE = 'average',
|
||||
MAX = 'max',
|
||||
MIN = 'min',
|
||||
WEIGHTED = 'weighted',
|
||||
}
|
||||
|
||||
export class CreateReviewRuleDto {
|
||||
@IsInt()
|
||||
contestId: number;
|
||||
|
||||
@IsString()
|
||||
ruleName: string;
|
||||
|
||||
@IsObject()
|
||||
dimensions: any; // JSON object
|
||||
|
||||
@IsEnum(CalculationRule)
|
||||
@IsOptional()
|
||||
calculationRule?: CalculationRule;
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateReviewRuleDto } from './create-review-rule.dto';
|
||||
|
||||
export class UpdateReviewRuleDto extends PartialType(CreateReviewRuleDto) {}
|
||||
|
||||
54
backend/src/contests/review-rules/review-rules.controller.ts
Normal file
54
backend/src/contests/review-rules/review-rules.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ReviewRulesService } from './review-rules.service';
|
||||
import { CreateReviewRuleDto } from './dto/create-review-rule.dto';
|
||||
import { UpdateReviewRuleDto } from './dto/update-review-rule.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('contests/review-rules')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewRulesController {
|
||||
constructor(private readonly reviewRulesService: ReviewRulesService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('contest:update')
|
||||
create(@Body() createReviewRuleDto: CreateReviewRuleDto, @Request() req) {
|
||||
const creatorId = req.user?.id;
|
||||
return this.reviewRulesService.create(createReviewRuleDto, creatorId);
|
||||
}
|
||||
|
||||
@Get('contest/:contestId')
|
||||
@RequirePermission('contest:read')
|
||||
findOne(@Param('contestId', ParseIntPipe) contestId: number) {
|
||||
return this.reviewRulesService.findOne(contestId);
|
||||
}
|
||||
|
||||
@Patch('contest/:contestId')
|
||||
@RequirePermission('contest:update')
|
||||
update(
|
||||
@Param('contestId', ParseIntPipe) contestId: number,
|
||||
@Body() updateReviewRuleDto: UpdateReviewRuleDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const modifierId = req.user?.id;
|
||||
return this.reviewRulesService.update(contestId, updateReviewRuleDto, modifierId);
|
||||
}
|
||||
|
||||
@Delete('contest/:contestId')
|
||||
@RequirePermission('contest:update')
|
||||
remove(@Param('contestId', ParseIntPipe) contestId: number) {
|
||||
return this.reviewRulesService.remove(contestId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/review-rules/review-rules.module.ts
Normal file
13
backend/src/contests/review-rules/review-rules.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReviewRulesService } from './review-rules.service';
|
||||
import { ReviewRulesController } from './review-rules.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ReviewRulesController],
|
||||
providers: [ReviewRulesService],
|
||||
exports: [ReviewRulesService],
|
||||
})
|
||||
export class ReviewRulesModule {}
|
||||
|
||||
126
backend/src/contests/review-rules/review-rules.service.ts
Normal file
126
backend/src/contests/review-rules/review-rules.service.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateReviewRuleDto } from './dto/create-review-rule.dto';
|
||||
import { UpdateReviewRuleDto } from './dto/update-review-rule.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewRulesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createReviewRuleDto: CreateReviewRuleDto, creatorId?: number) {
|
||||
// 验证比赛是否存在
|
||||
const contest = await this.prisma.contest.findUnique({
|
||||
where: { id: createReviewRuleDto.contestId },
|
||||
});
|
||||
|
||||
if (!contest) {
|
||||
throw new NotFoundException('比赛不存在');
|
||||
}
|
||||
|
||||
// 检查是否已有评审规则
|
||||
const existing = await this.prisma.contestReviewRule.findUnique({
|
||||
where: { contestId: createReviewRuleDto.contestId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('该比赛已存在评审规则');
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
contestId: createReviewRuleDto.contestId,
|
||||
ruleName: createReviewRuleDto.ruleName,
|
||||
dimensions: JSON.stringify(createReviewRuleDto.dimensions),
|
||||
calculationRule: createReviewRuleDto.calculationRule || 'average',
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
return this.prisma.contestReviewRule.create({
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(contestId: number) {
|
||||
const rule = await this.prisma.contestReviewRule.findUnique({
|
||||
where: { contestId },
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
throw new NotFoundException('评审规则不存在');
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
async update(
|
||||
contestId: number,
|
||||
updateReviewRuleDto: UpdateReviewRuleDto,
|
||||
modifierId?: number,
|
||||
) {
|
||||
const rule = await this.findOne(contestId);
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (updateReviewRuleDto.ruleName !== undefined) {
|
||||
data.ruleName = updateReviewRuleDto.ruleName;
|
||||
}
|
||||
if (updateReviewRuleDto.dimensions !== undefined) {
|
||||
data.dimensions = JSON.stringify(updateReviewRuleDto.dimensions);
|
||||
}
|
||||
if (updateReviewRuleDto.calculationRule !== undefined) {
|
||||
data.calculationRule = updateReviewRuleDto.calculationRule;
|
||||
}
|
||||
|
||||
if (modifierId) {
|
||||
data.modifier = modifierId;
|
||||
}
|
||||
|
||||
return this.prisma.contestReviewRule.update({
|
||||
where: { contestId },
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(contestId: number) {
|
||||
await this.findOne(contestId);
|
||||
|
||||
// 软删除
|
||||
return this.prisma.contestReviewRule.update({
|
||||
where: { contestId },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
11
backend/src/contests/reviews/dto/assign-work.dto.ts
Normal file
11
backend/src/contests/reviews/dto/assign-work.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsInt, IsArray } from 'class-validator';
|
||||
|
||||
export class AssignWorkDto {
|
||||
@IsInt()
|
||||
workId: number;
|
||||
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
judgeIds: number[];
|
||||
}
|
||||
|
||||
22
backend/src/contests/reviews/dto/create-score.dto.ts
Normal file
22
backend/src/contests/reviews/dto/create-score.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { IsInt, IsObject, IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateScoreDto {
|
||||
@IsInt()
|
||||
workId: number;
|
||||
|
||||
@IsInt()
|
||||
assignmentId: number;
|
||||
|
||||
@IsObject()
|
||||
dimensionScores: any; // JSON object
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
totalScore: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comments?: string;
|
||||
}
|
||||
|
||||
79
backend/src/contests/reviews/reviews.controller.ts
Normal file
79
backend/src/contests/reviews/reviews.controller.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
import { AssignWorkDto } from './dto/assign-work.dto';
|
||||
import { CreateScoreDto } from './dto/create-score.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('contests/reviews')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewsController {
|
||||
constructor(private readonly reviewsService: ReviewsService) {}
|
||||
|
||||
@Post('assign')
|
||||
@RequirePermission('review:assign')
|
||||
assignWork(
|
||||
@Body() assignWorkDto: AssignWorkDto,
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const creatorId = req.user?.id;
|
||||
return this.reviewsService.assignWork(assignWorkDto, contestId, creatorId);
|
||||
}
|
||||
|
||||
@Post('score')
|
||||
@RequirePermission('review:score')
|
||||
score(@Body() createScoreDto: CreateScoreDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const judgeId = req.user?.id;
|
||||
return this.reviewsService.score(createScoreDto, judgeId, tenantId);
|
||||
}
|
||||
|
||||
@Patch('score/:id')
|
||||
@RequirePermission('review:score')
|
||||
updateScore(
|
||||
@Param('id', ParseIntPipe) scoreId: number,
|
||||
@Body() updateScoreDto: Partial<CreateScoreDto>,
|
||||
@Request() req,
|
||||
) {
|
||||
const judgeId = req.user?.id;
|
||||
return this.reviewsService.updateScore(scoreId, updateScoreDto, judgeId);
|
||||
}
|
||||
|
||||
@Get('assigned')
|
||||
@RequirePermission('review:read')
|
||||
getAssignedWorks(
|
||||
@Query('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const judgeId = req.user?.id;
|
||||
return this.reviewsService.getAssignedWorks(judgeId, contestId);
|
||||
}
|
||||
|
||||
@Get('work/:workId/scores')
|
||||
@RequirePermission('review:read')
|
||||
getWorkScores(@Param('workId', ParseIntPipe) workId: number) {
|
||||
return this.reviewsService.getWorkScores(workId);
|
||||
}
|
||||
|
||||
@Get('work/:workId/final-score')
|
||||
@RequirePermission('review:read')
|
||||
calculateFinalScore(@Param('workId', ParseIntPipe) workId: number) {
|
||||
return this.reviewsService.calculateFinalScore(workId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/reviews/reviews.module.ts
Normal file
13
backend/src/contests/reviews/reviews.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
import { ReviewsController } from './reviews.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ReviewsController],
|
||||
providers: [ReviewsService],
|
||||
exports: [ReviewsService],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
|
||||
416
backend/src/contests/reviews/reviews.service.ts
Normal file
416
backend/src/contests/reviews/reviews.service.ts
Normal file
@ -0,0 +1,416 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AssignWorkDto } from './dto/assign-work.dto';
|
||||
import { CreateScoreDto } from './dto/create-score.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async assignWork(
|
||||
assignWorkDto: AssignWorkDto,
|
||||
contestId: number,
|
||||
creatorId?: number,
|
||||
) {
|
||||
// 验证作品是否存在
|
||||
const work = await this.prisma.contestWork.findUnique({
|
||||
where: { id: assignWorkDto.workId },
|
||||
include: {
|
||||
contest: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!work) {
|
||||
throw new NotFoundException('作品不存在');
|
||||
}
|
||||
|
||||
if (work.contestId !== contestId) {
|
||||
throw new BadRequestException('作品不属于该比赛');
|
||||
}
|
||||
|
||||
// 检查评审时间
|
||||
const now = new Date();
|
||||
if (
|
||||
now < work.contest.reviewStartTime ||
|
||||
now > work.contest.reviewEndTime
|
||||
) {
|
||||
throw new BadRequestException('不在评审时间范围内');
|
||||
}
|
||||
|
||||
// 验证评委是否存在且是该比赛的评委
|
||||
const judges = await this.prisma.contestJudge.findMany({
|
||||
where: {
|
||||
contestId,
|
||||
judgeId: { in: assignWorkDto.judgeIds },
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (judges.length !== assignWorkDto.judgeIds.length) {
|
||||
throw new BadRequestException('部分评委不存在或不是该比赛的评委');
|
||||
}
|
||||
|
||||
// 创建分配记录
|
||||
const assignments = [];
|
||||
for (const judgeId of assignWorkDto.judgeIds) {
|
||||
// 检查是否已分配
|
||||
const existing = await this.prisma.contestWorkJudgeAssignment.findFirst({
|
||||
where: {
|
||||
workId: assignWorkDto.workId,
|
||||
judgeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
continue; // 跳过已分配的
|
||||
}
|
||||
|
||||
const assignment = await this.prisma.contestWorkJudgeAssignment.create({
|
||||
data: {
|
||||
contestId,
|
||||
workId: assignWorkDto.workId,
|
||||
judgeId,
|
||||
status: 'assigned',
|
||||
creator: creatorId,
|
||||
},
|
||||
include: {
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assignments.push(assignment);
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
async score(
|
||||
createScoreDto: CreateScoreDto,
|
||||
judgeId: number,
|
||||
tenantId: number,
|
||||
) {
|
||||
// 验证分配记录是否存在
|
||||
const assignment = await this.prisma.contestWorkJudgeAssignment.findFirst({
|
||||
where: {
|
||||
id: createScoreDto.assignmentId,
|
||||
judgeId,
|
||||
},
|
||||
include: {
|
||||
work: {
|
||||
include: {
|
||||
contest: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new NotFoundException('分配记录不存在');
|
||||
}
|
||||
|
||||
if (assignment.workId !== createScoreDto.workId) {
|
||||
throw new BadRequestException('作品ID不匹配');
|
||||
}
|
||||
|
||||
// 检查评审时间
|
||||
const now = new Date();
|
||||
if (
|
||||
now < assignment.work.contest.reviewStartTime ||
|
||||
now > assignment.work.contest.reviewEndTime
|
||||
) {
|
||||
throw new BadRequestException('不在评审时间范围内');
|
||||
}
|
||||
|
||||
// 检查是否已评分
|
||||
const existingScore = await this.prisma.contestWorkScore.findFirst({
|
||||
where: {
|
||||
assignmentId: createScoreDto.assignmentId,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingScore) {
|
||||
throw new ConflictException('该作品已评分,请使用更新接口');
|
||||
}
|
||||
|
||||
// 获取评委信息
|
||||
const judge = await this.prisma.user.findUnique({
|
||||
where: { id: judgeId },
|
||||
});
|
||||
|
||||
// 创建评分记录
|
||||
const data: any = {
|
||||
tenantId,
|
||||
contestId: assignment.work.contestId,
|
||||
workId: createScoreDto.workId,
|
||||
assignmentId: createScoreDto.assignmentId,
|
||||
judgeId,
|
||||
judgeName: judge?.nickname || judge?.username || '',
|
||||
dimensionScores: JSON.stringify(createScoreDto.dimensionScores),
|
||||
totalScore: createScoreDto.totalScore,
|
||||
comments: createScoreDto.comments,
|
||||
scoreTime: new Date(),
|
||||
creator: judgeId,
|
||||
};
|
||||
|
||||
const score = await this.prisma.contestWorkScore.create({
|
||||
data,
|
||||
include: {
|
||||
work: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
workNo: true,
|
||||
},
|
||||
},
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新分配状态
|
||||
await this.prisma.contestWorkJudgeAssignment.update({
|
||||
where: { id: createScoreDto.assignmentId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
|
||||
// 更新作品状态
|
||||
await this.prisma.contestWork.update({
|
||||
where: { id: createScoreDto.workId },
|
||||
data: {
|
||||
status: 'reviewing',
|
||||
},
|
||||
});
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async updateScore(
|
||||
scoreId: number,
|
||||
updateScoreDto: Partial<CreateScoreDto>,
|
||||
judgeId: number,
|
||||
) {
|
||||
const score = await this.prisma.contestWorkScore.findFirst({
|
||||
where: {
|
||||
id: scoreId,
|
||||
judgeId,
|
||||
validState: 1,
|
||||
},
|
||||
include: {
|
||||
work: {
|
||||
include: {
|
||||
contest: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!score) {
|
||||
throw new NotFoundException('评分记录不存在');
|
||||
}
|
||||
|
||||
// 检查评审时间
|
||||
const now = new Date();
|
||||
if (
|
||||
now < score.work.contest.reviewStartTime ||
|
||||
now > score.work.contest.reviewEndTime
|
||||
) {
|
||||
throw new BadRequestException('不在评审时间范围内');
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (updateScoreDto.dimensionScores !== undefined) {
|
||||
data.dimensionScores = JSON.stringify(updateScoreDto.dimensionScores);
|
||||
}
|
||||
if (updateScoreDto.totalScore !== undefined) {
|
||||
data.totalScore = updateScoreDto.totalScore;
|
||||
}
|
||||
if (updateScoreDto.comments !== undefined) {
|
||||
data.comments = updateScoreDto.comments;
|
||||
}
|
||||
|
||||
data.modifier = judgeId;
|
||||
data.scoreTime = new Date();
|
||||
|
||||
return this.prisma.contestWorkScore.update({
|
||||
where: { id: scoreId },
|
||||
data,
|
||||
include: {
|
||||
work: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
workNo: true,
|
||||
},
|
||||
},
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAssignedWorks(judgeId: number, contestId?: number) {
|
||||
const where: any = {
|
||||
judgeId,
|
||||
};
|
||||
|
||||
if (contestId) {
|
||||
where.contestId = contestId;
|
||||
}
|
||||
|
||||
return this.prisma.contestWorkJudgeAssignment.findMany({
|
||||
where,
|
||||
include: {
|
||||
work: {
|
||||
include: {
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
},
|
||||
},
|
||||
scores: {
|
||||
where: { validState: 1 },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
assignmentTime: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkScores(workId: number) {
|
||||
return this.prisma.contestWorkScore.findMany({
|
||||
where: {
|
||||
workId,
|
||||
validState: 1,
|
||||
},
|
||||
include: {
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
scoreTime: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async calculateFinalScore(workId: number) {
|
||||
const work = await this.prisma.contestWork.findUnique({
|
||||
where: { id: workId },
|
||||
include: {
|
||||
contest: {
|
||||
include: {
|
||||
reviewRule: true,
|
||||
},
|
||||
},
|
||||
scores: {
|
||||
where: { validState: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!work) {
|
||||
throw new NotFoundException('作品不存在');
|
||||
}
|
||||
|
||||
if (!work.contest.reviewRule) {
|
||||
throw new BadRequestException('比赛未配置评审规则');
|
||||
}
|
||||
|
||||
if (work.scores.length === 0) {
|
||||
return {
|
||||
finalScore: 0,
|
||||
scoreCount: 0,
|
||||
calculationRule: work.contest.reviewRule.calculationRule,
|
||||
};
|
||||
}
|
||||
|
||||
const scores = work.scores.map((s) => Number(s.totalScore));
|
||||
const calculationRule = work.contest.reviewRule.calculationRule;
|
||||
|
||||
let finalScore = 0;
|
||||
|
||||
switch (calculationRule) {
|
||||
case 'average':
|
||||
finalScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
break;
|
||||
case 'max':
|
||||
finalScore = Math.max(...scores);
|
||||
break;
|
||||
case 'min':
|
||||
finalScore = Math.min(...scores);
|
||||
break;
|
||||
case 'weighted':
|
||||
// 加权平均需要从评委权重计算
|
||||
const judges = await this.prisma.contestJudge.findMany({
|
||||
where: {
|
||||
contestId: work.contestId,
|
||||
judgeId: { in: work.scores.map((s) => s.judgeId) },
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const judgeWeights = new Map(
|
||||
judges.map((j) => [j.judgeId, Number(j.weight || 1)]),
|
||||
);
|
||||
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
work.scores.forEach((score) => {
|
||||
const weight = judgeWeights.get(score.judgeId) || 1;
|
||||
weightedSum += Number(score.totalScore) * weight;
|
||||
totalWeight += weight;
|
||||
});
|
||||
|
||||
finalScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
break;
|
||||
default:
|
||||
finalScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
}
|
||||
|
||||
return {
|
||||
finalScore: Number(finalScore.toFixed(2)),
|
||||
scoreCount: scores.length,
|
||||
calculationRule,
|
||||
};
|
||||
}
|
||||
}
|
||||
18
backend/src/contests/teams/dto/create-team.dto.ts
Normal file
18
backend/src/contests/teams/dto/create-team.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IsString, IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class CreateTeamDto {
|
||||
@IsInt()
|
||||
contestId: number;
|
||||
|
||||
@IsString()
|
||||
teamName: string;
|
||||
|
||||
@IsInt()
|
||||
leaderUserId: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
maxMembers?: number;
|
||||
}
|
||||
|
||||
17
backend/src/contests/teams/dto/invite-member.dto.ts
Normal file
17
backend/src/contests/teams/dto/invite-member.dto.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IsInt, IsString, IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
export enum TeamMemberRole {
|
||||
LEADER = 'leader',
|
||||
MEMBER = 'member',
|
||||
MENTOR = 'mentor',
|
||||
}
|
||||
|
||||
export class InviteMemberDto {
|
||||
@IsInt()
|
||||
userId: number;
|
||||
|
||||
@IsEnum(TeamMemberRole)
|
||||
@IsOptional()
|
||||
role?: TeamMemberRole;
|
||||
}
|
||||
|
||||
5
backend/src/contests/teams/dto/update-team.dto.ts
Normal file
5
backend/src/contests/teams/dto/update-team.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateTeamDto } from './create-team.dto';
|
||||
|
||||
export class UpdateTeamDto extends PartialType(CreateTeamDto) {}
|
||||
|
||||
112
backend/src/contests/teams/teams.controller.ts
Normal file
112
backend/src/contests/teams/teams.controller.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { TeamsService } from './teams.service';
|
||||
import { CreateTeamDto } from './dto/create-team.dto';
|
||||
import { UpdateTeamDto } from './dto/update-team.dto';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('contests/teams')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teamsService: TeamsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('team:create')
|
||||
create(@Body() createTeamDto: CreateTeamDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.teamsService.create(createTeamDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@Get('contest/:contestId')
|
||||
@RequirePermission('team:read')
|
||||
findAll(
|
||||
@Param('contestId', ParseIntPipe) contestId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.teamsService.findAll(contestId, tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('team:read')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.teamsService.findOne(id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('team:update')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateTeamDto: UpdateTeamDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const modifierId = req.user?.id;
|
||||
return this.teamsService.update(id, updateTeamDto, tenantId, modifierId);
|
||||
}
|
||||
|
||||
@Post(':id/members')
|
||||
@RequirePermission('team:update')
|
||||
inviteMember(
|
||||
@Param('id', ParseIntPipe) teamId: number,
|
||||
@Body() inviteMemberDto: InviteMemberDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.teamsService.inviteMember(
|
||||
teamId,
|
||||
inviteMemberDto,
|
||||
tenantId,
|
||||
creatorId,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/members/:userId')
|
||||
@RequirePermission('team:update')
|
||||
removeMember(
|
||||
@Param('id', ParseIntPipe) teamId: number,
|
||||
@Param('userId', ParseIntPipe) userId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.teamsService.removeMember(teamId, userId, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('team:delete')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.teamsService.remove(id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/teams/teams.module.ts
Normal file
13
backend/src/contests/teams/teams.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeamsService } from './teams.service';
|
||||
import { TeamsController } from './teams.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TeamsController],
|
||||
providers: [TeamsService],
|
||||
exports: [TeamsService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
|
||||
412
backend/src/contests/teams/teams.service.ts
Normal file
412
backend/src/contests/teams/teams.service.ts
Normal file
@ -0,0 +1,412 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateTeamDto } from './dto/create-team.dto';
|
||||
import { UpdateTeamDto } from './dto/update-team.dto';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(
|
||||
createTeamDto: CreateTeamDto,
|
||||
tenantId: number,
|
||||
creatorId?: number,
|
||||
) {
|
||||
// 验证比赛是否存在
|
||||
const contest = await this.prisma.contest.findUnique({
|
||||
where: { id: createTeamDto.contestId },
|
||||
});
|
||||
|
||||
if (!contest) {
|
||||
throw new NotFoundException('比赛不存在');
|
||||
}
|
||||
|
||||
// 检查比赛类型
|
||||
if (contest.contestType !== 'team') {
|
||||
throw new BadRequestException('该比赛不是团队赛');
|
||||
}
|
||||
|
||||
// 检查团队名称是否已存在(同一比赛、同一租户内)
|
||||
const existing = await this.prisma.contestTeam.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contestId: createTeamDto.contestId,
|
||||
teamName: createTeamDto.teamName,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('团队名称已存在');
|
||||
}
|
||||
|
||||
// 验证队长用户是否存在
|
||||
const leader = await this.prisma.user.findUnique({
|
||||
where: { id: createTeamDto.leaderUserId },
|
||||
});
|
||||
|
||||
if (!leader) {
|
||||
throw new NotFoundException('队长用户不存在');
|
||||
}
|
||||
|
||||
if (leader.tenantId !== tenantId) {
|
||||
throw new BadRequestException('队长不属于当前租户');
|
||||
}
|
||||
|
||||
// 创建团队
|
||||
const data: any = {
|
||||
tenantId,
|
||||
contestId: createTeamDto.contestId,
|
||||
teamName: createTeamDto.teamName,
|
||||
leaderUserId: createTeamDto.leaderUserId,
|
||||
maxMembers: createTeamDto.maxMembers,
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
const team = await this.prisma.contestTeam.create({
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
leader: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 自动添加队长为成员
|
||||
await this.prisma.contestTeamMember.create({
|
||||
data: {
|
||||
tenantId,
|
||||
teamId: team.id,
|
||||
userId: createTeamDto.leaderUserId,
|
||||
role: 'leader',
|
||||
creator: creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return this.findOne(team.id, tenantId);
|
||||
}
|
||||
|
||||
async findAll(contestId: number, tenantId?: number) {
|
||||
const where: any = {
|
||||
contestId,
|
||||
validState: 1,
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
return this.prisma.contestTeam.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createTime: 'desc',
|
||||
},
|
||||
include: {
|
||||
leader: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
registrations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = {
|
||||
id,
|
||||
validState: 1,
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const team = await this.prisma.contestTeam.findFirst({
|
||||
where,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
contestType: true,
|
||||
},
|
||||
},
|
||||
leader: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
registrations: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException('团队不存在');
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
updateTeamDto: UpdateTeamDto,
|
||||
tenantId: number,
|
||||
modifierId?: number,
|
||||
) {
|
||||
const team = await this.findOne(id, tenantId);
|
||||
|
||||
// 检查团队名称是否重复
|
||||
if (updateTeamDto.teamName && updateTeamDto.teamName !== team.teamName) {
|
||||
const existing = await this.prisma.contestTeam.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contestId: team.contestId,
|
||||
teamName: updateTeamDto.teamName,
|
||||
validState: 1,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('团队名称已存在');
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (updateTeamDto.teamName !== undefined) {
|
||||
data.teamName = updateTeamDto.teamName;
|
||||
}
|
||||
if (updateTeamDto.maxMembers !== undefined) {
|
||||
data.maxMembers = updateTeamDto.maxMembers;
|
||||
}
|
||||
|
||||
if (modifierId) {
|
||||
data.modifier = modifierId;
|
||||
}
|
||||
|
||||
return this.prisma.contestTeam.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
leader: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async inviteMember(
|
||||
teamId: number,
|
||||
inviteMemberDto: InviteMemberDto,
|
||||
tenantId: number,
|
||||
creatorId?: number,
|
||||
) {
|
||||
const team = await this.findOne(teamId, tenantId);
|
||||
|
||||
// 检查成员数量限制
|
||||
const memberCount = await this.prisma.contestTeamMember.count({
|
||||
where: { teamId },
|
||||
});
|
||||
|
||||
if (team.maxMembers && memberCount >= team.maxMembers) {
|
||||
throw new BadRequestException('团队人数已达上限');
|
||||
}
|
||||
|
||||
// 验证用户是否存在
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: inviteMemberDto.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
if (user.tenantId !== tenantId) {
|
||||
throw new BadRequestException('用户不属于当前租户');
|
||||
}
|
||||
|
||||
// 检查是否已是成员
|
||||
const existing = await this.prisma.contestTeamMember.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
teamId,
|
||||
userId: inviteMemberDto.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('用户已是团队成员');
|
||||
}
|
||||
|
||||
// 检查用户是否已加入其他团队(同一比赛)
|
||||
const otherTeam = await this.prisma.contestTeamMember.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
userId: inviteMemberDto.userId,
|
||||
team: {
|
||||
contestId: team.contestId,
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (otherTeam) {
|
||||
throw new ConflictException('用户已加入其他团队');
|
||||
}
|
||||
|
||||
return this.prisma.contestTeamMember.create({
|
||||
data: {
|
||||
tenantId,
|
||||
teamId,
|
||||
userId: inviteMemberDto.userId,
|
||||
role: inviteMemberDto.role || 'member',
|
||||
creator: creatorId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
teamName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(teamId: number, userId: number, tenantId: number) {
|
||||
const team = await this.findOne(teamId, tenantId);
|
||||
|
||||
// 不能移除队长
|
||||
if (team.leaderUserId === userId) {
|
||||
throw new BadRequestException('不能移除队长');
|
||||
}
|
||||
|
||||
const member = await this.prisma.contestTeamMember.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException('成员不存在');
|
||||
}
|
||||
|
||||
return this.prisma.contestTeamMember.delete({
|
||||
where: { id: member.id },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(teamId: number, tenantId: number) {
|
||||
const team = await this.findOne(teamId, tenantId);
|
||||
|
||||
// 检查是否有报名记录
|
||||
const registrationCount = await this.prisma.contestRegistration.count({
|
||||
where: { teamId },
|
||||
});
|
||||
|
||||
if (registrationCount > 0) {
|
||||
throw new BadRequestException('团队已有报名记录,无法删除');
|
||||
}
|
||||
|
||||
// 软删除
|
||||
return this.prisma.contestTeam.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
44
backend/src/contests/works/dto/query-work.dto.ts
Normal file
44
backend/src/contests/works/dto/query-work.dto.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum WorkStatus {
|
||||
SUBMITTED = 'submitted',
|
||||
LOCKED = 'locked',
|
||||
REVIEWING = 'reviewing',
|
||||
ACCEPTED = 'accepted',
|
||||
REJECTED = 'rejected',
|
||||
}
|
||||
|
||||
export class QueryWorkDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
page?: number = 1;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
pageSize?: number = 10;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
contestId?: number;
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
registrationId?: number;
|
||||
|
||||
@IsEnum(WorkStatus)
|
||||
@IsOptional()
|
||||
status?: WorkStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
27
backend/src/contests/works/dto/submit-work.dto.ts
Normal file
27
backend/src/contests/works/dto/submit-work.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { IsString, IsInt, IsOptional, IsObject, IsArray } from 'class-validator';
|
||||
|
||||
export class SubmitWorkDto {
|
||||
@IsInt()
|
||||
registrationId: number;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
files?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
previewUrl?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
aiModelMeta?: any;
|
||||
}
|
||||
|
||||
66
backend/src/contests/works/works.controller.ts
Normal file
66
backend/src/contests/works/works.controller.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { WorksService } from './works.service';
|
||||
import { SubmitWorkDto } from './dto/submit-work.dto';
|
||||
import { QueryWorkDto } from './dto/query-work.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@Controller('contests/works')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WorksController {
|
||||
constructor(private readonly worksService: WorksService) {}
|
||||
|
||||
@Post('submit')
|
||||
@RequirePermission('work:submit')
|
||||
submit(@Body() submitWorkDto: SubmitWorkDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const submitterUserId = req.user?.id;
|
||||
return this.worksService.submit(submitWorkDto, tenantId, submitterUserId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('work:read')
|
||||
findAll(@Query() queryDto: QueryWorkDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.worksService.findAll(queryDto, tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('work:read')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.worksService.findOne(id, tenantId);
|
||||
}
|
||||
|
||||
@Get('registration/:registrationId/versions')
|
||||
@RequirePermission('work:read')
|
||||
getVersions(
|
||||
@Param('registrationId', ParseIntPipe) registrationId: number,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.worksService.getVersions(registrationId, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('work:update')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.worksService.remove(id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/contests/works/works.module.ts
Normal file
13
backend/src/contests/works/works.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorksService } from './works.service';
|
||||
import { WorksController } from './works.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [WorksController],
|
||||
providers: [WorksService],
|
||||
exports: [WorksService],
|
||||
})
|
||||
export class WorksModule {}
|
||||
|
||||
359
backend/src/contests/works/works.service.ts
Normal file
359
backend/src/contests/works/works.service.ts
Normal file
@ -0,0 +1,359 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SubmitWorkDto } from './dto/submit-work.dto';
|
||||
import { QueryWorkDto } from './dto/query-work.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WorksService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async submit(
|
||||
submitWorkDto: SubmitWorkDto,
|
||||
tenantId: number,
|
||||
submitterUserId: number,
|
||||
) {
|
||||
// 验证报名记录是否存在
|
||||
const registration = await this.prisma.contestRegistration.findUnique({
|
||||
where: { id: submitWorkDto.registrationId },
|
||||
include: {
|
||||
contest: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new NotFoundException('报名记录不存在');
|
||||
}
|
||||
|
||||
if (registration.tenantId !== tenantId) {
|
||||
throw new BadRequestException('报名记录不属于当前租户');
|
||||
}
|
||||
|
||||
// 检查报名状态
|
||||
if (registration.registrationState !== 'passed') {
|
||||
throw new BadRequestException('报名未通过审核,无法提交作品');
|
||||
}
|
||||
|
||||
// 检查提交时间
|
||||
const now = new Date();
|
||||
if (
|
||||
now < registration.contest.submitStartTime ||
|
||||
now > registration.contest.submitEndTime
|
||||
) {
|
||||
throw new BadRequestException('不在作品提交时间范围内');
|
||||
}
|
||||
|
||||
// 检查提交规则
|
||||
const existingWorks = await this.prisma.contestWork.findMany({
|
||||
where: {
|
||||
registrationId: submitWorkDto.registrationId,
|
||||
validState: 1,
|
||||
},
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
registration.contest.submitRule === 'once' &&
|
||||
existingWorks.length > 0
|
||||
) {
|
||||
throw new ConflictException('该比赛只允许提交一次作品');
|
||||
}
|
||||
|
||||
// 如果已有作品,将旧版本标记为非最新
|
||||
if (existingWorks.length > 0) {
|
||||
await this.prisma.contestWork.updateMany({
|
||||
where: {
|
||||
registrationId: submitWorkDto.registrationId,
|
||||
validState: 1,
|
||||
},
|
||||
data: {
|
||||
isLatest: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 生成作品编号
|
||||
const workNo = await this.generateWorkNo(registration.contestId, tenantId);
|
||||
|
||||
// 获取提交人信息
|
||||
const submitter = await this.prisma.user.findUnique({
|
||||
where: { id: submitterUserId },
|
||||
});
|
||||
|
||||
// 创建新版本作品
|
||||
const data: any = {
|
||||
tenantId,
|
||||
contestId: registration.contestId,
|
||||
registrationId: submitWorkDto.registrationId,
|
||||
workNo,
|
||||
title: submitWorkDto.title,
|
||||
description: submitWorkDto.description,
|
||||
files: submitWorkDto.files ? JSON.stringify(submitWorkDto.files) : null,
|
||||
version: existingWorks.length > 0 ? existingWorks[0].version + 1 : 1,
|
||||
isLatest: true,
|
||||
status: 'submitted',
|
||||
submitTime: new Date(),
|
||||
submitterUserId,
|
||||
submitterAccountNo: submitter?.username,
|
||||
submitSource: 'student', // 可以根据实际情况判断
|
||||
previewUrl: submitWorkDto.previewUrl,
|
||||
aiModelMeta: submitWorkDto.aiModelMeta
|
||||
? JSON.stringify(submitWorkDto.aiModelMeta)
|
||||
: null,
|
||||
creator: submitterUserId,
|
||||
};
|
||||
|
||||
return this.prisma.contestWork.create({
|
||||
data,
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成作品编号
|
||||
*/
|
||||
private async generateWorkNo(
|
||||
contestId: number,
|
||||
tenantId: number,
|
||||
): Promise<string> {
|
||||
const prefix = `WORK-${contestId}-${tenantId}-`;
|
||||
const count = await this.prisma.contestWork.count({
|
||||
where: {
|
||||
contestId,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
return `${prefix}${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
async findAll(queryDto: QueryWorkDto, tenantId?: number) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
contestId,
|
||||
registrationId,
|
||||
status,
|
||||
title,
|
||||
} = queryDto;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {
|
||||
validState: 1,
|
||||
isLatest: true, // 默认只查询最新版本
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (contestId) {
|
||||
where.contestId = contestId;
|
||||
}
|
||||
|
||||
if (registrationId) {
|
||||
where.registrationId = registrationId;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (title) {
|
||||
where.title = {
|
||||
contains: title,
|
||||
};
|
||||
}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.contestWork.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {
|
||||
submitTime: 'desc',
|
||||
},
|
||||
include: {
|
||||
contest: {
|
||||
select: {
|
||||
id: true,
|
||||
contestName: true,
|
||||
},
|
||||
},
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
_count: {
|
||||
select: {
|
||||
scores: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.contestWork.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = {
|
||||
id,
|
||||
validState: 1,
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const work = await this.prisma.contestWork.findFirst({
|
||||
where,
|
||||
include: {
|
||||
contest: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: true,
|
||||
assignments: {
|
||||
include: {
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scores: {
|
||||
where: { validState: 1 },
|
||||
include: {
|
||||
judge: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!work) {
|
||||
throw new NotFoundException('作品不存在');
|
||||
}
|
||||
|
||||
return work;
|
||||
}
|
||||
|
||||
async getVersions(registrationId: number, tenantId?: number) {
|
||||
const where: any = {
|
||||
registrationId,
|
||||
validState: 1,
|
||||
};
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
return this.prisma.contestWork.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
include: {
|
||||
attachments: true,
|
||||
_count: {
|
||||
select: {
|
||||
scores: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
await this.findOne(id, tenantId);
|
||||
|
||||
// 检查是否已有评分
|
||||
const scoreCount = await this.prisma.contestWorkScore.count({
|
||||
where: { workId: id },
|
||||
});
|
||||
|
||||
if (scoreCount > 0) {
|
||||
throw new BadRequestException('作品已有评分,无法删除');
|
||||
}
|
||||
|
||||
// 软删除
|
||||
return this.prisma.contestWork.update({
|
||||
where: { id },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
74
backend/src/school/classes/classes.controller.ts
Normal file
74
backend/src/school/classes/classes.controller.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { CreateClassDto } from './dto/create-class.dto';
|
||||
import { UpdateClassDto } from './dto/update-class.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('classes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ClassesController {
|
||||
constructor(private readonly classesService: ClassesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createClassDto: CreateClassDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.classesService.create(createClassDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('gradeId') gradeId?: string,
|
||||
@Query('type') type?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.classesService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
gradeId ? parseInt(gradeId) : undefined,
|
||||
type ? parseInt(type) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.classesService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateClassDto: UpdateClassDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const modifierId = req.user?.id;
|
||||
return this.classesService.update(+id, updateClassDto, tenantId, modifierId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.classesService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/school/classes/classes.module.ts
Normal file
13
backend/src/school/classes/classes.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassesController } from './classes.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ClassesController],
|
||||
providers: [ClassesService],
|
||||
exports: [ClassesService],
|
||||
})
|
||||
export class ClassesModule {}
|
||||
|
||||
282
backend/src/school/classes/classes.service.ts
Normal file
282
backend/src/school/classes/classes.service.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateClassDto } from './dto/create-class.dto';
|
||||
import { UpdateClassDto } from './dto/update-class.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClassesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createClassDto: CreateClassDto, tenantId: number, creatorId?: number) {
|
||||
// 验证年级是否存在且属于该租户
|
||||
const grade = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
id: createClassDto.gradeId,
|
||||
tenantId,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!grade) {
|
||||
throw new NotFoundException('年级不存在或不属于该租户');
|
||||
}
|
||||
|
||||
// 检查班级编码是否已存在
|
||||
const existingByCode = await this.prisma.class.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
code: createClassDto.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('班级编码已存在');
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
...createClassDto,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
return this.prisma.class.create({
|
||||
data,
|
||||
include: {
|
||||
grade: true,
|
||||
_count: {
|
||||
select: {
|
||||
students: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
studentInterestClasses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
tenantId?: number,
|
||||
gradeId?: number,
|
||||
type?: number,
|
||||
) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = { validState: 1 };
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (gradeId) {
|
||||
where.gradeId = gradeId;
|
||||
}
|
||||
|
||||
if (type !== undefined) {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.class.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: [
|
||||
{ grade: { level: 'asc' } },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
include: {
|
||||
grade: true,
|
||||
_count: {
|
||||
select: {
|
||||
students: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
studentInterestClasses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.class.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id, validState: 1 };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const classEntity = await this.prisma.class.findFirst({
|
||||
where,
|
||||
include: {
|
||||
grade: true,
|
||||
students: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
studentInterestClasses: {
|
||||
include: {
|
||||
student: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!classEntity) {
|
||||
throw new NotFoundException('班级不存在');
|
||||
}
|
||||
|
||||
return classEntity;
|
||||
}
|
||||
|
||||
async update(id: number, updateClassDto: UpdateClassDto, tenantId?: number, modifierId?: number) {
|
||||
// 验证班级是否存在
|
||||
const existingClass = await this.findOne(id, tenantId);
|
||||
|
||||
const data: any = {};
|
||||
|
||||
// 如果更新年级,验证年级是否存在
|
||||
if (updateClassDto.gradeId && updateClassDto.gradeId !== existingClass.gradeId) {
|
||||
const grade = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
id: updateClassDto.gradeId,
|
||||
tenantId: tenantId || existingClass.tenantId,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!grade) {
|
||||
throw new NotFoundException('年级不存在或不属于该租户');
|
||||
}
|
||||
data.gradeId = updateClassDto.gradeId;
|
||||
}
|
||||
|
||||
// 如果更新编码,检查是否冲突
|
||||
if (updateClassDto.code && updateClassDto.code !== existingClass.code) {
|
||||
const existingByCode = await this.prisma.class.findFirst({
|
||||
where: {
|
||||
tenantId: tenantId || existingClass.tenantId,
|
||||
code: updateClassDto.code,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('班级编码已存在');
|
||||
}
|
||||
data.code = updateClassDto.code;
|
||||
}
|
||||
|
||||
if (updateClassDto.name) {
|
||||
data.name = updateClassDto.name;
|
||||
}
|
||||
|
||||
if (updateClassDto.type !== undefined) {
|
||||
// 如果从行政班级改为兴趣班,需要检查是否有学生
|
||||
if (existingClass.type === 1 && updateClassDto.type === 2) {
|
||||
const studentCount = await this.prisma.student.count({
|
||||
where: {
|
||||
classId: id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (studentCount > 0) {
|
||||
throw new BadRequestException('无法将包含学生的行政班级改为兴趣班');
|
||||
}
|
||||
}
|
||||
data.type = updateClassDto.type;
|
||||
}
|
||||
|
||||
if (updateClassDto.capacity !== undefined) {
|
||||
data.capacity = updateClassDto.capacity;
|
||||
}
|
||||
|
||||
if (updateClassDto.description !== undefined) {
|
||||
data.description = updateClassDto.description;
|
||||
}
|
||||
|
||||
if (modifierId) {
|
||||
data.modifier = modifierId;
|
||||
}
|
||||
|
||||
return this.prisma.class.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
grade: true,
|
||||
_count: {
|
||||
select: {
|
||||
students: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
studentInterestClasses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证班级是否存在
|
||||
const classEntity = await this.findOne(id, tenantId);
|
||||
|
||||
// 如果是行政班级,检查是否有学生
|
||||
if (classEntity.type === 1) {
|
||||
const studentCount = await this.prisma.student.count({
|
||||
where: {
|
||||
classId: id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (studentCount > 0) {
|
||||
throw new BadRequestException('删除班级前需先转移或删除所有学生');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是兴趣班,删除关联关系
|
||||
if (classEntity.type === 2) {
|
||||
await this.prisma.studentInterestClass.deleteMany({
|
||||
where: {
|
||||
classId: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 软删除
|
||||
return this.prisma.class.update({
|
||||
where: { id },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
32
backend/src/school/classes/dto/create-class.dto.ts
Normal file
32
backend/src/school/classes/dto/create-class.dto.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateClassDto {
|
||||
@IsInt()
|
||||
gradeId: number;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsInt()
|
||||
@IsIn([1, 2])
|
||||
type: number; // 1-行政班级,2-兴趣班
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
capacity?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
36
backend/src/school/classes/dto/update-class.dto.ts
Normal file
36
backend/src/school/classes/dto/update-class.dto.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateClassDto {
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
gradeId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsIn([1, 2])
|
||||
@IsOptional()
|
||||
type?: number; // 1-行政班级,2-兴趣班
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
capacity?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
78
backend/src/school/departments/departments.controller.ts
Normal file
78
backend/src/school/departments/departments.controller.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { CreateDepartmentDto } from './dto/create-department.dto';
|
||||
import { UpdateDepartmentDto } from './dto/update-department.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('departments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DepartmentsController {
|
||||
constructor(private readonly departmentsService: DepartmentsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createDepartmentDto: CreateDepartmentDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.departmentsService.create(createDepartmentDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('parentId') parentId?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.departmentsService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
parentId ? parseInt(parentId) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
findTree(@Request() req?: any) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.departmentsService.findTree(tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.departmentsService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDepartmentDto: UpdateDepartmentDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const modifierId = req.user?.id;
|
||||
return this.departmentsService.update(+id, updateDepartmentDto, tenantId, modifierId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.departmentsService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/school/departments/departments.module.ts
Normal file
13
backend/src/school/departments/departments.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { DepartmentsController } from './departments.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [DepartmentsController],
|
||||
providers: [DepartmentsService],
|
||||
exports: [DepartmentsService],
|
||||
})
|
||||
export class DepartmentsModule {}
|
||||
|
||||
361
backend/src/school/departments/departments.service.ts
Normal file
361
backend/src/school/departments/departments.service.ts
Normal file
@ -0,0 +1,361 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateDepartmentDto } from './dto/create-department.dto';
|
||||
import { UpdateDepartmentDto } from './dto/update-department.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DepartmentsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createDepartmentDto: CreateDepartmentDto, tenantId: number, creatorId?: number) {
|
||||
// 检查部门编码是否已存在
|
||||
const existingByCode = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
code: createDepartmentDto.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('部门编码已存在');
|
||||
}
|
||||
|
||||
// 如果指定了父部门,验证父部门是否存在且属于该租户
|
||||
if (createDepartmentDto.parentId) {
|
||||
const parent = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
id: createDepartmentDto.parentId,
|
||||
tenantId,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new NotFoundException('父部门不存在或不属于该租户');
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
...createDepartmentDto,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
return this.prisma.department.create({
|
||||
data,
|
||||
include: {
|
||||
parent: true,
|
||||
_count: {
|
||||
select: {
|
||||
teachers: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number, parentId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = { validState: 1 };
|
||||
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (parentId !== undefined) {
|
||||
where.parentId = parentId;
|
||||
}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.department.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: [
|
||||
{ sort: 'desc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
include: {
|
||||
parent: true,
|
||||
_count: {
|
||||
select: {
|
||||
teachers: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.department.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findTree(tenantId?: number) {
|
||||
const where: any = { validState: 1 };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const departments = await this.prisma.department.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ sort: 'desc' },
|
||||
{ name: 'asc' },
|
||||
],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
teachers: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (items: any[], parentId: number | null = null): any[] => {
|
||||
return items
|
||||
.filter(item => item.parentId === parentId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: buildTree(items, item.id),
|
||||
}));
|
||||
};
|
||||
|
||||
return buildTree(departments);
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id, validState: 1 };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const department = await this.prisma.department.findFirst({
|
||||
where,
|
||||
include: {
|
||||
parent: true,
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
teachers: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!department) {
|
||||
throw new NotFoundException('部门不存在');
|
||||
}
|
||||
|
||||
return department;
|
||||
}
|
||||
|
||||
async update(id: number, updateDepartmentDto: UpdateDepartmentDto, tenantId?: number, modifierId?: number) {
|
||||
// 验证部门是否存在
|
||||
const existingDepartment = await this.findOne(id, tenantId);
|
||||
|
||||
const data: any = {};
|
||||
|
||||
// 如果更新编码,检查是否冲突
|
||||
if (updateDepartmentDto.code && updateDepartmentDto.code !== existingDepartment.code) {
|
||||
const existingByCode = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
tenantId: tenantId || existingDepartment.tenantId,
|
||||
code: updateDepartmentDto.code,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('部门编码已存在');
|
||||
}
|
||||
data.code = updateDepartmentDto.code;
|
||||
}
|
||||
|
||||
// 如果更新父部门,验证父部门是否存在且不会造成循环引用
|
||||
if (updateDepartmentDto.parentId !== undefined) {
|
||||
if (updateDepartmentDto.parentId === id) {
|
||||
throw new BadRequestException('不能将部门设置为自己的父部门');
|
||||
}
|
||||
|
||||
if (updateDepartmentDto.parentId !== null) {
|
||||
const parent = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
id: updateDepartmentDto.parentId,
|
||||
tenantId: tenantId || existingDepartment.tenantId,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new NotFoundException('父部门不存在或不属于该租户');
|
||||
}
|
||||
|
||||
// 检查是否会造成循环引用(父部门不能是当前部门的子部门)
|
||||
const isDescendant = await this.checkIsDescendant(
|
||||
updateDepartmentDto.parentId,
|
||||
id,
|
||||
tenantId || existingDepartment.tenantId,
|
||||
);
|
||||
|
||||
if (isDescendant) {
|
||||
throw new BadRequestException('不能将部门设置为其子部门的父部门');
|
||||
}
|
||||
}
|
||||
data.parentId = updateDepartmentDto.parentId;
|
||||
}
|
||||
|
||||
if (updateDepartmentDto.name) {
|
||||
data.name = updateDepartmentDto.name;
|
||||
}
|
||||
|
||||
if (updateDepartmentDto.description !== undefined) {
|
||||
data.description = updateDepartmentDto.description;
|
||||
}
|
||||
|
||||
if (updateDepartmentDto.sort !== undefined) {
|
||||
data.sort = updateDepartmentDto.sort;
|
||||
}
|
||||
|
||||
if (modifierId) {
|
||||
data.modifier = modifierId;
|
||||
}
|
||||
|
||||
return this.prisma.department.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
parent: true,
|
||||
_count: {
|
||||
select: {
|
||||
teachers: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证部门是否存在
|
||||
const department = await this.findOne(id, tenantId);
|
||||
|
||||
// 检查是否有子部门
|
||||
const childrenCount = await this.prisma.department.count({
|
||||
where: {
|
||||
parentId: id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (childrenCount > 0) {
|
||||
throw new BadRequestException('删除部门前需先删除或转移所有子部门');
|
||||
}
|
||||
|
||||
// 检查是否有教师
|
||||
const teacherCount = await this.prisma.teacher.count({
|
||||
where: {
|
||||
departmentId: id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (teacherCount > 0) {
|
||||
throw new BadRequestException('删除部门前需先转移或删除该部门下的所有教师');
|
||||
}
|
||||
|
||||
// 软删除
|
||||
return this.prisma.department.update({
|
||||
where: { id },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async checkIsDescendant(parentId: number, childId: number, tenantId: number): Promise<boolean> {
|
||||
const parent = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
tenantId,
|
||||
validState: 1,
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查直接子部门
|
||||
if (parent.children.some(child => child.id === childId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 递归检查所有子部门
|
||||
for (const child of parent.children) {
|
||||
if (await this.checkIsDescendant(child.id, childId, tenantId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
28
backend/src/school/departments/dto/create-department.dto.ts
Normal file
28
backend/src/school/departments/dto/create-department.dto.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateDepartmentDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
parentId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
30
backend/src/school/departments/dto/update-department.dto.ts
Normal file
30
backend/src/school/departments/dto/update-department.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateDepartmentDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
parentId?: number | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
23
backend/src/school/grades/dto/create-grade.dto.ts
Normal file
23
backend/src/school/grades/dto/create-grade.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateGradeDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
level: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
26
backend/src/school/grades/dto/update-grade.dto.ts
Normal file
26
backend/src/school/grades/dto/update-grade.dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateGradeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
level?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
70
backend/src/school/grades/grades.controller.ts
Normal file
70
backend/src/school/grades/grades.controller.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { GradesService } from './grades.service';
|
||||
import { CreateGradeDto } from './dto/create-grade.dto';
|
||||
import { UpdateGradeDto } from './dto/update-grade.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('grades')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GradesController {
|
||||
constructor(private readonly gradesService: GradesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createGradeDto: CreateGradeDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.gradesService.create(createGradeDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
||||
return this.gradesService.findAll(
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 10,
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.gradesService.findOne(+id, tenantId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateGradeDto: UpdateGradeDto,
|
||||
@Request() req,
|
||||
) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
const modifierId = req.user?.id;
|
||||
return this.gradesService.update(+id, updateGradeDto, tenantId, modifierId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
return this.gradesService.remove(+id, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/school/grades/grades.module.ts
Normal file
13
backend/src/school/grades/grades.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GradesService } from './grades.service';
|
||||
import { GradesController } from './grades.controller';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [GradesController],
|
||||
providers: [GradesService],
|
||||
exports: [GradesService],
|
||||
})
|
||||
export class GradesModule {}
|
||||
|
||||
203
backend/src/school/grades/grades.service.ts
Normal file
203
backend/src/school/grades/grades.service.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateGradeDto } from './dto/create-grade.dto';
|
||||
import { UpdateGradeDto } from './dto/update-grade.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GradesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(createGradeDto: CreateGradeDto, tenantId: number, creatorId?: number) {
|
||||
// 检查年级编码是否已存在
|
||||
const existingByCode = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
code: createGradeDto.code,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('年级编码已存在');
|
||||
}
|
||||
|
||||
// 检查年级级别是否已存在
|
||||
const existingByLevel = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
level: createGradeDto.level,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByLevel) {
|
||||
throw new ConflictException('年级级别已存在');
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
...createGradeDto,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
if (creatorId) {
|
||||
data.creator = creatorId;
|
||||
}
|
||||
|
||||
return this.prisma.grade.create({
|
||||
data,
|
||||
include: {
|
||||
classes: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = tenantId ? { tenantId, validState: 1 } : { validState: 1 };
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.grade.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {
|
||||
level: 'asc',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
classes: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.grade.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id, validState: 1 };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const grade = await this.prisma.grade.findFirst({
|
||||
where,
|
||||
include: {
|
||||
classes: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!grade) {
|
||||
throw new NotFoundException('年级不存在');
|
||||
}
|
||||
|
||||
return grade;
|
||||
}
|
||||
|
||||
async update(id: number, updateGradeDto: UpdateGradeDto, tenantId?: number, modifierId?: number) {
|
||||
// 验证年级是否存在
|
||||
const existingGrade = await this.findOne(id, tenantId);
|
||||
|
||||
const data: any = {};
|
||||
|
||||
// 如果更新编码,检查是否冲突
|
||||
if (updateGradeDto.code && updateGradeDto.code !== existingGrade.code) {
|
||||
const existingByCode = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
tenantId: tenantId || existingGrade.tenantId,
|
||||
code: updateGradeDto.code,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByCode) {
|
||||
throw new ConflictException('年级编码已存在');
|
||||
}
|
||||
data.code = updateGradeDto.code;
|
||||
}
|
||||
|
||||
// 如果更新级别,检查是否冲突
|
||||
if (updateGradeDto.level && updateGradeDto.level !== existingGrade.level) {
|
||||
const existingByLevel = await this.prisma.grade.findFirst({
|
||||
where: {
|
||||
tenantId: tenantId || existingGrade.tenantId,
|
||||
level: updateGradeDto.level,
|
||||
id: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByLevel) {
|
||||
throw new ConflictException('年级级别已存在');
|
||||
}
|
||||
data.level = updateGradeDto.level;
|
||||
}
|
||||
|
||||
if (updateGradeDto.name) {
|
||||
data.name = updateGradeDto.name;
|
||||
}
|
||||
|
||||
if (updateGradeDto.description !== undefined) {
|
||||
data.description = updateGradeDto.description;
|
||||
}
|
||||
|
||||
if (modifierId) {
|
||||
data.modifier = modifierId;
|
||||
}
|
||||
|
||||
return this.prisma.grade.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
classes: {
|
||||
where: {
|
||||
validState: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, tenantId?: number) {
|
||||
// 验证年级是否存在
|
||||
const grade = await this.findOne(id, tenantId);
|
||||
|
||||
// 检查是否有班级关联
|
||||
const classCount = await this.prisma.class.count({
|
||||
where: {
|
||||
gradeId: id,
|
||||
validState: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (classCount > 0) {
|
||||
throw new BadRequestException('删除年级前需先删除所有班级');
|
||||
}
|
||||
|
||||
// 软删除
|
||||
return this.prisma.grade.update({
|
||||
where: { id },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
28
backend/src/school/school.module.ts
Normal file
28
backend/src/school/school.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchoolsModule } from './schools/schools.module';
|
||||
import { GradesModule } from './grades/grades.module';
|
||||
import { ClassesModule } from './classes/classes.module';
|
||||
import { DepartmentsModule } from './departments/departments.module';
|
||||
import { TeachersModule } from './teachers/teachers.module';
|
||||
import { StudentsModule } from './students/students.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SchoolsModule,
|
||||
GradesModule,
|
||||
ClassesModule,
|
||||
DepartmentsModule,
|
||||
TeachersModule,
|
||||
StudentsModule,
|
||||
],
|
||||
exports: [
|
||||
SchoolsModule,
|
||||
GradesModule,
|
||||
ClassesModule,
|
||||
DepartmentsModule,
|
||||
TeachersModule,
|
||||
StudentsModule,
|
||||
],
|
||||
})
|
||||
export class SchoolModule {}
|
||||
|
||||
37
backend/src/school/schools/dto/create-school.dto.ts
Normal file
37
backend/src/school/schools/dto/create-school.dto.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSchoolDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
principal?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
established?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logo?: string;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
website?: string;
|
||||
}
|
||||
|
||||
37
backend/src/school/schools/dto/update-school.dto.ts
Normal file
37
backend/src/school/schools/dto/update-school.dto.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateSchoolDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
principal?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
established?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logo?: string;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
website?: string;
|
||||
}
|
||||
|
||||
59
backend/src/school/schools/schools.controller.ts
Normal file
59
backend/src/school/schools/schools.controller.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { SchoolsService } from './schools.service';
|
||||
import { CreateSchoolDto } from './dto/create-school.dto';
|
||||
import { UpdateSchoolDto } from './dto/update-school.dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('schools')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SchoolsController {
|
||||
constructor(private readonly schoolsService: SchoolsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createSchoolDto: CreateSchoolDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const creatorId = req.user?.id;
|
||||
return this.schoolsService.create(createSchoolDto, tenantId, creatorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findOne(@Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.schoolsService.findOne(tenantId);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
update(@Body() updateSchoolDto: UpdateSchoolDto, @Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
const modifierId = req.user?.id;
|
||||
return this.schoolsService.update(tenantId, updateSchoolDto, modifierId);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
remove(@Request() req) {
|
||||
const tenantId = req.tenantId || req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new Error('无法确定租户信息');
|
||||
}
|
||||
return this.schoolsService.remove(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user