移除node后端
This commit is contained in:
parent
0b989b047a
commit
ab5bd36cec
@ -1,122 +0,0 @@
|
|||||||
---
|
|
||||||
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 # 入口文件
|
|
||||||
```
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
parserOptions: {
|
|
||||||
project: 'tsconfig.json',
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
|
||||||
extends: [
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
],
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ['.eslintrc.js'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
47
backend/.gitignore
vendored
47
backend/.gitignore
vendored
@ -1,47 +0,0 @@
|
|||||||
# compiled output
|
|
||||||
/dist
|
|
||||||
/node_modules
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
/logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
/coverage
|
|
||||||
/.nyc_output
|
|
||||||
|
|
||||||
# IDEs and editors
|
|
||||||
/.idea
|
|
||||||
.project
|
|
||||||
.classpath
|
|
||||||
.c9/
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# IDE - VSCode
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
.env.development
|
|
||||||
.env.production
|
|
||||||
.env.test
|
|
||||||
.env.staging
|
|
||||||
# 保留示例文件
|
|
||||||
!.env*.example
|
|
||||||
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
# 后端 pnpm 配置
|
|
||||||
shamefully-hoist=true
|
|
||||||
strict-peer-dependencies=false
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -1,372 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"name": "工作台",
|
|
||||||
"path": "/workbench",
|
|
||||||
"icon": "DashboardOutlined",
|
|
||||||
"component": "workbench/Index",
|
|
||||||
"sort": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "我的评审",
|
|
||||||
"path": "/activities",
|
|
||||||
"icon": "AuditOutlined",
|
|
||||||
"sort": 2,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "评审任务",
|
|
||||||
"path": "/activities/review",
|
|
||||||
"icon": "FileSearchOutlined",
|
|
||||||
"component": "activities/Review",
|
|
||||||
"sort": 1,
|
|
||||||
"permission": "review:score"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "预设评语",
|
|
||||||
"path": "/activities/preset-comments",
|
|
||||||
"icon": "MessageOutlined",
|
|
||||||
"component": "activities/PresetComments",
|
|
||||||
"sort": 2,
|
|
||||||
"permission": "review:score"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "活动监管",
|
|
||||||
"path": "/contests",
|
|
||||||
"icon": "FundViewOutlined",
|
|
||||||
"sort": 3,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "全部活动",
|
|
||||||
"path": "/contests/list",
|
|
||||||
"icon": "UnorderedListOutlined",
|
|
||||||
"component": "contests/Index",
|
|
||||||
"sort": 1,
|
|
||||||
"permission": "contest:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "报名数据",
|
|
||||||
"path": "/contests/registrations",
|
|
||||||
"icon": "UserAddOutlined",
|
|
||||||
"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/review-progress",
|
|
||||||
"icon": "DashboardOutlined",
|
|
||||||
"component": "contests/reviews/Progress",
|
|
||||||
"sort": 4,
|
|
||||||
"permission": "review:progress:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "评委管理",
|
|
||||||
"path": "/contests/judges",
|
|
||||||
"icon": "SolutionOutlined",
|
|
||||||
"component": "contests/judges/Index",
|
|
||||||
"sort": 5,
|
|
||||||
"permission": "judge:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "评审规则",
|
|
||||||
"path": "/contests/review-rules",
|
|
||||||
"icon": "CheckCircleOutlined",
|
|
||||||
"component": "contests/reviews/Index",
|
|
||||||
"sort": 6,
|
|
||||||
"permission": "review:rule:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "活动成果",
|
|
||||||
"path": "/contests/results",
|
|
||||||
"icon": "TrophyOutlined",
|
|
||||||
"component": "contests/results/Index",
|
|
||||||
"sort": 7,
|
|
||||||
"permission": "result:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "通知管理",
|
|
||||||
"path": "/contests/notices",
|
|
||||||
"icon": "BellOutlined",
|
|
||||||
"component": "contests/notices/Index",
|
|
||||||
"sort": 8,
|
|
||||||
"permission": "contest:notice:read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "内容管理",
|
|
||||||
"path": "/content",
|
|
||||||
"icon": "PictureOutlined",
|
|
||||||
"sort": 4,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "作品审核",
|
|
||||||
"path": "/content/review",
|
|
||||||
"component": "content/WorkReview",
|
|
||||||
"sort": 1,
|
|
||||||
"permission": "content:review"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "作品管理",
|
|
||||||
"path": "/content/management",
|
|
||||||
"component": "content/WorkManagement",
|
|
||||||
"sort": 2,
|
|
||||||
"permission": "content:manage"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "标签管理",
|
|
||||||
"path": "/content/tags",
|
|
||||||
"component": "content/TagManagement",
|
|
||||||
"sort": 3,
|
|
||||||
"permission": "content:tags"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "学校管理",
|
|
||||||
"path": "/school",
|
|
||||||
"icon": "BankOutlined",
|
|
||||||
"sort": 5,
|
|
||||||
"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",
|
|
||||||
"sort": 6,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "活动列表",
|
|
||||||
"path": "/contests/list",
|
|
||||||
"icon": "UnorderedListOutlined",
|
|
||||||
"component": "contests/Index",
|
|
||||||
"sort": 1,
|
|
||||||
"permission": "contest:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "评委管理",
|
|
||||||
"path": "/contests/judges",
|
|
||||||
"icon": "UserSwitchOutlined",
|
|
||||||
"component": "contests/judges/Index",
|
|
||||||
"sort": 2,
|
|
||||||
"permission": "judge:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "报名管理",
|
|
||||||
"path": "/contests/registrations",
|
|
||||||
"icon": "FormOutlined",
|
|
||||||
"component": "contests/registrations/Index",
|
|
||||||
"sort": 3,
|
|
||||||
"permission": "contest:registration:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "作品管理",
|
|
||||||
"path": "/contests/works",
|
|
||||||
"icon": "FileTextOutlined",
|
|
||||||
"component": "contests/works/Index",
|
|
||||||
"sort": 4,
|
|
||||||
"permission": "contest:work:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "评审进度",
|
|
||||||
"path": "/contests/review-progress",
|
|
||||||
"icon": "DashboardOutlined",
|
|
||||||
"component": "contests/reviews/Progress",
|
|
||||||
"sort": 5,
|
|
||||||
"permission": "review:progress:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "评审规则",
|
|
||||||
"path": "/contests/review-rules",
|
|
||||||
"icon": "SettingOutlined",
|
|
||||||
"component": "contests/ReviewRules",
|
|
||||||
"sort": 6,
|
|
||||||
"permission": "review:rule:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "成果发布",
|
|
||||||
"path": "/contests/results",
|
|
||||||
"icon": "TrophyOutlined",
|
|
||||||
"component": "contests/results/Index",
|
|
||||||
"sort": 7,
|
|
||||||
"permission": "result:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "活动公告",
|
|
||||||
"path": "/contests/notices",
|
|
||||||
"icon": "NotificationOutlined",
|
|
||||||
"component": "contests/notices/Index",
|
|
||||||
"sort": 8,
|
|
||||||
"permission": "contest:notice:read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "机构管理",
|
|
||||||
"path": "/organization",
|
|
||||||
"icon": "BankOutlined",
|
|
||||||
"sort": 7,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "机构管理",
|
|
||||||
"path": "/system/tenants",
|
|
||||||
"icon": "UnorderedListOutlined",
|
|
||||||
"component": "system/tenants/Index",
|
|
||||||
"sort": 1,
|
|
||||||
"permission": "tenant:read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "用户中心",
|
|
||||||
"path": "/users-center",
|
|
||||||
"icon": "TeamOutlined",
|
|
||||||
"sort": 8,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "平台用户",
|
|
||||||
"path": "/system/users",
|
|
||||||
"icon": "UserSwitchOutlined",
|
|
||||||
"component": "system/users/Index",
|
|
||||||
"sort": 2,
|
|
||||||
"permission": "user:read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "角色管理",
|
|
||||||
"path": "/system/roles",
|
|
||||||
"icon": "SafetyOutlined",
|
|
||||||
"component": "system/roles/Index",
|
|
||||||
"sort": 3,
|
|
||||||
"permission": "role:read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "系统设置",
|
|
||||||
"path": "/system",
|
|
||||||
"icon": "SettingOutlined",
|
|
||||||
"sort": 9,
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "权限管理",
|
|
||||||
"path": "/system/permissions",
|
|
||||||
"icon": "SafetyOutlined",
|
|
||||||
"component": "system/permissions/Index",
|
|
||||||
"sort": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "租户管理",
|
|
||||||
"path": "/system/tenants",
|
|
||||||
"icon": "BankOutlined",
|
|
||||||
"component": "system/tenants/Index",
|
|
||||||
"sort": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -1,737 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"code": "ai-3d:read",
|
|
||||||
"resource": "ai-3d",
|
|
||||||
"action": "read",
|
|
||||||
"name": "使用3D建模实验室",
|
|
||||||
"description": "允许使用AI 3D建模实验室"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "ai-3d:create",
|
|
||||||
"resource": "ai-3d",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建3D模型任务",
|
|
||||||
"description": "允许创建AI 3D模型生成任务"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "tenant:create",
|
|
||||||
"resource": "tenant",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建租户",
|
|
||||||
"description": "允许创建租户"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "tenant:read",
|
|
||||||
"resource": "tenant",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看租户",
|
|
||||||
"description": "允许查看租户列表"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "tenant:update",
|
|
||||||
"resource": "tenant",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新租户",
|
|
||||||
"description": "允许更新租户信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "tenant:delete",
|
|
||||||
"resource": "tenant",
|
|
||||||
"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": "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": "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": "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:finish",
|
|
||||||
"resource": "contest",
|
|
||||||
"action": "finish",
|
|
||||||
"name": "结束活动",
|
|
||||||
"description": "允许结束活动"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review-rule:create",
|
|
||||||
"resource": "review-rule",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建评审规则",
|
|
||||||
"description": "允许创建评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review-rule:read",
|
|
||||||
"resource": "review-rule",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看评审规则",
|
|
||||||
"description": "允许查看评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review-rule:update",
|
|
||||||
"resource": "review-rule",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新评审规则",
|
|
||||||
"description": "允许更新评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review-rule:delete",
|
|
||||||
"resource": "review-rule",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除评审规则",
|
|
||||||
"description": "允许删除评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "judge:create",
|
|
||||||
"resource": "judge",
|
|
||||||
"action": "create",
|
|
||||||
"name": "添加评委",
|
|
||||||
"description": "允许添加评委"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "judge:read",
|
|
||||||
"resource": "judge",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看评委",
|
|
||||||
"description": "允许查看评委列表"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "judge:update",
|
|
||||||
"resource": "judge",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新评委",
|
|
||||||
"description": "允许更新评委信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "judge:delete",
|
|
||||||
"resource": "judge",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除评委",
|
|
||||||
"description": "允许删除评委"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "judge:assign",
|
|
||||||
"resource": "judge",
|
|
||||||
"action": "assign",
|
|
||||||
"name": "分配评委",
|
|
||||||
"description": "允许为活动分配评委"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "registration:create",
|
|
||||||
"resource": "registration",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建报名",
|
|
||||||
"description": "允许报名活动"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "registration:read",
|
|
||||||
"resource": "registration",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看报名",
|
|
||||||
"description": "允许查看报名记录"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "registration:update",
|
|
||||||
"resource": "registration",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新报名",
|
|
||||||
"description": "允许更新报名信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "registration:delete",
|
|
||||||
"resource": "registration",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "取消报名",
|
|
||||||
"description": "允许取消报名"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "registration:approve",
|
|
||||||
"resource": "registration",
|
|
||||||
"action": "approve",
|
|
||||||
"name": "审核报名",
|
|
||||||
"description": "允许审核报名"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "work:create",
|
|
||||||
"resource": "work",
|
|
||||||
"action": "create",
|
|
||||||
"name": "上传作品",
|
|
||||||
"description": "允许上传参赛作品"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "work:read",
|
|
||||||
"resource": "work",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看作品",
|
|
||||||
"description": "允许查看参赛作品"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "work:update",
|
|
||||||
"resource": "work",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新作品",
|
|
||||||
"description": "允许更新作品信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "work:delete",
|
|
||||||
"resource": "work",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除作品",
|
|
||||||
"description": "允许删除作品"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "work:submit",
|
|
||||||
"resource": "work",
|
|
||||||
"action": "submit",
|
|
||||||
"name": "提交作品",
|
|
||||||
"description": "允许提交作品"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review:read",
|
|
||||||
"resource": "review",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看评审任务",
|
|
||||||
"description": "允许查看待评审作品"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "review:score",
|
|
||||||
"resource": "review",
|
|
||||||
"action": "score",
|
|
||||||
"name": "评审打分",
|
|
||||||
"description": "允许对作品打分"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "notice:create",
|
|
||||||
"resource": "notice",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建公告",
|
|
||||||
"description": "允许创建活动公告"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "notice:read",
|
|
||||||
"resource": "notice",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看公告",
|
|
||||||
"description": "允许查看活动公告"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "notice:update",
|
|
||||||
"resource": "notice",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新公告",
|
|
||||||
"description": "允许更新公告信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "notice:delete",
|
|
||||||
"resource": "notice",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除公告",
|
|
||||||
"description": "允许删除公告"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework:create",
|
|
||||||
"resource": "homework",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建作业",
|
|
||||||
"description": "允许创建作业"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework:read",
|
|
||||||
"resource": "homework",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看作业",
|
|
||||||
"description": "允许查看作业列表"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework:update",
|
|
||||||
"resource": "homework",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新作业",
|
|
||||||
"description": "允许更新作业信息"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework:delete",
|
|
||||||
"resource": "homework",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除作业",
|
|
||||||
"description": "允许删除作业"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework:publish",
|
|
||||||
"resource": "homework",
|
|
||||||
"action": "publish",
|
|
||||||
"name": "发布作业",
|
|
||||||
"description": "允许发布作业"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-submission:create",
|
|
||||||
"resource": "homework-submission",
|
|
||||||
"action": "create",
|
|
||||||
"name": "提交作业",
|
|
||||||
"description": "允许提交作业"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-submission:read",
|
|
||||||
"resource": "homework-submission",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看作业提交",
|
|
||||||
"description": "允许查看作业提交记录"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-submission:update",
|
|
||||||
"resource": "homework-submission",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新作业提交",
|
|
||||||
"description": "允许更新提交的作业"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-review-rule:create",
|
|
||||||
"resource": "homework-review-rule",
|
|
||||||
"action": "create",
|
|
||||||
"name": "创建作业评审规则",
|
|
||||||
"description": "允许创建作业评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-review-rule:read",
|
|
||||||
"resource": "homework-review-rule",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看作业评审规则",
|
|
||||||
"description": "允许查看作业评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-review-rule:update",
|
|
||||||
"resource": "homework-review-rule",
|
|
||||||
"action": "update",
|
|
||||||
"name": "更新作业评审规则",
|
|
||||||
"description": "允许更新作业评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-review-rule:delete",
|
|
||||||
"resource": "homework-review-rule",
|
|
||||||
"action": "delete",
|
|
||||||
"name": "删除作业评审规则",
|
|
||||||
"description": "允许删除作业评审规则"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-score:create",
|
|
||||||
"resource": "homework-score",
|
|
||||||
"action": "create",
|
|
||||||
"name": "作业评分",
|
|
||||||
"description": "允许对作业评分"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "homework-score:read",
|
|
||||||
"resource": "homework-score",
|
|
||||||
"action": "read",
|
|
||||||
"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": "activity:read",
|
|
||||||
"resource": "activity",
|
|
||||||
"action": "read",
|
|
||||||
"name": "查看我的评审",
|
|
||||||
"description": "允许查看已发布的我的评审"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "activity:guidance",
|
|
||||||
"resource": "activity",
|
|
||||||
"action": "guidance",
|
|
||||||
"name": "指导学生",
|
|
||||||
"description": "允许指导学生参赛"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
# 超级管理员账号说明
|
|
||||||
|
|
||||||
## 📋 账号信息
|
|
||||||
|
|
||||||
### 登录凭据
|
|
||||||
|
|
||||||
- **用户名**: `admin`
|
|
||||||
- **密码**: `cms@admin`
|
|
||||||
- **昵称**: 超级管理员
|
|
||||||
- **邮箱**: admin@example.com
|
|
||||||
- **角色**: super_admin (超级管理员)
|
|
||||||
|
|
||||||
## 🔐 权限说明
|
|
||||||
|
|
||||||
超级管理员拥有系统所有权限,共 **27 个权限**:
|
|
||||||
|
|
||||||
### 用户管理权限
|
|
||||||
|
|
||||||
- `user:create` - 创建用户
|
|
||||||
- `user:read` - 查看用户
|
|
||||||
- `user:update` - 更新用户
|
|
||||||
- `user:delete` - 删除用户
|
|
||||||
|
|
||||||
### 角色管理权限
|
|
||||||
|
|
||||||
- `role:create` - 创建角色
|
|
||||||
- `role:read` - 查看角色
|
|
||||||
- `role:update` - 更新角色
|
|
||||||
- `role:delete` - 删除角色
|
|
||||||
- `role:assign` - 分配角色
|
|
||||||
|
|
||||||
### 权限管理权限
|
|
||||||
|
|
||||||
- `permission:create` - 创建权限
|
|
||||||
- `permission:read` - 查看权限
|
|
||||||
- `permission:update` - 更新权限
|
|
||||||
- `permission:delete` - 删除权限
|
|
||||||
|
|
||||||
### 菜单管理权限
|
|
||||||
|
|
||||||
- `menu:create` - 创建菜单
|
|
||||||
- `menu:read` - 查看菜单
|
|
||||||
- `menu:update` - 更新菜单
|
|
||||||
- `menu:delete` - 删除菜单
|
|
||||||
|
|
||||||
### 数据字典权限
|
|
||||||
|
|
||||||
- `dict:create` - 创建字典
|
|
||||||
- `dict:read` - 查看字典
|
|
||||||
- `dict:update` - 更新字典
|
|
||||||
- `dict:delete` - 删除字典
|
|
||||||
|
|
||||||
### 系统配置权限
|
|
||||||
|
|
||||||
- `config:create` - 创建配置
|
|
||||||
- `config:read` - 查看配置
|
|
||||||
- `config:update` - 更新配置
|
|
||||||
- `config:delete` - 删除配置
|
|
||||||
|
|
||||||
### 日志管理权限
|
|
||||||
|
|
||||||
- `log:read` - 查看日志
|
|
||||||
- `log:delete` - 删除日志
|
|
||||||
|
|
||||||
## 🚀 使用方法
|
|
||||||
|
|
||||||
### 1. 登录系统
|
|
||||||
|
|
||||||
使用以下 API 登录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/auth/login
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "cms@admin"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"nickname": "超级管理员",
|
|
||||||
"email": "admin@example.com",
|
|
||||||
"avatar": null,
|
|
||||||
"roles": ["super_admin"],
|
|
||||||
"permissions": [
|
|
||||||
"user:create",
|
|
||||||
"user:read",
|
|
||||||
"user:update",
|
|
||||||
"user:delete"
|
|
||||||
// ... 所有 27 个权限
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 使用 Token 访问 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /api/users
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 重新初始化
|
|
||||||
|
|
||||||
如果需要重新初始化超级管理员账号,可以运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
pnpm init:admin
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会:
|
|
||||||
|
|
||||||
- ✅ 创建/更新所有基础权限(27个)
|
|
||||||
- ✅ 创建/更新超级管理员角色
|
|
||||||
- ✅ 创建/更新 admin 用户
|
|
||||||
- ✅ 分配角色给用户
|
|
||||||
|
|
||||||
**注意**: 如果用户已存在,密码会被重置为 `cms@admin`
|
|
||||||
|
|
||||||
## 🔍 验证账号
|
|
||||||
|
|
||||||
验证超级管理员账号是否创建成功:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node scripts/verify-admin.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ 安全建议
|
|
||||||
|
|
||||||
1. **首次登录后立即修改密码**
|
|
||||||
2. **生产环境使用强密码**
|
|
||||||
3. **定期更换密码**
|
|
||||||
4. **不要将密码提交到版本控制**
|
|
||||||
|
|
||||||
## 📝 修改密码
|
|
||||||
|
|
||||||
可以通过以下方式修改密码:
|
|
||||||
|
|
||||||
### 方式一:通过 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PATCH /api/users/1
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"password": "new_strong_password"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式二:通过数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 需要先使用 bcrypt 加密密码
|
|
||||||
UPDATE users
|
|
||||||
SET password = '<bcrypt_hashed_password>'
|
|
||||||
WHERE username = 'admin';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式三:通过脚本
|
|
||||||
|
|
||||||
可以修改 `scripts/init-admin.ts` 中的密码,然后重新运行脚本。
|
|
||||||
|
|
||||||
## 🎯 下一步
|
|
||||||
|
|
||||||
1. ✅ 使用 admin 账号登录系统
|
|
||||||
2. ✅ 创建其他角色(如:编辑、查看者等)
|
|
||||||
3. ✅ 创建其他用户并分配角色
|
|
||||||
4. ✅ 配置菜单权限
|
|
||||||
5. ✅ 开始使用系统
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
# 比赛评委存储设计说明
|
|
||||||
|
|
||||||
## 📋 设计决策
|
|
||||||
|
|
||||||
### 问题:是否需要专门的评委表?
|
|
||||||
|
|
||||||
**结论:需要创建 `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. 保证数据一致性和查询效率
|
|
||||||
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
# 数据库配置指南
|
|
||||||
|
|
||||||
## 1. 创建数据库
|
|
||||||
|
|
||||||
首先需要在 MySQL 中创建数据库:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE db_competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 配置环境变量
|
|
||||||
|
|
||||||
### 方式一:复制示例文件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式二:手动创建 .env 文件
|
|
||||||
|
|
||||||
在 `backend` 目录下创建 `.env` 文件,内容如下:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public"
|
|
||||||
JWT_SECRET="your-secret-key-change-in-production"
|
|
||||||
PORT=3001
|
|
||||||
NODE_ENV=development
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 配置说明
|
|
||||||
|
|
||||||
### DATABASE_URL 格式
|
|
||||||
|
|
||||||
```
|
|
||||||
mysql://用户名:密码@主机:端口/数据库名?参数
|
|
||||||
```
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
|
|
||||||
- 本地 MySQL,默认端口:
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 远程 MySQL:
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL="mysql://user:password@192.168.1.100:3306/competition_management?schema=public"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用 SSL:
|
|
||||||
|
|
||||||
```
|
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/competition_management?schema=public&sslmode=require"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 包含特殊字符的密码(需要 URL 编码):
|
|
||||||
```
|
|
||||||
DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/competition_management?schema=public"
|
|
||||||
```
|
|
||||||
|
|
||||||
### JWT_SECRET
|
|
||||||
|
|
||||||
用于 JWT token 签名的密钥,生产环境必须使用强随机字符串。
|
|
||||||
|
|
||||||
**生成方式:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Node.js
|
|
||||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
||||||
|
|
||||||
# 或使用 openssl
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 初始化数据库
|
|
||||||
|
|
||||||
配置好 `.env` 文件后,执行以下命令初始化数据库:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生成 Prisma Client
|
|
||||||
pnpm prisma:generate
|
|
||||||
|
|
||||||
# 运行数据库迁移(创建表结构)
|
|
||||||
pnpm prisma:migrate
|
|
||||||
|
|
||||||
# 或使用开发模式(会提示输入迁移名称)
|
|
||||||
pnpm prisma:migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 验证连接
|
|
||||||
|
|
||||||
### 方式一:使用 Prisma Studio
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:studio
|
|
||||||
```
|
|
||||||
|
|
||||||
这会打开一个可视化界面,可以在浏览器中查看和管理数据库。
|
|
||||||
|
|
||||||
### 方式二:测试连接
|
|
||||||
|
|
||||||
启动后端服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
如果连接成功,服务会正常启动;如果失败,会显示具体的错误信息。
|
|
||||||
|
|
||||||
## 6. 常见问题
|
|
||||||
|
|
||||||
### 问题 1: 连接被拒绝
|
|
||||||
|
|
||||||
**错误信息:** `Can't reach database server`
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 检查 MySQL 服务是否启动
|
|
||||||
- 检查主机和端口是否正确
|
|
||||||
- 检查防火墙设置
|
|
||||||
|
|
||||||
### 问题 2: 认证失败
|
|
||||||
|
|
||||||
**错误信息:** `Access denied for user`
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 检查用户名和密码是否正确
|
|
||||||
- 确认用户有访问该数据库的权限
|
|
||||||
- 如果密码包含特殊字符,需要进行 URL 编码
|
|
||||||
|
|
||||||
### 问题 3: 数据库不存在
|
|
||||||
|
|
||||||
**错误信息:** `Unknown database`
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 先创建数据库(见步骤 1)
|
|
||||||
- 检查数据库名称是否正确
|
|
||||||
|
|
||||||
### 问题 4: 字符集问题
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
创建数据库时指定字符集:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 生产环境配置
|
|
||||||
|
|
||||||
生产环境建议:
|
|
||||||
|
|
||||||
1. **使用环境变量管理工具**(如 AWS Secrets Manager、Azure Key Vault)
|
|
||||||
2. **使用连接池**(Prisma 默认已配置)
|
|
||||||
3. **启用 SSL 连接**
|
|
||||||
4. **定期备份数据库**
|
|
||||||
5. **使用强密码和 JWT_SECRET**
|
|
||||||
|
|
||||||
## 8. 数据库迁移
|
|
||||||
|
|
||||||
### 创建新迁移
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:migrate dev --name migration_name
|
|
||||||
```
|
|
||||||
|
|
||||||
### 应用迁移(生产环境)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重置数据库(开发环境)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:migrate reset
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意:** 这会删除所有数据,仅用于开发环境!
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
# DATABASE_URL 来源说明
|
|
||||||
|
|
||||||
## 📍 定义位置
|
|
||||||
|
|
||||||
`DATABASE_URL` 在 `schema.prisma` 中定义:
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
datasource db {
|
|
||||||
provider = "mysql"
|
|
||||||
url = env("DATABASE_URL") // ← 从这里读取环境变量
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 加载流程
|
|
||||||
|
|
||||||
### 1. 配置文件定义
|
|
||||||
|
|
||||||
`DATABASE_URL` 定义在环境配置文件中:
|
|
||||||
|
|
||||||
**当前配置**:`.development.env` 文件
|
|
||||||
```env
|
|
||||||
DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. NestJS ConfigModule 加载
|
|
||||||
|
|
||||||
在 `app.module.ts` 中配置:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
isGlobal: true,
|
|
||||||
envFilePath: ['.development.env'], // ← 从这里加载环境变量
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**加载顺序**:
|
|
||||||
1. NestJS ConfigModule 读取 `.development.env` 文件
|
|
||||||
2. 将文件中的 `DATABASE_URL` 加载到 `process.env.DATABASE_URL`
|
|
||||||
3. 应用启动时,所有模块都可以通过 `ConfigService` 访问
|
|
||||||
|
|
||||||
### 3. Prisma 读取
|
|
||||||
|
|
||||||
Prisma 在以下时机读取 `DATABASE_URL`:
|
|
||||||
|
|
||||||
1. **生成 Prisma Client 时**:
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
- 读取 `process.env.DATABASE_URL`
|
|
||||||
- 生成类型定义(不连接数据库)
|
|
||||||
|
|
||||||
2. **运行迁移时**:
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev
|
|
||||||
npx prisma migrate deploy
|
|
||||||
```
|
|
||||||
- 读取 `process.env.DATABASE_URL`
|
|
||||||
- 连接到数据库执行迁移
|
|
||||||
|
|
||||||
3. **应用运行时**:
|
|
||||||
- `PrismaService` 初始化时读取 `process.env.DATABASE_URL`
|
|
||||||
- 建立数据库连接
|
|
||||||
|
|
||||||
## 📂 配置文件优先级
|
|
||||||
|
|
||||||
根据 `app.module.ts` 的配置:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
envFilePath: ['.development.env']
|
|
||||||
```
|
|
||||||
|
|
||||||
**当前配置**:
|
|
||||||
- ✅ 优先加载:`.development.env`
|
|
||||||
- ⚠️ 注意:如果设置了 `ignoreEnvFile: true`,则不会加载文件,只使用系统环境变量
|
|
||||||
|
|
||||||
## 🔍 验证 DATABASE_URL 来源
|
|
||||||
|
|
||||||
### 方法 1:查看环境变量(应用运行时)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动应用后,访问配置验证接口
|
|
||||||
curl http://localhost:3001/api/config-verification/env-info
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方法 2:查看启动日志
|
|
||||||
|
|
||||||
应用启动时会在控制台显示:
|
|
||||||
```
|
|
||||||
=== 环境配置验证 ===
|
|
||||||
DATABASE_URL: 已设置 mysql://root:woshimima@localhost:3306/db_competition_management?schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方法 3:检查配置文件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
cat .development.env | grep DATABASE_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方法 4:在代码中验证
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在任何服务中
|
|
||||||
constructor(private configService: ConfigService) {}
|
|
||||||
|
|
||||||
const dbUrl = this.configService.get('DATABASE_URL');
|
|
||||||
console.log('DATABASE_URL:', dbUrl);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 环境变量来源优先级
|
|
||||||
|
|
||||||
Prisma 读取 `DATABASE_URL` 的优先级:
|
|
||||||
|
|
||||||
1. **系统环境变量**(最高优先级)
|
|
||||||
```bash
|
|
||||||
export DATABASE_URL="mysql://..."
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **.env 文件**(通过 ConfigModule 加载)
|
|
||||||
- `.development.env`
|
|
||||||
- `.env`
|
|
||||||
|
|
||||||
3. **默认值**(如果都没有设置,Prisma 会报错)
|
|
||||||
|
|
||||||
## 📝 DATABASE_URL 格式
|
|
||||||
|
|
||||||
```
|
|
||||||
mysql://用户名:密码@主机:端口/数据库名?参数
|
|
||||||
```
|
|
||||||
|
|
||||||
**示例**:
|
|
||||||
```env
|
|
||||||
# 本地数据库
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/db_competition_management?schema=public"
|
|
||||||
|
|
||||||
# 远程数据库
|
|
||||||
DATABASE_URL="mysql://user:pass@192.168.1.100:3306/db_name?schema=public"
|
|
||||||
|
|
||||||
# 带 SSL
|
|
||||||
DATABASE_URL="mysql://user:pass@host:3306/db_name?schema=public&sslmode=require"
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
1. **密码包含特殊字符**:需要进行 URL 编码
|
|
||||||
```env
|
|
||||||
# 密码: p@ssw0rd
|
|
||||||
DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/db"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **配置文件安全**:
|
|
||||||
- `.development.env` 不应提交到 Git
|
|
||||||
- 生产环境使用环境变量或密钥管理服务
|
|
||||||
|
|
||||||
3. **Prisma 读取时机**:
|
|
||||||
- Prisma 直接读取 `process.env.DATABASE_URL`
|
|
||||||
- 不依赖 NestJS ConfigModule(但 ConfigModule 会将文件内容加载到 `process.env`)
|
|
||||||
|
|
||||||
## 🔧 当前配置总结
|
|
||||||
|
|
||||||
- **配置文件**:`.development.env`
|
|
||||||
- **配置项**:`DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public"`
|
|
||||||
- **加载方式**:NestJS ConfigModule → `process.env` → Prisma
|
|
||||||
- **验证方式**:启动日志或 `/api/config-verification/env-info` 接口
|
|
||||||
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
# 环境配置指南
|
|
||||||
|
|
||||||
## 环境区分方案
|
|
||||||
|
|
||||||
项目支持通过 `NODE_ENV` 环境变量和不同的 `.env` 文件来区分开发和生产环境。
|
|
||||||
|
|
||||||
## 配置文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── .env # 默认配置(可选,作为后备)
|
|
||||||
├── .env.development # 开发环境配置
|
|
||||||
├── .env.production # 生产环境配置
|
|
||||||
└── .env.test # 测试环境配置(可选)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置优先级
|
|
||||||
|
|
||||||
配置文件按以下优先级加载:
|
|
||||||
|
|
||||||
1. `.env.${NODE_ENV}` - 根据当前环境加载(最高优先级)
|
|
||||||
2. `.env` - 默认配置文件(后备)
|
|
||||||
|
|
||||||
例如:
|
|
||||||
- `NODE_ENV=development` → 加载 `.env.development`
|
|
||||||
- `NODE_ENV=production` → 加载 `.env.production`
|
|
||||||
- 未设置 `NODE_ENV` → 默认加载 `.env.development`,然后 `.env`
|
|
||||||
|
|
||||||
## 开发环境配置
|
|
||||||
|
|
||||||
### 创建 `.env.development` 文件
|
|
||||||
|
|
||||||
```env
|
|
||||||
# 开发环境配置
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# 开发数据库(本地数据库)
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
|
||||||
|
|
||||||
# JWT 密钥(开发环境可以使用简单密钥)
|
|
||||||
JWT_SECRET="dev-secret-key-not-for-production"
|
|
||||||
|
|
||||||
# 服务器端口
|
|
||||||
PORT=3001
|
|
||||||
|
|
||||||
# 日志级别
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
# CORS 配置(开发环境允许所有来源)
|
|
||||||
CORS_ORIGIN=*
|
|
||||||
```
|
|
||||||
|
|
||||||
### 开发环境数据库命名建议
|
|
||||||
|
|
||||||
- 数据库名:`competition_management_dev`
|
|
||||||
- 便于区分:开发和生产使用不同的数据库
|
|
||||||
- 安全:避免误操作生产数据
|
|
||||||
|
|
||||||
## 生产环境配置
|
|
||||||
|
|
||||||
### 创建 `.env.production` 文件
|
|
||||||
|
|
||||||
```env
|
|
||||||
# 生产环境配置
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# 生产数据库(远程或云数据库)
|
|
||||||
DATABASE_URL="mysql://prod_user:strong_password@prod-db-host:3306/competition_management?schema=public&sslmode=require"
|
|
||||||
|
|
||||||
# JWT 密钥(必须使用强随机字符串)
|
|
||||||
# 生成方式: openssl rand -hex 32
|
|
||||||
JWT_SECRET="your-production-secret-key-must-be-strong-and-random-64-chars"
|
|
||||||
|
|
||||||
# 服务器端口
|
|
||||||
PORT=3001
|
|
||||||
|
|
||||||
# 日志级别
|
|
||||||
LOG_LEVEL=error
|
|
||||||
|
|
||||||
# CORS 配置(生产环境指定具体域名)
|
|
||||||
CORS_ORIGIN=https://yourdomain.com
|
|
||||||
|
|
||||||
# 数据库连接池配置
|
|
||||||
DB_POOL_MIN=2
|
|
||||||
DB_POOL_MAX=10
|
|
||||||
|
|
||||||
# SSL/TLS 配置
|
|
||||||
SSL_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境数据库配置要点
|
|
||||||
|
|
||||||
1. **使用独立的数据库服务器**
|
|
||||||
2. **启用 SSL 连接**(`sslmode=require`)
|
|
||||||
3. **使用强密码**
|
|
||||||
4. **限制数据库用户权限**(最小权限原则)
|
|
||||||
5. **定期备份**
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 开发环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1: 设置环境变量后启动
|
|
||||||
NODE_ENV=development pnpm start:dev
|
|
||||||
|
|
||||||
# 方式 2: 在 package.json 中配置(推荐)
|
|
||||||
# 已自动配置,直接运行:
|
|
||||||
pnpm start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1: 设置环境变量后启动
|
|
||||||
NODE_ENV=production pnpm start:prod
|
|
||||||
|
|
||||||
# 方式 2: 在部署脚本中设置
|
|
||||||
export NODE_ENV=production
|
|
||||||
pnpm start:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试环境(可选)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建 .env.test 文件
|
|
||||||
NODE_ENV=test
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_test?schema=public"
|
|
||||||
JWT_SECRET="test-secret-key"
|
|
||||||
PORT=3002
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
NODE_ENV=test pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据库命名规范
|
|
||||||
|
|
||||||
建议使用以下命名规范来区分不同环境的数据库:
|
|
||||||
|
|
||||||
| 环境 | 数据库名 | 说明 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 开发 | `competition_management_dev` | 开发环境数据库 |
|
|
||||||
| 测试 | `competition_management_test` | 测试环境数据库 |
|
|
||||||
| 生产 | `competition_management` | 生产环境数据库 |
|
|
||||||
| 预发布 | `competition_management_staging` | 预发布环境数据库 |
|
|
||||||
|
|
||||||
## 创建不同环境的数据库
|
|
||||||
|
|
||||||
### 开发环境数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE competition_management_dev
|
|
||||||
CHARACTER SET utf8mb4
|
|
||||||
COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE competition_management
|
|
||||||
CHARACTER SET utf8mb4
|
|
||||||
COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环境变量管理最佳实践
|
|
||||||
|
|
||||||
### 1. 使用 .gitignore
|
|
||||||
|
|
||||||
确保 `.env*` 文件不被提交到版本控制:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# .env files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
.env.development
|
|
||||||
.env.production
|
|
||||||
.env.test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 提供示例文件
|
|
||||||
|
|
||||||
创建 `.env.example` 或 `.env.*.example` 文件作为模板:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发环境示例
|
|
||||||
cp .env.development.example .env.development
|
|
||||||
|
|
||||||
# 生产环境示例
|
|
||||||
cp .env.production.example .env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 使用环境变量管理工具(生产环境)
|
|
||||||
|
|
||||||
- **Docker**: 使用 `docker-compose.yml` 中的 `env_file`
|
|
||||||
- **Kubernetes**: 使用 `ConfigMap` 和 `Secret`
|
|
||||||
- **云平台**:
|
|
||||||
- AWS: Secrets Manager
|
|
||||||
- Azure: Key Vault
|
|
||||||
- GCP: Secret Manager
|
|
||||||
|
|
||||||
### 4. 验证配置
|
|
||||||
|
|
||||||
在应用启动时验证必要的环境变量:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 可以在 main.ts 中添加验证
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
throw new Error('DATABASE_URL is required');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 创建开发环境配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 创建开发环境配置文件
|
|
||||||
cat > .env.development << EOF
|
|
||||||
NODE_ENV=development
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
|
||||||
JWT_SECRET="dev-secret-key"
|
|
||||||
PORT=3001
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 创建生产环境配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建生产环境配置文件(不要提交到 Git)
|
|
||||||
cat > .env.production << EOF
|
|
||||||
NODE_ENV=production
|
|
||||||
DATABASE_URL="mysql://prod_user:password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
|
||||||
JWT_SECRET="$(openssl rand -hex 32)"
|
|
||||||
PORT=3001
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 初始化数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发环境
|
|
||||||
NODE_ENV=development pnpm prisma:migrate dev
|
|
||||||
|
|
||||||
# 生产环境(部署时)
|
|
||||||
NODE_ENV=production pnpm prisma:migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 如何确保使用正确的环境配置?
|
|
||||||
|
|
||||||
A: 在启动应用前检查 `NODE_ENV` 环境变量:
|
|
||||||
```bash
|
|
||||||
echo $NODE_ENV # 应该显示 development 或 production
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 生产环境配置应该存储在哪里?
|
|
||||||
|
|
||||||
A:
|
|
||||||
- **不要提交到 Git**
|
|
||||||
- 使用环境变量管理工具(如 Docker secrets、K8s secrets)
|
|
||||||
- 或使用云平台提供的密钥管理服务
|
|
||||||
|
|
||||||
### Q: 如何在不同环境间切换?
|
|
||||||
|
|
||||||
A: 通过设置 `NODE_ENV` 环境变量:
|
|
||||||
```bash
|
|
||||||
# 开发环境
|
|
||||||
export NODE_ENV=development
|
|
||||||
pnpm start:dev
|
|
||||||
|
|
||||||
# 生产环境
|
|
||||||
export NODE_ENV=production
|
|
||||||
pnpm start:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 数据库迁移如何区分环境?
|
|
||||||
|
|
||||||
A: Prisma 会根据 `DATABASE_URL` 环境变量自动使用对应的数据库:
|
|
||||||
```bash
|
|
||||||
# 开发环境迁移
|
|
||||||
NODE_ENV=development pnpm prisma:migrate dev
|
|
||||||
|
|
||||||
# 生产环境迁移
|
|
||||||
NODE_ENV=production pnpm prisma:migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
@ -1,254 +0,0 @@
|
|||||||
# 修改 DATABASE_URL 后的操作指南
|
|
||||||
|
|
||||||
## 📋 操作决策树
|
|
||||||
|
|
||||||
```
|
|
||||||
修改 DATABASE_URL
|
|
||||||
│
|
|
||||||
├─ 只改了连接信息(地址/端口/用户名/密码/数据库名)
|
|
||||||
│ └─ schema.prisma 未修改
|
|
||||||
│ ├─ 目标数据库已有表结构 → ✅ 只需重启应用
|
|
||||||
│ └─ 目标数据库是空的 → ⚠️ 需要运行迁移
|
|
||||||
│
|
|
||||||
└─ 同时修改了 schema.prisma
|
|
||||||
└─ ✅ 必须执行:生成 Client + 运行迁移
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 场景 1:只修改连接信息(最常见)
|
|
||||||
|
|
||||||
### 情况 A:目标数据库已有表结构
|
|
||||||
|
|
||||||
**示例**:从本地数据库切换到远程数据库,但表结构已存在
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 修改 .development.env 文件
|
|
||||||
DATABASE_URL="mysql://user:pass@new-host:3306/db_name?schema=public"
|
|
||||||
|
|
||||||
# 2. 重启应用即可(无需执行 Prisma 命令)
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
|
|
||||||
- Prisma Client 在应用启动时读取 `process.env.DATABASE_URL`
|
|
||||||
- 如果目标数据库已有表结构,直接连接即可
|
|
||||||
- 不需要重新生成 Client(类型定义没变)
|
|
||||||
- 不需要运行迁移(表结构没变)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 情况 B:目标数据库是空的(新数据库)
|
|
||||||
|
|
||||||
**示例**:切换到全新的数据库,还没有表结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 修改 .development.env 文件
|
|
||||||
DATABASE_URL="mysql://user:pass@new-host:3306/new_db?schema=public"
|
|
||||||
|
|
||||||
# 2. 运行迁移创建表结构
|
|
||||||
npm run prisma:migrate
|
|
||||||
|
|
||||||
# 或使用部署模式(生产环境)
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# 3. 重启应用
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
|
|
||||||
- 新数据库没有表结构
|
|
||||||
- 需要运行迁移来创建表
|
|
||||||
- 迁移会读取 `process.env.DATABASE_URL` 连接到新数据库
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 场景 2:同时修改了 schema.prisma
|
|
||||||
|
|
||||||
**示例**:修改了数据库模型(添加/删除字段、表等)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 修改 schema.prisma(添加字段、表等)
|
|
||||||
|
|
||||||
# 2. 生成 Prisma Client(必须)
|
|
||||||
npm run prisma:generate
|
|
||||||
|
|
||||||
# 3. 创建并运行迁移(必须)
|
|
||||||
npm run prisma:migrate
|
|
||||||
# 会提示输入迁移名称,如:add_user_email_field
|
|
||||||
|
|
||||||
# 4. 重启应用
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
|
|
||||||
- schema.prisma 改变 → TypeScript 类型定义改变 → 需要重新生成 Client
|
|
||||||
- 数据库结构改变 → 需要创建迁移并应用到数据库
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 完整操作流程
|
|
||||||
|
|
||||||
### 开发环境(推荐流程)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 1. 修改 .development.env 中的 DATABASE_URL
|
|
||||||
vim .development.env
|
|
||||||
|
|
||||||
# 2. 检查目标数据库是否有表结构
|
|
||||||
# 方式 A:使用 Prisma Studio 查看
|
|
||||||
npm run prisma:studio
|
|
||||||
|
|
||||||
# 方式 B:直接连接数据库查看
|
|
||||||
mysql -h host -u user -p database -e "SHOW TABLES;"
|
|
||||||
|
|
||||||
# 3. 根据情况选择操作:
|
|
||||||
|
|
||||||
# 情况 1:数据库已有表结构 → 只需重启
|
|
||||||
npm run start:dev
|
|
||||||
|
|
||||||
# 情况 2:数据库是空的 → 运行迁移
|
|
||||||
npm run prisma:migrate
|
|
||||||
npm run start:dev
|
|
||||||
|
|
||||||
# 情况 3:修改了 schema.prisma → 生成 + 迁移
|
|
||||||
npm run prisma:generate
|
|
||||||
npm run prisma:migrate
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境(部署流程)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 1. 修改生产环境配置文件或环境变量
|
|
||||||
# 注意:生产环境通常使用环境变量,而不是文件
|
|
||||||
|
|
||||||
# 2. 生成 Prisma Client
|
|
||||||
npm run prisma:generate
|
|
||||||
|
|
||||||
# 3. 运行迁移(生产环境使用 deploy,不会创建新迁移)
|
|
||||||
NODE_ENV=production npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# 4. 重启应用
|
|
||||||
npm run start:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 快速检查清单
|
|
||||||
|
|
||||||
修改 `DATABASE_URL` 后,按以下顺序检查:
|
|
||||||
|
|
||||||
- [ ] **只改了连接信息?**
|
|
||||||
- [ ] 目标数据库有表 → ✅ 重启应用
|
|
||||||
- [ ] 目标数据库为空 → ⚠️ 运行迁移
|
|
||||||
|
|
||||||
- [ ] **修改了 schema.prisma?**
|
|
||||||
- [ ] 是 → ✅ 生成 Client + 运行迁移
|
|
||||||
- [ ] 否 → 跳过
|
|
||||||
|
|
||||||
- [ ] **应用启动后验证**
|
|
||||||
- [ ] 检查启动日志中的 DATABASE_URL
|
|
||||||
- [ ] 访问 `/api/config-verification/env-info` 验证
|
|
||||||
- [ ] 测试数据库操作是否正常
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 验证方法
|
|
||||||
|
|
||||||
### 1. 验证 DATABASE_URL 是否生效
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动应用后查看日志
|
|
||||||
npm run start:dev
|
|
||||||
|
|
||||||
# 应该看到:
|
|
||||||
# DATABASE_URL: 已设置 mysql://...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 验证数据库连接
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Prisma Studio 连接
|
|
||||||
npm run prisma:studio
|
|
||||||
|
|
||||||
# 如果能打开并看到表,说明连接成功
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 验证表结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查迁移状态
|
|
||||||
npx prisma migrate status
|
|
||||||
|
|
||||||
# 应该显示:All migrations have been successfully applied
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 常见错误
|
|
||||||
|
|
||||||
### 错误 1:连接失败
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Can't reach database server
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
- 检查 DATABASE_URL 格式是否正确
|
|
||||||
- 检查数据库服务是否运行
|
|
||||||
- 检查网络连接和防火墙
|
|
||||||
|
|
||||||
### 错误 2:表不存在
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Table 'xxx' doesn't exist
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
- 运行迁移:`npm run prisma:migrate`
|
|
||||||
- 或使用:`npx prisma db push`(仅开发环境)
|
|
||||||
|
|
||||||
### 错误 3:迁移状态不一致
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: The migration failed to apply
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
- 检查迁移历史:`npx prisma migrate status`
|
|
||||||
- 重置数据库(仅开发环境):`npx prisma migrate reset`
|
|
||||||
- 或手动修复迁移文件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关命令速查
|
|
||||||
|
|
||||||
| 操作 | 命令 | 说明 |
|
|
||||||
| ----------- | ------------------------------- | ------------------------- |
|
|
||||||
| 生成 Client | `npm run prisma:generate` | 根据 schema 生成类型 |
|
|
||||||
| 创建迁移 | `npm run prisma:migrate` | 开发环境,会创建新迁移 |
|
|
||||||
| 应用迁移 | `npm run prisma:migrate:deploy` | 生产环境,只应用已有迁移 |
|
|
||||||
| 查看状态 | `npx prisma migrate status` | 查看迁移状态 |
|
|
||||||
| 打开 Studio | `npm run prisma:studio` | 可视化数据库 |
|
|
||||||
| 推送结构 | `npx prisma db push` | 直接同步 schema(仅开发) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 总结
|
|
||||||
|
|
||||||
**修改 DATABASE_URL 后的最小操作**:
|
|
||||||
|
|
||||||
1. **只改连接信息 + 数据库有表** → ✅ **重启应用**
|
|
||||||
2. **只改连接信息 + 数据库为空** → ⚠️ **运行迁移**
|
|
||||||
3. **修改了 schema.prisma** → ✅ **生成 Client + 运行迁移**
|
|
||||||
|
|
||||||
**记住**:Prisma Client 在应用启动时读取 `process.env.DATABASE_URL`,所以修改后必须重启应用才能生效!
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
# 菜单初始化指南
|
|
||||||
|
|
||||||
## 📋 概述
|
|
||||||
|
|
||||||
菜单初始化脚本会根据项目的前端路由配置,自动创建菜单数据到数据库中。脚本会创建树形结构的菜单,包括顶级菜单和子菜单。
|
|
||||||
|
|
||||||
## 🚀 使用方法
|
|
||||||
|
|
||||||
### 1. 执行初始化脚本
|
|
||||||
|
|
||||||
在 `backend` 目录下执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm init:menus
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用 npm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run init:menus
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 脚本功能
|
|
||||||
|
|
||||||
脚本会根据 `frontend/src/router/index.ts` 中的路由配置,自动创建以下菜单结构:
|
|
||||||
|
|
||||||
```
|
|
||||||
仪表盘 (/dashboard)
|
|
||||||
系统管理 (/system)
|
|
||||||
├── 用户管理 (/system/users)
|
|
||||||
├── 角色管理 (/system/roles)
|
|
||||||
├── 菜单管理 (/system/menus)
|
|
||||||
├── 数据字典 (/system/dict)
|
|
||||||
├── 系统配置 (/system/config)
|
|
||||||
└── 日志记录 (/system/logs)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 菜单数据结构
|
|
||||||
|
|
||||||
### 顶级菜单
|
|
||||||
|
|
||||||
1. **仪表盘**
|
|
||||||
- 路径: `/dashboard`
|
|
||||||
- 图标: `DashboardOutlined`
|
|
||||||
- 组件: `dashboard/Index`
|
|
||||||
- 排序: 1
|
|
||||||
|
|
||||||
2. **系统管理**
|
|
||||||
- 路径: `/system`
|
|
||||||
- 图标: `SettingOutlined`
|
|
||||||
- 组件: `null` (父菜单)
|
|
||||||
- 排序: 10
|
|
||||||
|
|
||||||
### 系统管理子菜单
|
|
||||||
|
|
||||||
1. **用户管理**
|
|
||||||
- 路径: `/system/users`
|
|
||||||
- 图标: `UserOutlined`
|
|
||||||
- 组件: `system/users/Index`
|
|
||||||
- 排序: 1
|
|
||||||
|
|
||||||
2. **角色管理**
|
|
||||||
- 路径: `/system/roles`
|
|
||||||
- 图标: `TeamOutlined`
|
|
||||||
- 组件: `system/roles/Index`
|
|
||||||
- 排序: 2
|
|
||||||
|
|
||||||
3. **菜单管理**
|
|
||||||
- 路径: `/system/menus`
|
|
||||||
- 图标: `MenuOutlined`
|
|
||||||
- 组件: `system/menus/Index`
|
|
||||||
- 排序: 3
|
|
||||||
|
|
||||||
4. **数据字典**
|
|
||||||
- 路径: `/system/dict`
|
|
||||||
- 图标: `BookOutlined`
|
|
||||||
- 组件: `system/dict/Index`
|
|
||||||
- 排序: 4
|
|
||||||
|
|
||||||
5. **系统配置**
|
|
||||||
- 路径: `/system/config`
|
|
||||||
- 图标: `ToolOutlined`
|
|
||||||
- 组件: `system/config/Index`
|
|
||||||
- 排序: 5
|
|
||||||
|
|
||||||
6. **日志记录**
|
|
||||||
- 路径: `/system/logs`
|
|
||||||
- 图标: `FileTextOutlined`
|
|
||||||
- 组件: `system/logs/Index`
|
|
||||||
- 排序: 6
|
|
||||||
|
|
||||||
## 🔄 脚本特性
|
|
||||||
|
|
||||||
### 1. 幂等性
|
|
||||||
|
|
||||||
- 脚本支持重复执行
|
|
||||||
- 如果菜单已存在(相同名称和父菜单),会更新现有菜单
|
|
||||||
- 如果菜单不存在,会创建新菜单
|
|
||||||
|
|
||||||
### 2. 树形结构
|
|
||||||
|
|
||||||
- 自动处理父子菜单关系
|
|
||||||
- 递归创建子菜单
|
|
||||||
- 保持菜单层级结构
|
|
||||||
|
|
||||||
### 3. 数据更新
|
|
||||||
|
|
||||||
- 如果菜单已存在,会更新以下字段:
|
|
||||||
- 路径 (path)
|
|
||||||
- 图标 (icon)
|
|
||||||
- 组件路径 (component)
|
|
||||||
- 排序 (sort)
|
|
||||||
- 有效状态 (validState)
|
|
||||||
|
|
||||||
## ⚙️ 自定义菜单数据
|
|
||||||
|
|
||||||
如果需要修改菜单数据,可以编辑 `backend/scripts/init-menus.ts` 文件中的 `menus` 数组:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const menus = [
|
|
||||||
{
|
|
||||||
name: '菜单名称',
|
|
||||||
path: '/路由路径',
|
|
||||||
icon: 'IconOutlined', // Ant Design Icons 图标名称
|
|
||||||
component: '组件路径', // 相对于 views 目录的路径
|
|
||||||
parentId: null, // null 表示顶级菜单
|
|
||||||
sort: 1, // 排序值,越小越靠前
|
|
||||||
children: [
|
|
||||||
// 子菜单数组(可选)
|
|
||||||
// ...
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗑️ 清空现有菜单(可选)
|
|
||||||
|
|
||||||
如果需要清空所有现有菜单后重新创建,可以取消注释脚本中的以下代码:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 清空现有菜单
|
|
||||||
console.log('🗑️ 清空现有菜单...');
|
|
||||||
await prisma.menu.deleteMany({});
|
|
||||||
console.log('✅ 已清空现有菜单\n');
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**: 清空菜单会删除所有现有菜单数据,请谨慎操作!
|
|
||||||
|
|
||||||
## 📊 执行结果示例
|
|
||||||
|
|
||||||
脚本执行成功后会显示:
|
|
||||||
|
|
||||||
```
|
|
||||||
🚀 开始初始化菜单数据...
|
|
||||||
|
|
||||||
📝 创建菜单...
|
|
||||||
|
|
||||||
✓ 仪表盘 (/dashboard)
|
|
||||||
✓ 系统管理 (/system)
|
|
||||||
✓ 用户管理 (/system/users)
|
|
||||||
✓ 角色管理 (/system/roles)
|
|
||||||
✓ 菜单管理 (/system/menus)
|
|
||||||
✓ 数据字典 (/system/dict)
|
|
||||||
✓ 系统配置 (/system/config)
|
|
||||||
✓ 日志记录 (/system/logs)
|
|
||||||
|
|
||||||
🔍 验证结果...
|
|
||||||
|
|
||||||
📊 初始化结果:
|
|
||||||
顶级菜单数量: 2
|
|
||||||
总菜单数量: 8
|
|
||||||
|
|
||||||
📋 菜单结构:
|
|
||||||
├─ 仪表盘 (/dashboard)
|
|
||||||
├─ 系统管理 (/system)
|
|
||||||
│ ├─ 用户管理 (/system/users)
|
|
||||||
│ ├─ 角色管理 (/system/roles)
|
|
||||||
│ ├─ 菜单管理 (/system/menus)
|
|
||||||
│ ├─ 数据字典 (/system/dict)
|
|
||||||
│ ├─ 系统配置 (/system/config)
|
|
||||||
│ └─ 日志记录 (/system/logs)
|
|
||||||
|
|
||||||
✅ 菜单初始化完成!
|
|
||||||
|
|
||||||
🎉 菜单初始化脚本执行完成!
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 验证菜单数据
|
|
||||||
|
|
||||||
初始化完成后,可以通过以下方式验证:
|
|
||||||
|
|
||||||
### 方式一:使用 Prisma Studio
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm prisma:studio
|
|
||||||
```
|
|
||||||
|
|
||||||
在浏览器中打开 Prisma Studio,查看 `menus` 表的数据。
|
|
||||||
|
|
||||||
### 方式二:通过菜单管理页面
|
|
||||||
|
|
||||||
1. 登录系统
|
|
||||||
2. 访问"系统管理" -> "菜单管理"
|
|
||||||
3. 查看菜单列表,确认菜单已正确创建
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
1. **数据库连接**: 确保 `.env` 文件中的 `DATABASE_URL` 配置正确
|
|
||||||
2. **Prisma Client**: 确保已运行 `pnpm prisma:generate` 生成 Prisma Client
|
|
||||||
3. **数据库迁移**: 确保已运行 `pnpm prisma:migrate` 创建数据库表结构
|
|
||||||
4. **图标名称**: 图标名称必须是有效的 Ant Design Icons 组件名称
|
|
||||||
5. **路径格式**: 路由路径必须以 `/` 开头
|
|
||||||
6. **组件路径**: 组件路径是相对于 `frontend/src/views/` 目录的路径
|
|
||||||
|
|
||||||
## 🔗 相关文档
|
|
||||||
|
|
||||||
- [数据库配置指南](./DATABASE_SETUP.md)
|
|
||||||
- [管理员账户初始化](./ADMIN_ACCOUNT.md)
|
|
||||||
- [路由配置说明](../frontend/src/router/index.ts)
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
# Prisma 增量迁移指南
|
|
||||||
|
|
||||||
## 📋 概述
|
|
||||||
|
|
||||||
Prisma 的迁移机制**已经内置了增量执行功能**。当你运行迁移命令时,Prisma 会自动:
|
|
||||||
|
|
||||||
- ✅ 只执行**新增的、未应用的**迁移
|
|
||||||
- ✅ **跳过**已经执行过的迁移
|
|
||||||
- ✅ 通过 `_prisma_migrations` 表跟踪迁移状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Prisma 如何跟踪迁移状态
|
|
||||||
|
|
||||||
Prisma 在数据库中维护一个特殊的表 `_prisma_migrations`,用于记录:
|
|
||||||
|
|
||||||
- 迁移名称(migration_name)
|
|
||||||
- 应用时间(applied_at)
|
|
||||||
- 迁移文件内容(checksum)
|
|
||||||
- 其他元数据
|
|
||||||
|
|
||||||
每次迁移执行后,Prisma 会在这个表中记录一条记录,确保不会重复执行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 迁移命令对比
|
|
||||||
|
|
||||||
### 1. `prisma migrate deploy`(生产环境推荐)
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
|
|
||||||
- ✅ **只执行未应用的迁移**
|
|
||||||
- ✅ 不会创建新迁移
|
|
||||||
- ✅ 不会重置数据库
|
|
||||||
- ✅ 适合生产环境
|
|
||||||
|
|
||||||
**使用场景**:
|
|
||||||
|
|
||||||
- 生产环境部署
|
|
||||||
- CI/CD 流程
|
|
||||||
- 多环境同步
|
|
||||||
|
|
||||||
**示例**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生产环境
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# 或直接使用
|
|
||||||
NODE_ENV=production prisma migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
**执行逻辑**:
|
|
||||||
|
|
||||||
1. 读取 `prisma/migrations` 目录中的所有迁移文件
|
|
||||||
2. 查询数据库中的 `_prisma_migrations` 表
|
|
||||||
3. 对比找出未应用的迁移
|
|
||||||
4. **只执行未应用的迁移**
|
|
||||||
5. 在 `_prisma_migrations` 表中记录新应用的迁移
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `prisma migrate dev`(开发环境推荐)
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
|
|
||||||
- ✅ 创建新迁移(如果有 schema 变更)
|
|
||||||
- ✅ **只执行未应用的迁移**
|
|
||||||
- ✅ 可能会重置开发数据库(如果使用 shadow database)
|
|
||||||
- ✅ 适合开发环境
|
|
||||||
|
|
||||||
**使用场景**:
|
|
||||||
|
|
||||||
- 本地开发
|
|
||||||
- Schema 变更后创建迁移
|
|
||||||
|
|
||||||
**示例**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发环境
|
|
||||||
npm run prisma:migrate
|
|
||||||
|
|
||||||
# 或直接使用
|
|
||||||
prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**执行逻辑**:
|
|
||||||
|
|
||||||
1. 检查 schema.prisma 是否有变更
|
|
||||||
2. 如果有变更,创建新迁移文件
|
|
||||||
3. 查询 `_prisma_migrations` 表找出未应用的迁移
|
|
||||||
4. **只执行未应用的迁移**(包括新创建的)
|
|
||||||
5. 记录到 `_prisma_migrations` 表
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 查看迁移状态
|
|
||||||
|
|
||||||
### 检查哪些迁移已应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看迁移状态
|
|
||||||
npx prisma migrate status
|
|
||||||
|
|
||||||
# 输出示例:
|
|
||||||
# ✅ Database schema is up to date!
|
|
||||||
#
|
|
||||||
# The following migrations have been applied:
|
|
||||||
# - 20251118035205_init
|
|
||||||
# - 20251118041000_add_comments
|
|
||||||
# - 20251118211424_change_log_content_to_text
|
|
||||||
```
|
|
||||||
|
|
||||||
### 直接查询数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 查看所有已应用的迁移
|
|
||||||
SELECT * FROM _prisma_migrations ORDER BY applied_at DESC;
|
|
||||||
|
|
||||||
-- 查看迁移名称和状态
|
|
||||||
SELECT migration_name, applied_at, finished_at
|
|
||||||
FROM _prisma_migrations
|
|
||||||
ORDER BY applied_at DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 实际使用场景
|
|
||||||
|
|
||||||
### 场景 1:生产环境部署
|
|
||||||
|
|
||||||
**情况**:生产数据库已经有部分迁移,现在要部署新版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 部署新代码(包含新的迁移文件)
|
|
||||||
|
|
||||||
# 2. 运行迁移(只会执行新增的迁移)
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# Prisma 会自动:
|
|
||||||
# - 检查 _prisma_migrations 表
|
|
||||||
# - 找出未应用的迁移(如:20251120000000_new_feature)
|
|
||||||
# - 只执行这个新迁移
|
|
||||||
# - 跳过已执行的迁移(如:20251118035205_init)
|
|
||||||
```
|
|
||||||
|
|
||||||
**结果**:
|
|
||||||
|
|
||||||
- ✅ 已执行的迁移不会重复执行
|
|
||||||
- ✅ 只执行新增的迁移
|
|
||||||
- ✅ 数据库结构同步到最新状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 场景 2:多环境同步
|
|
||||||
|
|
||||||
**情况**:开发环境有 3 个迁移,生产环境只有 2 个
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发环境迁移:
|
|
||||||
# - 20251118035205_init ✅
|
|
||||||
# - 20251118041000_add_comments ✅
|
|
||||||
# - 20251118211424_change_log_content_to_text ✅
|
|
||||||
|
|
||||||
# 生产环境迁移:
|
|
||||||
# - 20251118035205_init ✅
|
|
||||||
# - 20251118041000_add_comments ✅
|
|
||||||
# - 20251118211424_change_log_content_to_text ❌(未应用)
|
|
||||||
|
|
||||||
# 在生产环境运行:
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# Prisma 会:
|
|
||||||
# - 跳过前两个已应用的迁移
|
|
||||||
# - 只执行最后一个未应用的迁移
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 场景 3:回滚和修复
|
|
||||||
|
|
||||||
**情况**:某个迁移执行失败,需要修复
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 检查迁移状态
|
|
||||||
npx prisma migrate status
|
|
||||||
|
|
||||||
# 2. 如果迁移失败,_prisma_migrations 表中不会有记录
|
|
||||||
# 3. 修复迁移文件后,重新运行
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
|
|
||||||
# Prisma 会:
|
|
||||||
# - 检查失败的迁移是否已记录
|
|
||||||
# - 如果没有记录,会重新执行
|
|
||||||
# - 如果已记录,会跳过
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 1. 不要手动修改 `_prisma_migrations` 表
|
|
||||||
|
|
||||||
这个表由 Prisma 自动管理,手动修改可能导致迁移状态不一致。
|
|
||||||
|
|
||||||
### 2. 迁移文件不要删除
|
|
||||||
|
|
||||||
即使迁移已执行,也不要删除 `prisma/migrations` 目录中的迁移文件。这些文件是迁移历史的一部分。
|
|
||||||
|
|
||||||
### 3. 生产环境使用 `migrate deploy`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ 正确:生产环境
|
|
||||||
prisma migrate deploy
|
|
||||||
|
|
||||||
# ❌ 错误:生产环境不要使用
|
|
||||||
prisma migrate dev # 可能会重置数据库
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 迁移文件顺序很重要
|
|
||||||
|
|
||||||
Prisma 按照迁移文件名(时间戳)的顺序执行迁移。确保迁移文件名的时间戳顺序正确。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 故障排查
|
|
||||||
|
|
||||||
### 问题 1:迁移状态不一致
|
|
||||||
|
|
||||||
**症状**:`prisma migrate status` 显示状态不一致
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 检查 _prisma_migrations 表
|
|
||||||
SELECT * FROM _prisma_migrations;
|
|
||||||
|
|
||||||
# 2. 检查迁移文件
|
|
||||||
ls -la prisma/migrations/
|
|
||||||
|
|
||||||
# 3. 如果迁移文件存在但未记录,手动标记(谨慎操作)
|
|
||||||
# 或者重新运行迁移
|
|
||||||
prisma migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 2:迁移重复执行
|
|
||||||
|
|
||||||
**症状**:迁移被重复执行
|
|
||||||
|
|
||||||
**原因**:`_prisma_migrations` 表中没有记录
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查迁移记录
|
|
||||||
npx prisma migrate status
|
|
||||||
|
|
||||||
# 如果显示迁移未应用,但数据库结构已存在
|
|
||||||
# 可能需要手动标记迁移为已应用(谨慎操作)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题 3:迁移文件丢失
|
|
||||||
|
|
||||||
**症状**:迁移文件被删除,但数据库中有记录
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 从版本控制恢复迁移文件
|
|
||||||
git checkout prisma/migrations/
|
|
||||||
|
|
||||||
# 2. 重新运行迁移检查
|
|
||||||
npx prisma migrate status
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关命令速查
|
|
||||||
|
|
||||||
| 命令 | 说明 | 使用场景 |
|
|
||||||
| ----------------------- | ---------------------- | -------- |
|
|
||||||
| `prisma migrate deploy` | 只执行未应用的迁移 | 生产环境 |
|
|
||||||
| `prisma migrate dev` | 创建并执行迁移 | 开发环境 |
|
|
||||||
| `prisma migrate status` | 查看迁移状态 | 所有环境 |
|
|
||||||
| `prisma migrate reset` | 重置数据库(开发环境) | 开发环境 |
|
|
||||||
| `prisma db push` | 直接同步 schema | 快速原型 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 总结
|
|
||||||
|
|
||||||
**Prisma 迁移机制的核心特点**:
|
|
||||||
|
|
||||||
1. ✅ **自动增量执行**:只执行未应用的迁移
|
|
||||||
2. ✅ **状态跟踪**:通过 `_prisma_migrations` 表跟踪
|
|
||||||
3. ✅ **安全可靠**:不会重复执行已应用的迁移
|
|
||||||
4. ✅ **环境区分**:`migrate deploy` 用于生产,`migrate dev` 用于开发
|
|
||||||
|
|
||||||
**最佳实践**:
|
|
||||||
|
|
||||||
- 🎯 生产环境:使用 `prisma migrate deploy`
|
|
||||||
- 🎯 开发环境:使用 `prisma migrate dev`
|
|
||||||
- 🎯 定期检查:使用 `prisma migrate status` 查看状态
|
|
||||||
- 🎯 版本控制:提交所有迁移文件到 Git
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 相关文档
|
|
||||||
|
|
||||||
- [Prisma 官方迁移文档](https://www.prisma.io/docs/concepts/components/prisma-migrate)
|
|
||||||
- [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) - Schema 修改指南
|
|
||||||
- [DATABASE_SETUP.md](./DATABASE_SETUP.md) - 数据库设置指南
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
# 环境配置快速参考
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 1. 创建开发环境配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 创建开发环境配置文件
|
|
||||||
cat > .env.development << 'EOF'
|
|
||||||
NODE_ENV=development
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
|
||||||
JWT_SECRET="dev-secret-key"
|
|
||||||
PORT=3001
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 创建生产环境配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建生产环境配置文件(不要提交到 Git)
|
|
||||||
cat > .env.production << 'EOF'
|
|
||||||
NODE_ENV=production
|
|
||||||
DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
|
||||||
JWT_SECRET="$(openssl rand -hex 32)"
|
|
||||||
PORT=3001
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 创建数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 开发环境数据库
|
|
||||||
CREATE DATABASE competition_management_dev
|
|
||||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- 生产环境数据库
|
|
||||||
CREATE DATABASE competition_management
|
|
||||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 初始化数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发环境
|
|
||||||
pnpm prisma:generate
|
|
||||||
pnpm prisma:migrate
|
|
||||||
|
|
||||||
# 生产环境(部署时)
|
|
||||||
NODE_ENV=production pnpm prisma:migrate:deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 环境区分总结
|
|
||||||
|
|
||||||
| 项目 | 开发环境 | 生产环境 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| **配置文件** | `.env.development` | `.env.production` |
|
|
||||||
| **数据库名** | `competition_management_dev` | `competition_management` |
|
|
||||||
| **启动命令** | `pnpm start:dev` | `pnpm start:prod` |
|
|
||||||
| **迁移命令** | `pnpm prisma:migrate` | `pnpm prisma:migrate:deploy` |
|
|
||||||
| **Prisma Studio** | `pnpm prisma:studio:dev` | `pnpm prisma:studio:prod` |
|
|
||||||
| **日志级别** | `debug` | `error` |
|
|
||||||
| **CORS** | `*` (所有来源) | 指定域名 |
|
|
||||||
| **SSL** | 可选 | 必须启用 |
|
|
||||||
|
|
||||||
## 🔑 关键区别
|
|
||||||
|
|
||||||
### 开发环境
|
|
||||||
- ✅ 使用本地数据库
|
|
||||||
- ✅ 简单的 JWT 密钥(便于开发)
|
|
||||||
- ✅ 详细的日志输出
|
|
||||||
- ✅ 允许所有 CORS 来源
|
|
||||||
- ✅ 热重载支持
|
|
||||||
|
|
||||||
### 生产环境
|
|
||||||
- ✅ 独立的数据库服务器
|
|
||||||
- ✅ 强随机 JWT 密钥
|
|
||||||
- ✅ 最小化日志输出
|
|
||||||
- ✅ 限制 CORS 来源
|
|
||||||
- ✅ 启用 SSL/TLS
|
|
||||||
- ✅ 连接池优化
|
|
||||||
|
|
||||||
## 📝 配置文件示例
|
|
||||||
|
|
||||||
### `.env.development`
|
|
||||||
```env
|
|
||||||
NODE_ENV=development
|
|
||||||
DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public"
|
|
||||||
JWT_SECRET="dev-secret-key"
|
|
||||||
PORT=3001
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
CORS_ORIGIN=*
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.env.production`
|
|
||||||
```env
|
|
||||||
NODE_ENV=production
|
|
||||||
DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require"
|
|
||||||
JWT_SECRET="your-production-secret-key-must-be-strong-and-random"
|
|
||||||
PORT=3001
|
|
||||||
LOG_LEVEL=error
|
|
||||||
CORS_ORIGIN=https://yourdomain.com
|
|
||||||
SSL_ENABLED=true
|
|
||||||
DB_POOL_MIN=2
|
|
||||||
DB_POOL_MAX=10
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
1. **不要提交 `.env` 文件到 Git**
|
|
||||||
2. **生产环境必须使用强密码和 JWT_SECRET**
|
|
||||||
3. **生产环境建议启用 SSL 连接**
|
|
||||||
4. **定期备份生产数据库**
|
|
||||||
5. **使用不同的数据库名称区分环境**
|
|
||||||
|
|
||||||
## 🔍 验证配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查当前环境
|
|
||||||
echo $NODE_ENV
|
|
||||||
|
|
||||||
# 验证数据库连接(开发环境)
|
|
||||||
NODE_ENV=development pnpm prisma:studio
|
|
||||||
|
|
||||||
# 验证数据库连接(生产环境)
|
|
||||||
NODE_ENV=production pnpm prisma:studio:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
更多详细信息请查看 [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)
|
|
||||||
|
|
||||||
@ -1,444 +0,0 @@
|
|||||||
# RBAC 权限控制使用示例
|
|
||||||
|
|
||||||
## 📋 目录
|
|
||||||
1. [基础使用](#基础使用)
|
|
||||||
2. [角色控制示例](#角色控制示例)
|
|
||||||
3. [权限控制示例](#权限控制示例)
|
|
||||||
4. [完整示例](#完整示例)
|
|
||||||
|
|
||||||
## 🔧 基础使用
|
|
||||||
|
|
||||||
### 1. 创建权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在数据库中创建权限
|
|
||||||
const permissions = [
|
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户' },
|
|
||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户' },
|
|
||||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户' },
|
|
||||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户' },
|
|
||||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色' },
|
|
||||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const perm of permissions) {
|
|
||||||
await prisma.permission.create({ data: perm });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 创建角色并分配权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 创建管理员角色
|
|
||||||
const adminRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: '管理员',
|
|
||||||
code: 'admin',
|
|
||||||
permissions: {
|
|
||||||
create: [
|
|
||||||
{ permission: { connect: { code: 'user:create' } } },
|
|
||||||
{ permission: { connect: { code: 'user:read' } } },
|
|
||||||
{ permission: { connect: { code: 'user:update' } } },
|
|
||||||
{ permission: { connect: { code: 'user:delete' } } },
|
|
||||||
{ permission: { connect: { code: 'role:create' } } },
|
|
||||||
{ permission: { connect: { code: 'role:read' } } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建编辑角色(只有查看和更新权限)
|
|
||||||
const editorRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: '编辑',
|
|
||||||
code: 'editor',
|
|
||||||
permissions: {
|
|
||||||
create: [
|
|
||||||
{ permission: { connect: { code: 'user:read' } } },
|
|
||||||
{ permission: { connect: { code: 'user:update' } } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 给用户分配角色
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 给用户分配管理员角色
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
user: { connect: { id: 1 } },
|
|
||||||
role: { connect: { code: 'admin' } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 用户可以有多个角色
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
user: { connect: { id: 1 } },
|
|
||||||
role: { connect: { code: 'editor' } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 角色控制示例
|
|
||||||
|
|
||||||
### 在控制器中使用角色装饰器
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common';
|
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@Controller('users')
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard) // 先验证 JWT,再验证角色
|
|
||||||
export class UsersController {
|
|
||||||
|
|
||||||
// 所有已登录用户都可以查看
|
|
||||||
@Get()
|
|
||||||
findAll() {
|
|
||||||
return this.usersService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有管理员和编辑可以创建用户
|
|
||||||
@Post()
|
|
||||||
@Roles('admin', 'editor')
|
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
|
||||||
return this.usersService.create(createUserDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有管理员可以删除用户
|
|
||||||
@Delete(':id')
|
|
||||||
@Roles('admin')
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.usersService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 权限控制示例
|
|
||||||
|
|
||||||
### 创建权限守卫(可选扩展)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/auth/guards/permissions.guard.ts
|
|
||||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PermissionsGuard implements CanActivate {
|
|
||||||
constructor(private reflector: Reflector) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
|
||||||
'permissions',
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!requiredPermissions) {
|
|
||||||
return true; // 没有权限要求,允许访问
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = context.switchToHttp().getRequest();
|
|
||||||
const userPermissions = user.permissions || [];
|
|
||||||
|
|
||||||
// 检查用户是否拥有任一所需权限
|
|
||||||
return requiredPermissions.some((permission) =>
|
|
||||||
userPermissions.includes(permission),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 创建权限装饰器
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/auth/decorators/permissions.decorator.ts
|
|
||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const PERMISSIONS_KEY = 'permissions';
|
|
||||||
export const Permissions = (...permissions: string[]) =>
|
|
||||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用权限控制
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common';
|
|
||||||
import { Permissions } from '../auth/decorators/permissions.decorator';
|
|
||||||
import { PermissionsGuard } from '../auth/guards/permissions.guard';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@Controller('users')
|
|
||||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
|
||||||
export class UsersController {
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@Permissions('user:read') // 需要 user:read 权限
|
|
||||||
findAll() {
|
|
||||||
return this.usersService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@Permissions('user:create') // 需要 user:create 权限
|
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
|
||||||
return this.usersService.create(createUserDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@Permissions('user:delete') // 需要 user:delete 权限
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.usersService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 完整示例
|
|
||||||
|
|
||||||
### 完整的用户管理控制器
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Patch,
|
|
||||||
Param,
|
|
||||||
Delete,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
|
||||||
|
|
||||||
@Controller('users')
|
|
||||||
@UseGuards(JwtAuthGuard) // 所有接口都需要登录
|
|
||||||
export class UsersController {
|
|
||||||
constructor(private readonly usersService: UsersService) {}
|
|
||||||
|
|
||||||
// 查看用户列表 - 所有已登录用户都可以访问
|
|
||||||
@Get()
|
|
||||||
findAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
|
||||||
return this.usersService.findAll(
|
|
||||||
page ? parseInt(page) : 1,
|
|
||||||
pageSize ? parseInt(pageSize) : 10,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看用户详情 - 所有已登录用户都可以访问
|
|
||||||
@Get(':id')
|
|
||||||
findOne(@Param('id') id: string) {
|
|
||||||
return this.usersService.findOne(+id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建用户 - 需要 admin 或 editor 角色
|
|
||||||
@Post()
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
@Roles('admin', 'editor')
|
|
||||||
create(@Body() createUserDto: CreateUserDto, @Request() req) {
|
|
||||||
// req.user 包含当前用户信息(从 JWT 中提取)
|
|
||||||
return this.usersService.create(createUserDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户 - 需要 admin 角色,或者用户自己更新自己
|
|
||||||
@Patch(':id')
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
async update(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() updateUserDto: UpdateUserDto,
|
|
||||||
@Request() req,
|
|
||||||
) {
|
|
||||||
const userId = parseInt(id);
|
|
||||||
const currentUserId = req.user.userId;
|
|
||||||
|
|
||||||
// 管理员可以更新任何人,普通用户只能更新自己
|
|
||||||
if (req.user.roles?.includes('admin') || userId === currentUserId) {
|
|
||||||
return this.usersService.update(userId, updateUserDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ForbiddenException('无权更新此用户');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除用户 - 只有管理员可以删除
|
|
||||||
@Delete(':id')
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
@Roles('admin')
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.usersService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 权限检查流程
|
|
||||||
|
|
||||||
### 1. 用户登录
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// POST /api/auth/login
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "admin",
|
|
||||||
"nickname": "管理员",
|
|
||||||
"roles": ["admin"], // 用户的角色列表
|
|
||||||
"permissions": [ // 用户的所有权限(从角色中聚合)
|
|
||||||
"user:create",
|
|
||||||
"user:read",
|
|
||||||
"user:update",
|
|
||||||
"user:delete",
|
|
||||||
"role:create",
|
|
||||||
"role:read"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 访问受保护的接口
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 请求头
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
|
|
||||||
// 流程
|
|
||||||
1. JwtAuthGuard 验证 Token
|
|
||||||
└─> 提取用户信息,添加到 req.user
|
|
||||||
|
|
||||||
2. RolesGuard 检查角色
|
|
||||||
└─> 从 req.user.roles 中检查是否包含所需角色
|
|
||||||
└─> 如果包含,允许访问;否则返回 403 Forbidden
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 前端权限控制示例
|
|
||||||
|
|
||||||
### Vue 3 中使用权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// stores/auth.ts
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const user = ref<User | null>(null);
|
|
||||||
|
|
||||||
// 检查是否有指定角色
|
|
||||||
const hasRole = (role: string) => {
|
|
||||||
return user.value?.roles?.includes(role) ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否有指定权限
|
|
||||||
const hasPermission = (permission: string) => {
|
|
||||||
return user.value?.permissions?.includes(permission) ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否有任一角色
|
|
||||||
const hasAnyRole = (roles: string[]) => {
|
|
||||||
return roles.some(role => hasRole(role));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否有任一权限
|
|
||||||
const hasAnyPermission = (permissions: string[]) => {
|
|
||||||
return permissions.some(perm => hasPermission(perm));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
hasRole,
|
|
||||||
hasPermission,
|
|
||||||
hasAnyRole,
|
|
||||||
hasAnyPermission,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 在组件中使用
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- 根据角色显示按钮 -->
|
|
||||||
<a-button v-if="authStore.hasRole('admin')" @click="deleteUser">
|
|
||||||
删除用户
|
|
||||||
</a-button>
|
|
||||||
|
|
||||||
<!-- 根据权限显示按钮 -->
|
|
||||||
<a-button v-if="authStore.hasPermission('user:create')" @click="createUser">
|
|
||||||
创建用户
|
|
||||||
</a-button>
|
|
||||||
|
|
||||||
<!-- 根据角色或权限显示 -->
|
|
||||||
<a-button
|
|
||||||
v-if="authStore.hasAnyRole(['admin', 'editor']) || authStore.hasPermission('user:update')"
|
|
||||||
@click="editUser"
|
|
||||||
>
|
|
||||||
编辑用户
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 路由守卫
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// router/index.ts
|
|
||||||
router.beforeEach((to, from, next) => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// 检查是否需要认证
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
|
||||||
next({ name: 'Login' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查角色
|
|
||||||
if (to.meta.roles && !authStore.hasAnyRole(to.meta.roles)) {
|
|
||||||
next({ name: 'Forbidden' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查权限
|
|
||||||
if (to.meta.permissions && !authStore.hasAnyPermission(to.meta.permissions)) {
|
|
||||||
next({ name: 'Forbidden' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 权限矩阵示例
|
|
||||||
|
|
||||||
| 角色 | user:create | user:read | user:update | user:delete | role:create | role:read |
|
|
||||||
|------|-------------|-----------|-------------|------------|-------------|-----------|
|
|
||||||
| admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| editor | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
|
||||||
| viewer | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
||||||
|
|
||||||
## 🎯 总结
|
|
||||||
|
|
||||||
RBAC 权限控制的核心是:
|
|
||||||
|
|
||||||
1. **用户** ←→ **角色** ←→ **权限**
|
|
||||||
2. 通过 `@Roles()` 装饰器控制接口访问
|
|
||||||
3. 前端根据返回的 `roles` 和 `permissions` 控制 UI 显示
|
|
||||||
4. 权限由 `resource:action` 组成,如 `user:create`
|
|
||||||
|
|
||||||
这样的设计既保证了安全性,又提供了良好的灵活性和可维护性!
|
|
||||||
|
|
||||||
@ -1,397 +0,0 @@
|
|||||||
# RBAC 权限控制详解
|
|
||||||
|
|
||||||
## 📚 什么是 RBAC?
|
|
||||||
|
|
||||||
**RBAC(Role-Based Access Control)** 即**基于角色的访问控制**,是一种权限管理模型。它的核心思想是:
|
|
||||||
|
|
||||||
> **用户 → 角色 → 权限**
|
|
||||||
|
|
||||||
通过给用户分配角色,角色拥有权限,从而间接地给用户授予权限。
|
|
||||||
|
|
||||||
## 🎯 RBAC 的核心概念
|
|
||||||
|
|
||||||
### 1. **用户(User)**
|
|
||||||
|
|
||||||
系统中的实际使用者,如:张三、李四
|
|
||||||
|
|
||||||
### 2. **角色(Role)**
|
|
||||||
|
|
||||||
一组权限的集合,如:管理员、编辑、访客
|
|
||||||
|
|
||||||
### 3. **权限(Permission)**
|
|
||||||
|
|
||||||
对资源的操作能力,如:创建用户、删除文章、查看报表
|
|
||||||
|
|
||||||
### 4. **资源(Resource)**
|
|
||||||
|
|
||||||
系统中的实体对象,如:用户、文章、订单
|
|
||||||
|
|
||||||
### 5. **操作(Action)**
|
|
||||||
|
|
||||||
对资源的操作类型,如:create(创建)、read(查看)、update(更新)、delete(删除)
|
|
||||||
|
|
||||||
## 🏗️ 项目中的 RBAC 架构
|
|
||||||
|
|
||||||
### 数据模型关系
|
|
||||||
|
|
||||||
```
|
|
||||||
User (用户)
|
|
||||||
↓ (多对多)
|
|
||||||
UserRole (用户角色关联)
|
|
||||||
↓
|
|
||||||
Role (角色)
|
|
||||||
↓ (多对多)
|
|
||||||
RolePermission (角色权限关联)
|
|
||||||
↓
|
|
||||||
Permission (权限)
|
|
||||||
├─ resource: 资源名称 (如: user, role, menu)
|
|
||||||
└─ action: 操作类型 (如: create, read, update, delete)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库表结构
|
|
||||||
|
|
||||||
#### 1. **users** - 用户表
|
|
||||||
|
|
||||||
存储系统用户的基本信息
|
|
||||||
|
|
||||||
#### 2. **roles** - 角色表
|
|
||||||
|
|
||||||
存储角色信息,如:
|
|
||||||
|
|
||||||
- `admin` - 管理员
|
|
||||||
- `editor` - 编辑
|
|
||||||
- `viewer` - 查看者
|
|
||||||
|
|
||||||
#### 3. **permissions** - 权限表
|
|
||||||
|
|
||||||
存储权限信息,权限由 `resource` + `action` 组成,如:
|
|
||||||
|
|
||||||
- `user:create` - 创建用户
|
|
||||||
- `user:read` - 查看用户
|
|
||||||
- `user:update` - 更新用户
|
|
||||||
- `user:delete` - 删除用户
|
|
||||||
- `role:create` - 创建角色
|
|
||||||
- `menu:read` - 查看菜单
|
|
||||||
|
|
||||||
#### 4. **user_roles** - 用户角色关联表
|
|
||||||
|
|
||||||
用户和角色的多对多关系
|
|
||||||
|
|
||||||
#### 5. **role_permissions** - 角色权限关联表
|
|
||||||
|
|
||||||
角色和权限的多对多关系
|
|
||||||
|
|
||||||
## 🔄 RBAC 工作流程
|
|
||||||
|
|
||||||
### 1. **权限分配流程**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 创建权限
|
|
||||||
└─> 定义资源(resource)和操作(action)
|
|
||||||
└─> 例如:user:create, user:read
|
|
||||||
|
|
||||||
2. 创建角色
|
|
||||||
└─> 给角色分配权限
|
|
||||||
└─> 例如:管理员角色 = [user:create, user:read, user:update, user:delete]
|
|
||||||
|
|
||||||
3. 给用户分配角色
|
|
||||||
└─> 用户继承角色的所有权限
|
|
||||||
└─> 例如:张三 = 管理员角色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **权限验证流程**
|
|
||||||
|
|
||||||
```
|
|
||||||
用户请求 API
|
|
||||||
↓
|
|
||||||
JWT 认证(验证用户身份)
|
|
||||||
↓
|
|
||||||
提取用户信息(包含 roles 和 permissions)
|
|
||||||
↓
|
|
||||||
RolesGuard 检查(检查用户是否有指定角色)
|
|
||||||
↓
|
|
||||||
PermissionGuard 检查(检查用户是否有指定权限)
|
|
||||||
↓
|
|
||||||
允许/拒绝访问
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💻 代码实现示例
|
|
||||||
|
|
||||||
### 1. **定义权限**
|
|
||||||
|
|
||||||
权限由 `resource` + `action` 组成:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 权限示例
|
|
||||||
{
|
|
||||||
code: 'user:create', // 权限编码
|
|
||||||
resource: 'user', // 资源:用户
|
|
||||||
action: 'create', // 操作:创建
|
|
||||||
name: '创建用户',
|
|
||||||
description: '允许创建新用户'
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
code: 'user:read',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看用户',
|
|
||||||
description: '允许查看用户列表和详情'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **创建角色并分配权限**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 创建管理员角色
|
|
||||||
const adminRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: '管理员',
|
|
||||||
code: 'admin',
|
|
||||||
permissions: {
|
|
||||||
create: [
|
|
||||||
{ permission: { connect: { code: 'user:create' } } },
|
|
||||||
{ permission: { connect: { code: 'user:read' } } },
|
|
||||||
{ permission: { connect: { code: 'user:update' } } },
|
|
||||||
{ permission: { connect: { code: 'user:delete' } } },
|
|
||||||
{ permission: { connect: { code: 'role:create' } } },
|
|
||||||
// ... 更多权限
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **给用户分配角色**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 给用户分配管理员角色
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
user: { connect: { id: userId } },
|
|
||||||
role: { connect: { code: 'admin' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **在控制器中使用权限控制**
|
|
||||||
|
|
||||||
#### 方式一:使用角色装饰器
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
|
||||||
|
|
||||||
@Controller('users')
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
export class UsersController {
|
|
||||||
@Get()
|
|
||||||
@Roles('admin', 'editor') // 需要 admin 或 editor 角色
|
|
||||||
findAll() {
|
|
||||||
// 只有拥有 admin 或 editor 角色的用户才能访问
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@Roles('admin') // 只有 admin 角色可以删除
|
|
||||||
remove() {
|
|
||||||
// 只有管理员可以删除用户
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 方式二:使用权限装饰器(可扩展)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 可以创建 PermissionGuard 和 @Permissions() 装饰器
|
|
||||||
@Get()
|
|
||||||
@Permissions('user:read') // 需要 user:read 权限
|
|
||||||
findAll() {
|
|
||||||
// 只有拥有 user:read 权限的用户才能访问
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **获取用户权限**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在 AuthService 中
|
|
||||||
private async getUserPermissions(userId: number): Promise<string[]> {
|
|
||||||
const user = await this.usersService.findOne(userId);
|
|
||||||
if (!user) return [];
|
|
||||||
|
|
||||||
const permissions = new Set<string>();
|
|
||||||
|
|
||||||
// 遍历用户的所有角色
|
|
||||||
user.roles?.forEach((ur: any) => {
|
|
||||||
// 遍历角色的所有权限
|
|
||||||
ur.role.permissions?.forEach((rp: any) => {
|
|
||||||
permissions.add(rp.permission.code);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(permissions);
|
|
||||||
// 返回: ['user:create', 'user:read', 'user:update', 'role:create', ...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 RBAC 的优势
|
|
||||||
|
|
||||||
### 1. **灵活性**
|
|
||||||
|
|
||||||
- ✅ 一个用户可以有多个角色
|
|
||||||
- ✅ 一个角色可以有多个权限
|
|
||||||
- ✅ 权限可以动态分配和回收
|
|
||||||
|
|
||||||
### 2. **可维护性**
|
|
||||||
|
|
||||||
- ✅ 权限变更只需修改角色,不需要逐个修改用户
|
|
||||||
- ✅ 角色可以复用,减少重复配置
|
|
||||||
|
|
||||||
### 3. **可扩展性**
|
|
||||||
|
|
||||||
- ✅ 新增资源只需添加新的权限
|
|
||||||
- ✅ 新增角色只需组合现有权限
|
|
||||||
|
|
||||||
### 4. **安全性**
|
|
||||||
|
|
||||||
- ✅ 最小权限原则:用户只获得必要的权限
|
|
||||||
- ✅ 权限集中管理,便于审计
|
|
||||||
|
|
||||||
## 🎨 实际应用场景
|
|
||||||
|
|
||||||
### 场景 1:内容管理系统
|
|
||||||
|
|
||||||
```
|
|
||||||
角色定义:
|
|
||||||
- 超级管理员:所有权限
|
|
||||||
- 内容管理员:文章 CRUD、评论管理
|
|
||||||
- 编辑:文章创建、编辑
|
|
||||||
- 作者:文章创建
|
|
||||||
- 访客:文章查看
|
|
||||||
|
|
||||||
权限示例:
|
|
||||||
- article:create
|
|
||||||
- article:read
|
|
||||||
- article:update
|
|
||||||
- article:delete
|
|
||||||
- comment:moderate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 场景 2:电商系统
|
|
||||||
|
|
||||||
```
|
|
||||||
角色定义:
|
|
||||||
- 平台管理员:所有权限
|
|
||||||
- 店铺管理员:店铺管理、订单管理
|
|
||||||
- 客服:订单查看、退款处理
|
|
||||||
- 财务:订单查看、财务报表
|
|
||||||
|
|
||||||
权限示例:
|
|
||||||
- order:create
|
|
||||||
- order:read
|
|
||||||
- order:update
|
|
||||||
- order:refund
|
|
||||||
- report:financial
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 项目中的权限控制实现
|
|
||||||
|
|
||||||
### 1. **JWT 认证**
|
|
||||||
|
|
||||||
用户登录后获得 JWT Token,Token 中包含用户 ID
|
|
||||||
|
|
||||||
### 2. **JwtAuthGuard**
|
|
||||||
|
|
||||||
验证 JWT Token,提取用户信息
|
|
||||||
|
|
||||||
### 3. **RolesGuard**
|
|
||||||
|
|
||||||
检查用户是否拥有指定的角色
|
|
||||||
|
|
||||||
### 4. **权限获取**
|
|
||||||
|
|
||||||
登录时,系统会:
|
|
||||||
|
|
||||||
1. 查询用户的所有角色
|
|
||||||
2. 查询角色关联的所有权限
|
|
||||||
3. 合并所有权限并返回给前端
|
|
||||||
|
|
||||||
### 5. **前端权限控制**
|
|
||||||
|
|
||||||
前端可以根据返回的 `roles` 和 `permissions` 数组:
|
|
||||||
|
|
||||||
- 控制菜单显示
|
|
||||||
- 控制按钮显示
|
|
||||||
- 控制路由访问
|
|
||||||
|
|
||||||
## 📝 最佳实践
|
|
||||||
|
|
||||||
### 1. **权限命名规范**
|
|
||||||
|
|
||||||
```
|
|
||||||
格式:resource:action
|
|
||||||
示例:
|
|
||||||
- user:create
|
|
||||||
- user:read
|
|
||||||
- user:update
|
|
||||||
- user:delete
|
|
||||||
- role:assign
|
|
||||||
- menu:manage
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **角色命名规范**
|
|
||||||
|
|
||||||
```
|
|
||||||
使用有意义的英文代码:
|
|
||||||
- admin: 管理员
|
|
||||||
- editor: 编辑
|
|
||||||
- viewer: 查看者
|
|
||||||
- guest: 访客
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **权限粒度**
|
|
||||||
|
|
||||||
- ✅ 不要过粗:避免一个权限包含太多操作
|
|
||||||
- ✅ 不要过细:避免权限过多难以管理
|
|
||||||
- ✅ 按业务模块划分:user、role、menu、dict 等
|
|
||||||
|
|
||||||
### 4. **默认角色**
|
|
||||||
|
|
||||||
建议创建以下默认角色:
|
|
||||||
|
|
||||||
- **超级管理员**:拥有所有权限
|
|
||||||
- **普通用户**:基础查看权限
|
|
||||||
- **访客**:只读权限
|
|
||||||
|
|
||||||
## 🚀 扩展功能
|
|
||||||
|
|
||||||
### 1. **权限继承**
|
|
||||||
|
|
||||||
可以实现角色继承,子角色继承父角色的权限
|
|
||||||
|
|
||||||
### 2. **动态权限**
|
|
||||||
|
|
||||||
可以根据数据范围动态控制权限,如:
|
|
||||||
|
|
||||||
- 用户只能管理自己创建的订单
|
|
||||||
- 部门管理员只能管理本部门的用户
|
|
||||||
|
|
||||||
### 3. **权限缓存**
|
|
||||||
|
|
||||||
将用户权限缓存到 Redis,提高性能
|
|
||||||
|
|
||||||
### 4. **权限审计**
|
|
||||||
|
|
||||||
记录权限变更日志,便于追溯
|
|
||||||
|
|
||||||
## 📖 总结
|
|
||||||
|
|
||||||
RBAC 权限控制通过 **用户 → 角色 → 权限** 的三层关系,实现了灵活、可维护的权限管理系统。在你的项目中:
|
|
||||||
|
|
||||||
1. ✅ **用户** 通过 `user_roles` 表关联 **角色**
|
|
||||||
2. ✅ **角色** 通过 `role_permissions` 表关联 **权限**
|
|
||||||
3. ✅ **权限** 由 `resource` + `action` 组成
|
|
||||||
4. ✅ 使用 `@Roles()` 装饰器控制接口访问
|
|
||||||
5. ✅ 登录时返回用户的角色和权限列表
|
|
||||||
|
|
||||||
这样的设计既保证了安全性,又提供了良好的扩展性和可维护性!
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
# 项目文档索引
|
|
||||||
|
|
||||||
本目录包含项目后端的所有指南和文档。
|
|
||||||
|
|
||||||
## 📚 文档分类
|
|
||||||
|
|
||||||
### 🚀 快速开始
|
|
||||||
|
|
||||||
- **[QUICK_START_ENV.md](./QUICK_START_ENV.md)** - 环境配置快速参考
|
|
||||||
- 快速创建开发和生产环境配置
|
|
||||||
- 环境区分总结表
|
|
||||||
- 关键区别说明
|
|
||||||
|
|
||||||
### 🗄️ 数据库相关
|
|
||||||
|
|
||||||
- **[DATABASE_SETUP.md](./DATABASE_SETUP.md)** - 数据库配置指南
|
|
||||||
- 创建数据库
|
|
||||||
- DATABASE_URL 格式说明
|
|
||||||
- 初始化数据库步骤
|
|
||||||
- 验证连接方法
|
|
||||||
|
|
||||||
- **[DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md)** - DATABASE_URL 来源说明
|
|
||||||
- DATABASE_URL 的定义位置
|
|
||||||
- 加载流程详解
|
|
||||||
- 配置文件优先级
|
|
||||||
- 验证方法
|
|
||||||
|
|
||||||
- **[SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)** - Prisma Schema 修改指南
|
|
||||||
- 修改 schema.prisma 后的操作步骤
|
|
||||||
- 生成 Prisma Client
|
|
||||||
- 应用数据库迁移
|
|
||||||
- 验证迁移是否成功
|
|
||||||
|
|
||||||
- **[ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md)** - 修改 DATABASE_URL 后的操作指南
|
|
||||||
- 操作决策树
|
|
||||||
- 不同场景的处理方法
|
|
||||||
- 完整操作流程
|
|
||||||
- 常见错误解决
|
|
||||||
|
|
||||||
### ⚙️ 环境配置
|
|
||||||
|
|
||||||
- **[ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)** - 环境配置指南
|
|
||||||
- 环境区分方案
|
|
||||||
- 配置文件结构
|
|
||||||
- 配置优先级
|
|
||||||
- 开发/生产环境配置示例
|
|
||||||
- 安全注意事项
|
|
||||||
|
|
||||||
### 🔐 权限管理
|
|
||||||
|
|
||||||
- **[RBAC_GUIDE.md](./RBAC_GUIDE.md)** - RBAC 权限系统指南
|
|
||||||
- 权限系统架构
|
|
||||||
- 权限模型说明
|
|
||||||
- 使用示例
|
|
||||||
- 最佳实践
|
|
||||||
|
|
||||||
- **[RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md)** - RBAC 使用示例
|
|
||||||
- 完整的权限配置示例
|
|
||||||
- 常见场景实现
|
|
||||||
- 代码示例
|
|
||||||
|
|
||||||
### 👤 账户管理
|
|
||||||
|
|
||||||
- **[ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md)** - 管理员账户指南
|
|
||||||
- 初始化管理员账户
|
|
||||||
- 验证管理员账户
|
|
||||||
- 账户管理说明
|
|
||||||
|
|
||||||
## 📖 文档使用建议
|
|
||||||
|
|
||||||
### 新项目设置流程
|
|
||||||
|
|
||||||
1. **环境配置** → [QUICK_START_ENV.md](./QUICK_START_ENV.md)
|
|
||||||
2. **数据库设置** → [DATABASE_SETUP.md](./DATABASE_SETUP.md)
|
|
||||||
3. **初始化管理员** → [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md)
|
|
||||||
4. **权限配置** → [RBAC_GUIDE.md](./RBAC_GUIDE.md)
|
|
||||||
|
|
||||||
### 日常开发流程
|
|
||||||
|
|
||||||
- **修改数据库结构** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)
|
|
||||||
- **修改环境变量** → [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md)
|
|
||||||
- **配置权限** → [RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md)
|
|
||||||
|
|
||||||
### 问题排查
|
|
||||||
|
|
||||||
- **数据库连接问题** → [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md)
|
|
||||||
- **环境配置问题** → [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)
|
|
||||||
- **迁移问题** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)
|
|
||||||
|
|
||||||
## 🔍 快速查找
|
|
||||||
|
|
||||||
| 需求 | 文档 |
|
|
||||||
|------|------|
|
|
||||||
| 如何设置开发环境? | [QUICK_START_ENV.md](./QUICK_START_ENV.md) |
|
|
||||||
| 如何配置数据库? | [DATABASE_SETUP.md](./DATABASE_SETUP.md) |
|
|
||||||
| DATABASE_URL 从哪里来? | [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md) |
|
|
||||||
| 修改 schema 后做什么? | [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) |
|
|
||||||
| 修改环境变量后做什么? | [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md) |
|
|
||||||
| 如何配置权限? | [RBAC_GUIDE.md](./RBAC_GUIDE.md) |
|
|
||||||
| 如何创建管理员? | [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md) |
|
|
||||||
|
|
||||||
## 📝 文档更新记录
|
|
||||||
|
|
||||||
- 2024-11-19: 创建文档索引,归档所有指南文件
|
|
||||||
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
# Prisma Schema 修改后的操作指南
|
|
||||||
|
|
||||||
## 修改 schema.prisma 后需要执行的步骤
|
|
||||||
|
|
||||||
### 1. 生成 Prisma Client(必须)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npx prisma generate
|
|
||||||
# 或使用 npm script
|
|
||||||
npm run prisma:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
**作用**:根据最新的 schema 重新生成 Prisma Client,使 TypeScript 类型和代码与数据库结构同步。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 应用数据库迁移(必须)
|
|
||||||
|
|
||||||
根据环境选择不同的方式:
|
|
||||||
|
|
||||||
#### 开发环境(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npx prisma migrate dev
|
|
||||||
# 或使用 npm script
|
|
||||||
npm run prisma:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
**作用**:
|
|
||||||
|
|
||||||
- 应用待执行的迁移到数据库
|
|
||||||
- 如果有新的迁移,会自动创建并应用
|
|
||||||
- 会重置开发数据库(如果使用 shadow database)
|
|
||||||
|
|
||||||
#### 生产环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npx prisma migrate deploy
|
|
||||||
# 或使用 npm script
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
**作用**:
|
|
||||||
|
|
||||||
- 仅应用待执行的迁移,不会创建新迁移
|
|
||||||
- 不会重置数据库
|
|
||||||
- 适合生产环境使用
|
|
||||||
|
|
||||||
#### 快速同步(仅开发环境,不推荐用于生产)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npx prisma db push
|
|
||||||
```
|
|
||||||
|
|
||||||
**作用**:
|
|
||||||
|
|
||||||
- 直接将 schema 变更推送到数据库
|
|
||||||
- 不创建迁移文件
|
|
||||||
- 适合快速原型开发
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 重启应用(如果正在运行)
|
|
||||||
|
|
||||||
应用迁移后,需要重启 NestJS 应用以加载新的 Prisma Client:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 如果使用 npm run start:dev,会自动重启
|
|
||||||
# 如果使用其他方式启动,需要手动重启
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 当前状态
|
|
||||||
|
|
||||||
✅ **已完成**:
|
|
||||||
|
|
||||||
- schema.prisma 已修改(content 字段改为 TEXT)
|
|
||||||
- 迁移文件已创建:`20251118211424_change_log_content_to_text`
|
|
||||||
|
|
||||||
⏳ **待执行**:
|
|
||||||
|
|
||||||
1. 生成 Prisma Client
|
|
||||||
2. 应用数据库迁移
|
|
||||||
3. 重启应用(如果正在运行)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行顺序
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 生成 Prisma Client
|
|
||||||
cd backend
|
|
||||||
npx prisma generate
|
|
||||||
|
|
||||||
# 2. 应用迁移(开发环境)
|
|
||||||
npx prisma migrate dev
|
|
||||||
# 或生产环境
|
|
||||||
npx prisma migrate deploy
|
|
||||||
|
|
||||||
# 3. 重启应用(如果需要)
|
|
||||||
# 如果使用 start:dev,会自动重启
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证迁移是否成功
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查迁移状态
|
|
||||||
npx prisma migrate status
|
|
||||||
|
|
||||||
# 查看数据库结构
|
|
||||||
npx prisma studio
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **生产环境**:务必使用 `prisma migrate deploy`,不要使用 `prisma migrate dev`
|
|
||||||
2. **备份数据**:在生产环境应用迁移前,建议先备份数据库
|
|
||||||
3. **迁移冲突**:如果迁移失败,检查错误信息并解决后再继续
|
|
||||||
4. **类型同步**:每次修改 schema 后都要运行 `prisma generate` 更新类型
|
|
||||||
@ -1,301 +0,0 @@
|
|||||||
# 学校模块数据库设计文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本文档描述了学校管理系统的数据库表设计,包括学校信息、年级、班级、部门、教师和学生等核心实体。
|
|
||||||
|
|
||||||
## 设计原则
|
|
||||||
|
|
||||||
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,270 +0,0 @@
|
|||||||
# 多租户系统实现指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本系统实现了完整的多租户架构,支持:
|
|
||||||
- 每个租户独立的数据隔离(用户、角色、权限、菜单等)
|
|
||||||
- 每个租户独立的访问链接(通过租户编码或域名)
|
|
||||||
- 超级租户可以创建和管理其他租户
|
|
||||||
- 超级租户可以为租户分配菜单
|
|
||||||
|
|
||||||
## 数据库设计
|
|
||||||
|
|
||||||
### 核心表结构
|
|
||||||
|
|
||||||
1. **Tenant(租户表)**
|
|
||||||
- `id`: 租户ID
|
|
||||||
- `name`: 租户名称
|
|
||||||
- `code`: 租户编码(唯一,用于访问链接)
|
|
||||||
- `domain`: 租户域名(可选,用于子域名访问)
|
|
||||||
- `isSuper`: 是否为超级租户(0-否,1-是)
|
|
||||||
- `validState`: 有效状态(1-有效,2-失效)
|
|
||||||
|
|
||||||
2. **TenantMenu(租户菜单关联表)**
|
|
||||||
- `tenantId`: 租户ID
|
|
||||||
- `menuId`: 菜单ID
|
|
||||||
- 用于关联租户和菜单,实现菜单分配
|
|
||||||
|
|
||||||
3. **其他表添加租户字段**
|
|
||||||
- `User`: 添加 `tenantId` 字段
|
|
||||||
- `Role`: 添加 `tenantId` 字段
|
|
||||||
- `Permission`: 添加 `tenantId` 字段
|
|
||||||
- `Dict`: 添加 `tenantId` 字段
|
|
||||||
- `Config`: 添加 `tenantId` 字段
|
|
||||||
|
|
||||||
### 唯一性约束调整
|
|
||||||
|
|
||||||
- `User.username`: 从全局唯一改为 `(tenantId, username)` 唯一
|
|
||||||
- `User.email`: 从全局唯一改为 `(tenantId, email)` 唯一
|
|
||||||
- `Role.name/code`: 从全局唯一改为 `(tenantId, name/code)` 唯一
|
|
||||||
- `Permission.code`: 从全局唯一改为 `(tenantId, code)` 唯一
|
|
||||||
- 其他类似字段也做了相应调整
|
|
||||||
|
|
||||||
## 租户识别机制
|
|
||||||
|
|
||||||
系统支持多种方式识别租户:
|
|
||||||
|
|
||||||
1. **请求头方式**(推荐)
|
|
||||||
- `X-Tenant-Code`: 租户编码
|
|
||||||
- `X-Tenant-Id`: 租户ID
|
|
||||||
|
|
||||||
2. **子域名方式**
|
|
||||||
- 从 `Host` 请求头提取子域名
|
|
||||||
- 匹配租户的 `code` 或 `domain` 字段
|
|
||||||
|
|
||||||
3. **JWT Token方式**
|
|
||||||
- Token中包含 `tenantId` 字段
|
|
||||||
- 登录时自动关联租户
|
|
||||||
|
|
||||||
4. **登录参数方式**
|
|
||||||
- 登录接口支持 `tenantCode` 参数
|
|
||||||
|
|
||||||
## 使用流程
|
|
||||||
|
|
||||||
### 1. 数据库迁移
|
|
||||||
|
|
||||||
首先需要生成并执行数据库迁移:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生成迁移文件
|
|
||||||
npm run prisma:migrate:dev -- --name add_tenant_support
|
|
||||||
|
|
||||||
# 执行迁移
|
|
||||||
npm run prisma:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 初始化超级租户
|
|
||||||
|
|
||||||
运行初始化脚本创建超级租户:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run init:super-tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
这将创建:
|
|
||||||
- 超级租户(code: `super`)
|
|
||||||
- 超级管理员用户(username: `admin`, password: `admin123`)
|
|
||||||
- 超级管理员角色
|
|
||||||
- 基础权限
|
|
||||||
|
|
||||||
### 3. 创建普通租户
|
|
||||||
|
|
||||||
使用超级租户的管理员账号登录后,通过租户管理接口创建新租户:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/tenants
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
X-Tenant-Code: super
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"name": "租户A",
|
|
||||||
"code": "tenant-a",
|
|
||||||
"domain": "tenant-a.example.com",
|
|
||||||
"description": "租户A的描述",
|
|
||||||
"menuIds": [1, 2, 3] // 分配的菜单ID列表
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 为租户分配菜单
|
|
||||||
|
|
||||||
超级租户可以为租户分配菜单:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PATCH /api/tenants/:id
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
X-Tenant-Code: super
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"menuIds": [1, 2, 3, 4, 5]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 租户用户登录
|
|
||||||
|
|
||||||
租户用户登录时需要指定租户:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/auth/login
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"username": "user1",
|
|
||||||
"password": "password123",
|
|
||||||
"tenantCode": "tenant-a" // 可选,也可以从请求头获取
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或者在请求头中指定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/auth/login
|
|
||||||
Headers:
|
|
||||||
X-Tenant-Code: tenant-a
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"username": "user1",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 访问租户数据
|
|
||||||
|
|
||||||
所有API请求都会自动根据租户ID过滤数据:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /api/users
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
X-Tenant-Code: tenant-a
|
|
||||||
```
|
|
||||||
|
|
||||||
返回的数据只会包含该租户的用户。
|
|
||||||
|
|
||||||
## API接口
|
|
||||||
|
|
||||||
### 租户管理接口
|
|
||||||
|
|
||||||
- `POST /api/tenants` - 创建租户(需要 `tenant:create` 权限)
|
|
||||||
- `GET /api/tenants` - 获取租户列表(需要 `tenant:read` 权限)
|
|
||||||
- `GET /api/tenants/:id` - 获取租户详情(需要 `tenant:read` 权限)
|
|
||||||
- `PATCH /api/tenants/:id` - 更新租户(需要 `tenant:update` 权限)
|
|
||||||
- `DELETE /api/tenants/:id` - 删除租户(需要 `tenant:delete` 权限)
|
|
||||||
- `GET /api/tenants/:id/menus` - 获取租户的菜单树(需要 `tenant:read` 权限)
|
|
||||||
|
|
||||||
### 其他接口
|
|
||||||
|
|
||||||
所有其他接口(用户、角色、权限等)都支持租户隔离,会自动根据请求中的租户信息过滤数据。
|
|
||||||
|
|
||||||
## 前端集成
|
|
||||||
|
|
||||||
### 1. 请求拦截器
|
|
||||||
|
|
||||||
在前端请求拦截器中添加租户信息:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// utils/request.ts
|
|
||||||
service.interceptors.request.use(
|
|
||||||
(config: InternalAxiosRequestConfig) => {
|
|
||||||
const token = getToken();
|
|
||||||
const tenantCode = getTenantCode(); // 从localStorage或store获取
|
|
||||||
|
|
||||||
if (token && config.headers) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenantCode && config.headers) {
|
|
||||||
config.headers['X-Tenant-Code'] = tenantCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 登录时保存租户信息
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 登录成功后
|
|
||||||
localStorage.setItem('tenantCode', response.data.user.tenantCode);
|
|
||||||
localStorage.setItem('tenantId', response.data.user.tenantId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 租户切换
|
|
||||||
|
|
||||||
如果需要支持租户切换,可以在前端实现租户选择器,切换时更新localStorage中的租户信息并重新加载数据。
|
|
||||||
|
|
||||||
## 权限控制
|
|
||||||
|
|
||||||
### 超级租户权限
|
|
||||||
|
|
||||||
超级租户的用户拥有所有权限,包括:
|
|
||||||
- 创建、查看、更新、删除租户
|
|
||||||
- 为租户分配菜单
|
|
||||||
- 管理所有租户的数据(如果需要在超级租户中查看所有租户数据)
|
|
||||||
|
|
||||||
### 普通租户权限
|
|
||||||
|
|
||||||
普通租户的用户只能:
|
|
||||||
- 管理自己租户内的数据
|
|
||||||
- 查看分配给租户的菜单
|
|
||||||
- 无法访问其他租户的数据
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **数据隔离**: 所有查询都会自动添加租户过滤条件,确保数据隔离
|
|
||||||
2. **唯一性**: 用户名、邮箱等在租户内唯一,不同租户可以有相同的用户名
|
|
||||||
3. **菜单管理**: 菜单是全局的(由超级租户管理),但通过 `TenantMenu` 表分配给各个租户
|
|
||||||
4. **超级租户**: 超级租户不能被删除,且拥有所有权限
|
|
||||||
5. **迁移数据**: 如果现有系统已有数据,需要编写迁移脚本将现有数据关联到超级租户
|
|
||||||
|
|
||||||
## 迁移现有数据
|
|
||||||
|
|
||||||
如果系统已有数据,需要将现有数据迁移到超级租户:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 假设超级租户ID为1
|
|
||||||
UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL;
|
|
||||||
UPDATE roles SET tenant_id = 1 WHERE tenant_id IS NULL;
|
|
||||||
UPDATE permissions SET tenant_id = 1 WHERE tenant_id IS NULL;
|
|
||||||
-- 其他表类似
|
|
||||||
```
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
1. **租户识别失败**: 检查请求头是否正确设置,或检查JWT token中是否包含tenantId
|
|
||||||
2. **数据查询为空**: 确认租户ID正确,且数据确实属于该租户
|
|
||||||
3. **权限不足**: 确认用户角色有相应权限,且角色属于正确的租户
|
|
||||||
|
|
||||||
## 扩展功能
|
|
||||||
|
|
||||||
未来可以考虑的扩展:
|
|
||||||
1. 租户级别的配置(每个租户可以有自己的系统配置)
|
|
||||||
2. 租户级别的主题和品牌定制
|
|
||||||
3. 租户级别的功能开关
|
|
||||||
4. 租户使用统计和监控
|
|
||||||
5. 租户数据导出和备份
|
|
||||||
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
# 租户登录使用指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
系统已完整支持多租户登录功能,每个租户可以独立访问系统,数据完全隔离。
|
|
||||||
|
|
||||||
## 租户识别方式
|
|
||||||
|
|
||||||
系统支持以下方式识别租户:
|
|
||||||
|
|
||||||
### 1. URL参数方式(推荐)
|
|
||||||
|
|
||||||
在登录页面URL中添加 `tenant` 参数:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://your-domain.com/login?tenant=tenant-a
|
|
||||||
```
|
|
||||||
|
|
||||||
登录页面会自动识别租户编码,并在登录时自动发送。
|
|
||||||
|
|
||||||
### 2. 登录表单输入
|
|
||||||
|
|
||||||
如果URL中没有租户参数,登录页面会显示租户编码输入框,用户可以手动输入。
|
|
||||||
|
|
||||||
### 3. 请求头方式
|
|
||||||
|
|
||||||
前端会自动将租户信息添加到所有API请求的请求头中:
|
|
||||||
- `X-Tenant-Code`: 租户编码
|
|
||||||
- `X-Tenant-Id`: 租户ID
|
|
||||||
|
|
||||||
## 使用流程
|
|
||||||
|
|
||||||
### 方式一:通过URL参数访问(推荐)
|
|
||||||
|
|
||||||
1. **访问租户登录页面**
|
|
||||||
```
|
|
||||||
http://your-domain.com/login?tenant=tenant-a
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **输入用户名和密码**
|
|
||||||
- 用户名:租户内的用户名
|
|
||||||
- 密码:用户密码
|
|
||||||
- 租户编码:已自动填充(从URL参数)
|
|
||||||
|
|
||||||
3. **登录成功**
|
|
||||||
- 系统自动保存租户信息到 localStorage
|
|
||||||
- 后续所有API请求都会自动携带租户信息
|
|
||||||
- 用户只能看到和操作自己租户的数据
|
|
||||||
|
|
||||||
### 方式二:手动输入租户编码
|
|
||||||
|
|
||||||
1. **访问登录页面**
|
|
||||||
```
|
|
||||||
http://your-domain.com/login
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **输入租户信息**
|
|
||||||
- 租户编码:输入租户编码(如:`tenant-a`)
|
|
||||||
- 用户名:租户内的用户名
|
|
||||||
- 密码:用户密码
|
|
||||||
|
|
||||||
3. **登录成功**
|
|
||||||
- 系统保存租户信息
|
|
||||||
- 后续请求自动携带租户信息
|
|
||||||
|
|
||||||
## 后端API使用
|
|
||||||
|
|
||||||
### 登录接口
|
|
||||||
|
|
||||||
**请求:**
|
|
||||||
```bash
|
|
||||||
POST /api/auth/login
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "user1",
|
|
||||||
"password": "password123",
|
|
||||||
"tenantCode": "tenant-a" // 可选,也可以从请求头获取
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**或者通过请求头:**
|
|
||||||
```bash
|
|
||||||
POST /api/auth/login
|
|
||||||
X-Tenant-Code: tenant-a
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "user1",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"username": "user1",
|
|
||||||
"nickname": "用户1",
|
|
||||||
"email": "user1@example.com",
|
|
||||||
"tenantId": 2,
|
|
||||||
"tenantCode": "tenant-a",
|
|
||||||
"roles": ["admin"],
|
|
||||||
"permissions": ["user:read", "user:create", ...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 其他API请求
|
|
||||||
|
|
||||||
登录后,所有API请求都会自动携带租户信息(通过JWT Token或请求头),后端会自动过滤数据:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GET /api/users
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
X-Tenant-Code: tenant-a # 自动添加
|
|
||||||
```
|
|
||||||
|
|
||||||
返回的数据只会包含该租户的用户。
|
|
||||||
|
|
||||||
## 前端实现细节
|
|
||||||
|
|
||||||
### 1. 登录页面自动识别租户
|
|
||||||
|
|
||||||
登录页面 (`Login.vue`) 会:
|
|
||||||
- 从URL参数 `?tenant=xxx` 获取租户编码
|
|
||||||
- 如果URL中没有,从 localStorage 读取之前保存的租户编码
|
|
||||||
- 如果都没有,显示租户输入框
|
|
||||||
|
|
||||||
### 2. 请求拦截器自动添加租户信息
|
|
||||||
|
|
||||||
所有API请求都会自动添加租户信息到请求头:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// utils/request.ts
|
|
||||||
service.interceptors.request.use((config) => {
|
|
||||||
const tenantCode = getTenantCode();
|
|
||||||
const tenantId = getTenantId();
|
|
||||||
|
|
||||||
if (tenantCode) {
|
|
||||||
config.headers['X-Tenant-Code'] = tenantCode;
|
|
||||||
}
|
|
||||||
if (tenantId) {
|
|
||||||
config.headers['X-Tenant-Id'] = tenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 登录后保存租户信息
|
|
||||||
|
|
||||||
登录成功后,系统会自动保存:
|
|
||||||
- Token
|
|
||||||
- 租户编码 (tenantCode)
|
|
||||||
- 租户ID (tenantId)
|
|
||||||
|
|
||||||
这些信息保存在 localStorage 中,页面刷新后仍然有效。
|
|
||||||
|
|
||||||
## 示例场景
|
|
||||||
|
|
||||||
### 场景1:租户A的用户登录
|
|
||||||
|
|
||||||
1. 访问:`http://your-domain.com/login?tenant=tenant-a`
|
|
||||||
2. 输入用户名和密码
|
|
||||||
3. 登录后只能看到租户A的数据
|
|
||||||
|
|
||||||
### 场景2:租户B的用户登录
|
|
||||||
|
|
||||||
1. 访问:`http://your-domain.com/login?tenant=tenant-b`
|
|
||||||
2. 输入用户名和密码
|
|
||||||
3. 登录后只能看到租户B的数据
|
|
||||||
4. 租户A的数据完全不可见
|
|
||||||
|
|
||||||
### 场景3:超级租户管理员登录
|
|
||||||
|
|
||||||
1. 访问:`http://your-domain.com/login?tenant=super`
|
|
||||||
2. 使用超级管理员账号登录
|
|
||||||
3. 可以管理所有租户
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **租户编码必须唯一**:每个租户都有唯一的编码(code)
|
|
||||||
2. **用户属于特定租户**:用户只能登录到自己所属的租户
|
|
||||||
3. **数据完全隔离**:不同租户的数据完全隔离,无法互相访问
|
|
||||||
4. **租户信息持久化**:登录后租户信息保存在 localStorage,刷新页面不会丢失
|
|
||||||
5. **切换租户**:如果需要切换租户,需要先登出,然后使用新的租户编码登录
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 问题1:登录时提示"无法确定租户信息"
|
|
||||||
|
|
||||||
**原因**:没有提供租户编码或租户ID
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
- 在URL中添加 `?tenant=xxx` 参数
|
|
||||||
- 或者在登录表单中输入租户编码
|
|
||||||
- 或者通过请求头 `X-Tenant-Code` 提供
|
|
||||||
|
|
||||||
### 问题2:登录时提示"用户不属于该租户"
|
|
||||||
|
|
||||||
**原因**:用户不属于指定的租户
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
- 确认租户编码是否正确
|
|
||||||
- 确认用户是否属于该租户
|
|
||||||
- 联系管理员检查用户和租户的关联关系
|
|
||||||
|
|
||||||
### 问题3:登录后看不到数据
|
|
||||||
|
|
||||||
**原因**:可能是租户信息没有正确传递
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
- 检查浏览器控制台的网络请求,确认请求头中是否包含 `X-Tenant-Code`
|
|
||||||
- 检查 localStorage 中是否保存了租户信息
|
|
||||||
- 确认后端是否正确识别了租户
|
|
||||||
|
|
||||||
## 开发建议
|
|
||||||
|
|
||||||
1. **使用URL参数方式**:这是最用户友好的方式,用户只需要记住租户的访问链接
|
|
||||||
2. **提供租户选择器**:如果系统需要支持租户切换,可以在前端添加租户选择器
|
|
||||||
3. **错误提示优化**:当租户信息缺失时,提供清晰的错误提示
|
|
||||||
4. **租户信息显示**:在用户界面显示当前租户信息,让用户知道自己在哪个租户下操作
|
|
||||||
|
|
||||||
@ -1 +0,0 @@
|
|||||||
##
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* PM2 进程管理器配置文件
|
|
||||||
*
|
|
||||||
* 环境区分说明:
|
|
||||||
* 1. 通过 --env 参数指定环境:pm2 start ecosystem.config.js --env <环境名>
|
|
||||||
* 2. 环境配置会自动合并:基础配置(env) + 环境特定配置(env_<环境名>)
|
|
||||||
* 3. 测试环境: --env test (端口 3001, 2个实例)
|
|
||||||
* 4. 生产环境: --env production (端口 3000, 最大实例数)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const baseAppConfig = {
|
|
||||||
script: './dist/src/main.js',
|
|
||||||
|
|
||||||
// 日志文件路径
|
|
||||||
error_file: './logs/pm2-error.log',
|
|
||||||
out_file: './logs/pm2-out.log',
|
|
||||||
log_file: './logs/pm2-combined.log',
|
|
||||||
|
|
||||||
// 日志日期格式
|
|
||||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
|
||||||
|
|
||||||
// 合并日志(所有实例的日志合并到一个文件)
|
|
||||||
merge_logs: true,
|
|
||||||
|
|
||||||
// 自动重启配置
|
|
||||||
autorestart: true,
|
|
||||||
|
|
||||||
// 监听文件变化(生产环境建议关闭)
|
|
||||||
watch: false,
|
|
||||||
|
|
||||||
// 忽略监听的文件/目录
|
|
||||||
ignore_watch: ['node_modules', 'logs', 'dist', '.git', '*.log'],
|
|
||||||
|
|
||||||
// 最大内存限制(超过后自动重启)
|
|
||||||
max_memory_restart: '1G',
|
|
||||||
|
|
||||||
// 最小正常运行时间(秒),小于此时间重启不计入重启次数
|
|
||||||
min_uptime: '10s',
|
|
||||||
|
|
||||||
// 最大重启次数(在 min_uptime 时间内)
|
|
||||||
max_restarts: 10,
|
|
||||||
|
|
||||||
// 重启延迟(毫秒)
|
|
||||||
restart_delay: 4000,
|
|
||||||
|
|
||||||
// 等待就绪信号的时间(毫秒)
|
|
||||||
wait_ready: true,
|
|
||||||
listen_timeout: 10000,
|
|
||||||
|
|
||||||
// 优雅关闭超时时间(毫秒)
|
|
||||||
kill_timeout: 5000,
|
|
||||||
|
|
||||||
// 应用启动后的等待时间(毫秒)
|
|
||||||
shutdown_with_message: true,
|
|
||||||
|
|
||||||
// 源代码映射支持
|
|
||||||
source_map_support: true,
|
|
||||||
|
|
||||||
// 实例间负载均衡策略
|
|
||||||
instance_var: 'INSTANCE_ID',
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
...baseAppConfig,
|
|
||||||
|
|
||||||
// 生产环境应用
|
|
||||||
name: 'competition-api',
|
|
||||||
instances: 2,
|
|
||||||
exec_mode: 'cluster',
|
|
||||||
|
|
||||||
env_file: '.env.production',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3234,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...baseAppConfig,
|
|
||||||
|
|
||||||
// 测试环境应用
|
|
||||||
name: 'competition-api-test',
|
|
||||||
instances: 2,
|
|
||||||
exec_mode: 'cluster',
|
|
||||||
|
|
||||||
env_file: '.env.test',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
PORT: 3234,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 部署配置(用于 PM2 自动化部署)
|
|
||||||
// 使用方式: pm2 deploy ecosystem.config.js <环境名>
|
|
||||||
// ============================================
|
|
||||||
deploy: {
|
|
||||||
// 测试环境部署配置
|
|
||||||
test: {
|
|
||||||
user: 'deploy',
|
|
||||||
host: ['119.29.229.174'],
|
|
||||||
ref: 'origin/develop',
|
|
||||||
repo: 'git@github.com:your-username/competition-management-system.git',
|
|
||||||
path: '/var/www/competition-management-test',
|
|
||||||
'post-deploy':
|
|
||||||
'cd backend && pnpm install && pnpm run build && pm2 reload ecosystem.config.js --only competition-api-test',
|
|
||||||
'pre-setup': 'apt-get update && apt-get install -y git',
|
|
||||||
},
|
|
||||||
// 生产环境部署配置
|
|
||||||
production: {
|
|
||||||
user: 'deploy',
|
|
||||||
host: ['your-prod-server-ip'],
|
|
||||||
ref: 'origin/master',
|
|
||||||
repo: 'git@github.com:your-username/competition-management-system.git',
|
|
||||||
path: '/var/www/competition-management',
|
|
||||||
'post-deploy':
|
|
||||||
'cd backend && pnpm install && pnpm run build && pm2 reload ecosystem.config.js --only competition-api',
|
|
||||||
'pre-setup': 'apt-get update && apt-get install -y git',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
10990
backend/package-lock.json
generated
10990
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,122 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "competition-management-backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "活动管理系统后端",
|
|
||||||
"author": "",
|
|
||||||
"private": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
|
||||||
"build": "nest build",
|
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
||||||
"start": "nest start",
|
|
||||||
"start:dev": "set NODE_ENV=development&&nest start --watch",
|
|
||||||
"start:debug": "NODE_ENV=development nest start --debug --watch",
|
|
||||||
"start:prod": "NODE_ENV=production node dist/main",
|
|
||||||
"start:pm2:test": "pm2 start ecosystem.config.js --env test --only competition-api-test",
|
|
||||||
"start:pm2:prod": "pm2 start ecosystem.config.js --env production --only competition-api",
|
|
||||||
"stop:pm2:test": "pm2 stop competition-api-test",
|
|
||||||
"stop:pm2:prod": "pm2 stop competition-api",
|
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
||||||
"test": "NODE_ENV=test jest",
|
|
||||||
"test:watch": "NODE_ENV=test jest --watch",
|
|
||||||
"test:cov": "NODE_ENV=test jest --coverage",
|
|
||||||
"test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
|
||||||
"test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json",
|
|
||||||
"prisma:status:dev": "dotenv -e .env.development -- prisma migrate status",
|
|
||||||
"prisma:generate": "prisma generate",
|
|
||||||
"prisma:generate:dev": "dotenv -e .env.development -- prisma generate",
|
|
||||||
"prisma:migrate": "prisma migrate dev",
|
|
||||||
"prisma:migrate:dev": "dotenv -e .env.development -- prisma migrate dev --create-only --name add_contest_module",
|
|
||||||
"prisma:migrate:deploy": "NODE_ENV=production prisma migrate deploy",
|
|
||||||
"prisma:studio": "prisma studio",
|
|
||||||
"prisma:studio:dev": "NODE_ENV=development prisma studio",
|
|
||||||
"prisma:studio:prod": "NODE_ENV=production prisma studio",
|
|
||||||
"init:admin": "ts-node scripts/init-admin.ts",
|
|
||||||
"init:admin:permissions": "ts-node scripts/init-admin-permissions.ts",
|
|
||||||
"init:menus": "ts-node scripts/init-menus.ts",
|
|
||||||
"init:super-tenant": "ts-node scripts/init-super-tenant.ts",
|
|
||||||
"init:linksea-tenant": "ts-node scripts/init-linksea-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",
|
|
||||||
"fix:invalid-datetime": "ts-node scripts/fix-invalid-datetime.ts",
|
|
||||||
"cleanup:tenant-permissions": "ts-node scripts/cleanup-tenant-permissions.ts",
|
|
||||||
"init:roles:super": "ts-node scripts/init-roles-permissions.ts --super",
|
|
||||||
"init:roles": "ts-node scripts/init-roles-permissions.ts",
|
|
||||||
"init:roles:all": "ts-node scripts/init-roles-permissions.ts --all",
|
|
||||||
"init:tenant": "ts-node scripts/init-tenant.ts",
|
|
||||||
"compress:tgz:prod:win": "node -p \"require('./package.json').version\" | xargs -I {} bash scripts/compress.sh --env production --version {}",
|
|
||||||
"compress:tgz:test:win": "node -p \"require('./package.json').version\" | xargs -I {} bash scripts/compress.sh --env test --version {}"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs/common": "^10.3.3",
|
|
||||||
"@nestjs/config": "^3.1.1",
|
|
||||||
"@nestjs/core": "^10.3.3",
|
|
||||||
"@nestjs/jwt": "^10.2.0",
|
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
|
||||||
"@nestjs/passport": "^10.0.3",
|
|
||||||
"@nestjs/platform-express": "^10.3.3",
|
|
||||||
"@prisma/client": "^6.19.0",
|
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"axios": "^1.6.7",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.1",
|
|
||||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"reflect-metadata": "^0.2.1",
|
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"uuid": "^8.3.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nestjs/cli": "^10.3.2",
|
|
||||||
"@nestjs/schematics": "^10.1.0",
|
|
||||||
"@nestjs/testing": "^10.3.3",
|
|
||||||
"@types/adm-zip": "^0.5.5",
|
|
||||||
"@types/bcrypt": "^5.0.2",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/jest": "^29.5.11",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/node": "^20.11.5",
|
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"@types/passport-local": "^1.0.36",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
|
||||||
"@typescript-eslint/parser": "^6.19.1",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
|
||||||
"eslint": "^8.56.0",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"prettier": "^3.2.4",
|
|
||||||
"prisma": "^6.19.0",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"ts-jest": "^29.1.2",
|
|
||||||
"ts-loader": "^9.5.1",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "../coverage",
|
|
||||||
"testEnvironment": "node"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,60 +0,0 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// 查找香港小学租户
|
|
||||||
const tenant = await prisma.tenant.findFirst({
|
|
||||||
where: { code: 'school001' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
console.log('租户 school001 不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`租户: ${tenant.name} (${tenant.code})\n`);
|
|
||||||
|
|
||||||
// 查找该租户的 school_admin 角色
|
|
||||||
const role = await prisma.role.findFirst({
|
|
||||||
where: { tenantId: tenant.id, code: 'school_admin' },
|
|
||||||
include: {
|
|
||||||
permissions: {
|
|
||||||
include: {
|
|
||||||
permission: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
console.log('school_admin 角色不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`角色: ${role.name} (${role.code})`);
|
|
||||||
console.log(`权限数量: ${role.permissions.length}\n`);
|
|
||||||
|
|
||||||
// 检查系统管理相关权限
|
|
||||||
const systemPermissions = ['user:read', 'role:read', 'menu:read', 'permission:read'];
|
|
||||||
console.log('系统管理相关权限:');
|
|
||||||
systemPermissions.forEach(code => {
|
|
||||||
const has = role.permissions.some(rp => rp.permission.code === code);
|
|
||||||
console.log(` ${code}: ${has ? '✓' : '✗'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 查找该租户的权限
|
|
||||||
console.log('\n该租户所有权限:');
|
|
||||||
const permissions = await prisma.permission.findMany({
|
|
||||||
where: { tenantId: tenant.id }
|
|
||||||
});
|
|
||||||
permissions.forEach(p => {
|
|
||||||
console.log(` ${p.code}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// 查找3D打印作品大赛
|
|
||||||
const contest = await prisma.contest.findFirst({
|
|
||||||
where: { contestName: { contains: '3D打印' } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contest) {
|
|
||||||
console.log('未找到3D打印作品大赛');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`赛事: ${contest.contestName} (ID: ${contest.id})\n`);
|
|
||||||
|
|
||||||
// 查找该赛事的所有报名记录
|
|
||||||
const registrations = await prisma.contestRegistration.findMany({
|
|
||||||
where: { contestId: contest.id },
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
contest: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`报名记录数量: ${registrations.length}\n`);
|
|
||||||
|
|
||||||
if (registrations.length > 0) {
|
|
||||||
console.log('报名记录详情:');
|
|
||||||
registrations.forEach(r => {
|
|
||||||
console.log(` ID: ${r.id}, 用户: ${r.user?.username || 'N/A'}, 租户ID: ${r.tenantId}, 状态: ${r.status}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找 xuesheng1 用户
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: { username: 'xuesheng1' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
console.log(`\nxuesheng1 用户信息:`);
|
|
||||||
console.log(` ID: ${user.id}, 租户ID: ${user.tenantId}`);
|
|
||||||
|
|
||||||
// 查找该用户的所有报名记录
|
|
||||||
const userRegistrations = await prisma.contestRegistration.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
include: { contest: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nxuesheng1 的所有报名记录 (${userRegistrations.length}):`);
|
|
||||||
userRegistrations.forEach(r => {
|
|
||||||
console.log(` 赛事: ${r.contest?.contestName}, 状态: ${r.status}, 租户ID: ${r.tenantId}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('\n未找到 xuesheng1 用户');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// 查找香港小学租户
|
|
||||||
const tenant = await prisma.tenant.findFirst({
|
|
||||||
where: { code: 'school001' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
console.log('租户 school001 不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`租户: ${tenant.name} (${tenant.code})\n`);
|
|
||||||
|
|
||||||
// 查找该租户的 student 角色
|
|
||||||
const role = await prisma.role.findFirst({
|
|
||||||
where: { tenantId: tenant.id, code: 'student' },
|
|
||||||
include: {
|
|
||||||
permissions: {
|
|
||||||
include: {
|
|
||||||
permission: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
console.log('student 角色不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`角色: ${role.name} (${role.code})`);
|
|
||||||
console.log(`权限数量: ${role.permissions.length}\n`);
|
|
||||||
|
|
||||||
console.log('学生角色所有权限:');
|
|
||||||
role.permissions.forEach(rp => {
|
|
||||||
console.log(` ${rp.permission.code}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检查是否有 registration:read 权限
|
|
||||||
const hasRegistrationRead = role.permissions.some(rp => rp.permission.code === 'registration:read');
|
|
||||||
console.log(`\nregistration:read 权限: ${hasRegistrationRead ? '有' : '无'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
include: { tenant: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('All users:');
|
|
||||||
users.forEach(u => {
|
|
||||||
const tenantName = u.tenant ? u.tenant.name : 'N/A';
|
|
||||||
const tenantCode = u.tenant ? u.tenant.code : 'N/A';
|
|
||||||
console.log(` Tenant: ${tenantName} (${tenantCode}), User: ${u.username}, ID: ${u.id}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('DATABASE_URL not found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 超级管理员专属权限(普通租户不应该有这些权限)
|
|
||||||
const superAdminOnlyPermissions = [
|
|
||||||
'tenant:create',
|
|
||||||
'tenant:update',
|
|
||||||
'tenant:delete',
|
|
||||||
];
|
|
||||||
|
|
||||||
async function cleanupTenantPermissions() {
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始清理普通租户的超级管理员权限...\n');
|
|
||||||
|
|
||||||
// 1. 获取所有非超级租户
|
|
||||||
const normalTenants = await prisma.tenant.findMany({
|
|
||||||
where: {
|
|
||||||
isSuper: { not: 1 },
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`找到 ${normalTenants.length} 个普通租户\n`);
|
|
||||||
|
|
||||||
for (const tenant of normalTenants) {
|
|
||||||
console.log(`处理租户: ${tenant.name} (${tenant.code})`);
|
|
||||||
|
|
||||||
// 2. 找到该租户下的超级管理员专属权限
|
|
||||||
const permissionsToRemove = await prisma.permission.findMany({
|
|
||||||
where: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
code: { in: superAdminOnlyPermissions },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permissionsToRemove.length === 0) {
|
|
||||||
console.log(` ✓ 没有需要清理的权限\n`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionIds = permissionsToRemove.map((p) => p.id);
|
|
||||||
console.log(` 找到 ${permissionsToRemove.length} 个需要清理的权限: ${permissionsToRemove.map((p) => p.code).join(', ')}`);
|
|
||||||
|
|
||||||
// 3. 删除角色-权限关联
|
|
||||||
const deletedRolePermissions = await prisma.rolePermission.deleteMany({
|
|
||||||
where: {
|
|
||||||
permissionId: { in: permissionIds },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(` 删除了 ${deletedRolePermissions.count} 条角色-权限关联`);
|
|
||||||
|
|
||||||
// 4. 删除权限记录
|
|
||||||
const deletedPermissions = await prisma.permission.deleteMany({
|
|
||||||
where: {
|
|
||||||
id: { in: permissionIds },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(` 删除了 ${deletedPermissions.count} 条权限记录\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 更新租户管理菜单权限
|
|
||||||
console.log('更新租户管理菜单权限...');
|
|
||||||
const tenantMenu = await prisma.menu.findFirst({
|
|
||||||
where: {
|
|
||||||
name: '租户管理',
|
|
||||||
path: '/system/tenants',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tenantMenu) {
|
|
||||||
if (tenantMenu.permission !== 'tenant:update') {
|
|
||||||
await prisma.menu.update({
|
|
||||||
where: { id: tenantMenu.id },
|
|
||||||
data: { permission: 'tenant:update' },
|
|
||||||
});
|
|
||||||
console.log(`✅ 菜单权限已更新为 tenant:update (原: ${tenantMenu.permission})`);
|
|
||||||
} else {
|
|
||||||
console.log('✅ 菜单权限已经是 tenant:update');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ 未找到租户管理菜单');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ 清理完成!');
|
|
||||||
console.log('\n说明:');
|
|
||||||
console.log(' - 普通租户现在只有 tenant:read 权限(用于读取租户列表)');
|
|
||||||
console.log(' - 租户管理菜单需要 tenant:update 权限才能看到');
|
|
||||||
console.log(' - 只有超级租户才有 tenant:create/update/delete 权限');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 清理失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupTenantPermissions()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 压缩脚本
|
|
||||||
# 使用方法:
|
|
||||||
# ./scripts/compress.sh # 使用默认配置(不包含 node_modules)
|
|
||||||
# ./scripts/compress.sh --include-node-modules # 使用默认配置(包含 node_modules)
|
|
||||||
# ./scripts/compress.sh -n # 使用默认配置(包含 node_modules,简写)
|
|
||||||
# ./scripts/compress.sh --env production --version 1.0.0 # 指定环境和版本
|
|
||||||
# ./scripts/compress.sh --env test --version 1.0.0 -n # 组合使用多个参数
|
|
||||||
# ./scripts/compress.sh src/ package.json # 自定义文件/文件夹列表
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# 颜色输出
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# 默认值
|
|
||||||
ENV=""
|
|
||||||
VERSION=""
|
|
||||||
INCLUDE_NODE_MODULES=false
|
|
||||||
|
|
||||||
# 解析命令行参数
|
|
||||||
CUSTOM_ITEMS=()
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--include-node-modules|-n)
|
|
||||||
INCLUDE_NODE_MODULES=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--env)
|
|
||||||
if [ -z "$2" ]; then
|
|
||||||
echo -e "${RED}错误: --env 参数需要一个值${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ENV="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--version)
|
|
||||||
if [ -z "$2" ]; then
|
|
||||||
echo -e "${RED}错误: --version 参数需要一个值${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
VERSION="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
CUSTOM_ITEMS+=("$1")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# 构建输出文件名
|
|
||||||
OUTPUT_FILE="competition-management-service"
|
|
||||||
if [ -n "$ENV" ]; then
|
|
||||||
OUTPUT_FILE="${OUTPUT_FILE}-${ENV}"
|
|
||||||
fi
|
|
||||||
if [ -n "$VERSION" ]; then
|
|
||||||
OUTPUT_FILE="${OUTPUT_FILE}-${VERSION}"
|
|
||||||
fi
|
|
||||||
OUTPUT_FILE="${OUTPUT_FILE}.tgz"
|
|
||||||
|
|
||||||
# 默认要压缩的文件和文件夹(如果用户没有指定)
|
|
||||||
DEFAULT_ITEMS=(
|
|
||||||
"dist"
|
|
||||||
"package.json"
|
|
||||||
"tsconfig.json"
|
|
||||||
"ecosystem.config.js"
|
|
||||||
"prisma/"
|
|
||||||
".env"
|
|
||||||
".env.development"
|
|
||||||
".env.production"
|
|
||||||
".env.test"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 如果指定了包含 node_modules,则添加到默认列表的开头
|
|
||||||
if [ "$INCLUDE_NODE_MODULES" = true ]; then
|
|
||||||
DEFAULT_ITEMS=("node_modules" "${DEFAULT_ITEMS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 排除的文件和文件夹模式
|
|
||||||
EXCLUDE_PATTERNS=(
|
|
||||||
"docs/"
|
|
||||||
"scripts/"
|
|
||||||
"sql/"
|
|
||||||
".git"
|
|
||||||
".DS_Store"
|
|
||||||
"*.log"
|
|
||||||
"./logs"
|
|
||||||
"coverage"
|
|
||||||
"*.tmp"
|
|
||||||
"*.temp"
|
|
||||||
".cache"
|
|
||||||
".pnpm-store"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取脚本所在目录
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
# 切换到项目根目录
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
echo -e "${GREEN}📦 开始压缩项目...${NC}"
|
|
||||||
if [ -n "$ENV" ]; then
|
|
||||||
echo -e "${BLUE}环境: ${ENV}${NC}"
|
|
||||||
fi
|
|
||||||
if [ -n "$VERSION" ]; then
|
|
||||||
echo -e "${BLUE}版本: ${VERSION}${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 确定要压缩的文件和文件夹
|
|
||||||
if [ ${#CUSTOM_ITEMS[@]} -eq 0 ]; then
|
|
||||||
if [ "$INCLUDE_NODE_MODULES" = true ]; then
|
|
||||||
echo -e "${YELLOW}未指定文件/文件夹,使用默认配置(包含 node_modules)${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}未指定文件/文件夹,使用默认配置(不包含 node_modules)${NC}"
|
|
||||||
fi
|
|
||||||
ITEMS_TO_COMPRESS=("${DEFAULT_ITEMS[@]}")
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}使用自定义文件/文件夹列表${NC}"
|
|
||||||
ITEMS_TO_COMPRESS=("${CUSTOM_ITEMS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 验证文件和文件夹是否存在
|
|
||||||
echo -e "${BLUE}检查文件/文件夹是否存在...${NC}"
|
|
||||||
MISSING_ITEMS=()
|
|
||||||
for item in "${ITEMS_TO_COMPRESS[@]}"; do
|
|
||||||
if [ ! -e "$item" ]; then
|
|
||||||
MISSING_ITEMS+=("$item")
|
|
||||||
echo -e "${RED} ⚠️ 警告: $item 不存在,将被跳过${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${GREEN} ✅ $item${NC}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 如果有缺失的文件,询问是否继续
|
|
||||||
if [ ${#MISSING_ITEMS[@]} -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}发现 ${#MISSING_ITEMS[@]} 个不存在的文件/文件夹${NC}"
|
|
||||||
read -p "是否继续压缩? (y/n) " -n 1 -r
|
|
||||||
echo ""
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
echo -e "${RED}已取消压缩${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 构建 tar 排除选项
|
|
||||||
EXCLUDE_ARGS=()
|
|
||||||
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
|
|
||||||
EXCLUDE_ARGS+=(--exclude="$pattern")
|
|
||||||
done
|
|
||||||
|
|
||||||
# 如果输出文件已存在,询问是否覆盖
|
|
||||||
if [ -f "$OUTPUT_FILE" ]; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}输出文件 $OUTPUT_FILE 已存在${NC}"
|
|
||||||
read -p "是否覆盖? (y/n) " -n 1 -r
|
|
||||||
echo ""
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
echo -e "${RED}已取消压缩${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -f "$OUTPUT_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 执行压缩
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}正在压缩...${NC}"
|
|
||||||
|
|
||||||
# 使用 tar 压缩
|
|
||||||
tar -czf "$OUTPUT_FILE" \
|
|
||||||
"${EXCLUDE_ARGS[@]}" \
|
|
||||||
"${ITEMS_TO_COMPRESS[@]}" 2>/dev/null || {
|
|
||||||
echo -e "${RED}压缩失败${NC}"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检查压缩结果
|
|
||||||
if [ ! -f "$OUTPUT_FILE" ]; then
|
|
||||||
echo -e "${RED}错误: 压缩文件未生成${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 显示压缩文件信息
|
|
||||||
FILE_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1)
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
|
||||||
echo -e "${GREEN}✅ 压缩完成!${NC}"
|
|
||||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "📦 输出文件: $OUTPUT_FILE"
|
|
||||||
echo "📊 文件大小: $FILE_SIZE"
|
|
||||||
echo "📍 位置: $(pwd)/$OUTPUT_FILE"
|
|
||||||
if [ -n "$ENV" ]; then
|
|
||||||
echo "🌍 环境: $ENV"
|
|
||||||
fi
|
|
||||||
if [ -n "$VERSION" ]; then
|
|
||||||
echo "🏷️ 版本: $VERSION"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "📝 已压缩的内容:"
|
|
||||||
for item in "${ITEMS_TO_COMPRESS[@]}"; do
|
|
||||||
if [ -e "$item" ]; then
|
|
||||||
echo " ✅ $item"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo "🚫 已排除的内容:"
|
|
||||||
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
|
|
||||||
echo " ❌ $pattern"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
@ -1,598 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
// 加载环境变量(必须在其他导入之前)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
// scripts 目录的父目录就是 backend 目录
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
// 尝试加载环境特定的配置文件
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必要的环境变量
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
console.error(` 请确保存在以下文件之一:`);
|
|
||||||
console.error(` - ${envPath}`);
|
|
||||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
|
||||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 定义所有基础权限
|
|
||||||
const permissions = [
|
|
||||||
// 用户管理权限
|
|
||||||
{
|
|
||||||
code: 'user:create',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建用户',
|
|
||||||
description: '允许创建新用户',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'user:read',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看用户',
|
|
||||||
description: '允许查看用户列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'user:update',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新用户',
|
|
||||||
description: '允许更新用户信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'user:delete',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除用户',
|
|
||||||
description: '允许删除用户',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 角色管理权限
|
|
||||||
{
|
|
||||||
code: 'role:create',
|
|
||||||
resource: 'role',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建角色',
|
|
||||||
description: '允许创建新角色',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'role:read',
|
|
||||||
resource: 'role',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看角色',
|
|
||||||
description: '允许查看角色列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'role:update',
|
|
||||||
resource: 'role',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新角色',
|
|
||||||
description: '允许更新角色信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'role:delete',
|
|
||||||
resource: 'role',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除角色',
|
|
||||||
description: '允许删除角色',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'role:assign',
|
|
||||||
resource: 'role',
|
|
||||||
action: 'assign',
|
|
||||||
name: '分配角色',
|
|
||||||
description: '允许给用户分配角色',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 权限管理权限
|
|
||||||
{
|
|
||||||
code: 'permission:create',
|
|
||||||
resource: 'permission',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建权限',
|
|
||||||
description: '允许创建新权限',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'permission:read',
|
|
||||||
resource: 'permission',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看权限',
|
|
||||||
description: '允许查看权限列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'permission:update',
|
|
||||||
resource: 'permission',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新权限',
|
|
||||||
description: '允许更新权限信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'permission:delete',
|
|
||||||
resource: 'permission',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除权限',
|
|
||||||
description: '允许删除权限',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 菜单管理权限
|
|
||||||
{
|
|
||||||
code: 'menu:create',
|
|
||||||
resource: 'menu',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建菜单',
|
|
||||||
description: '允许创建新菜单',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'menu:read',
|
|
||||||
resource: 'menu',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看菜单',
|
|
||||||
description: '允许查看菜单列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'menu:update',
|
|
||||||
resource: 'menu',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新菜单',
|
|
||||||
description: '允许更新菜单信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'menu:delete',
|
|
||||||
resource: 'menu',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除菜单',
|
|
||||||
description: '允许删除菜单',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 数据字典权限
|
|
||||||
{
|
|
||||||
code: 'dict:create',
|
|
||||||
resource: 'dict',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建字典',
|
|
||||||
description: '允许创建新字典',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'dict:read',
|
|
||||||
resource: 'dict',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看字典',
|
|
||||||
description: '允许查看字典列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'dict:update',
|
|
||||||
resource: 'dict',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新字典',
|
|
||||||
description: '允许更新字典信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'dict:delete',
|
|
||||||
resource: 'dict',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除字典',
|
|
||||||
description: '允许删除字典',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 系统配置权限
|
|
||||||
{
|
|
||||||
code: 'config:create',
|
|
||||||
resource: 'config',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建配置',
|
|
||||||
description: '允许创建新配置',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'config:read',
|
|
||||||
resource: 'config',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看配置',
|
|
||||||
description: '允许查看配置列表和详情',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'config:update',
|
|
||||||
resource: 'config',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新配置',
|
|
||||||
description: '允许更新配置信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'config:delete',
|
|
||||||
resource: 'config',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除配置',
|
|
||||||
description: '允许删除配置',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 日志管理权限
|
|
||||||
{
|
|
||||||
code: 'log:read',
|
|
||||||
resource: 'log',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看日志',
|
|
||||||
description: '允许查看系统日志',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'log:delete',
|
|
||||||
resource: 'log',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除日志',
|
|
||||||
description: '允许删除系统日志',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 活动管理权限
|
|
||||||
{
|
|
||||||
code: '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: 'notice:create',
|
|
||||||
resource: 'notice',
|
|
||||||
action: 'create',
|
|
||||||
name: '创建公告',
|
|
||||||
description: '允许创建活动公告',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'notice:read',
|
|
||||||
resource: 'notice',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看公告',
|
|
||||||
description: '允许查看活动公告',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'notice:update',
|
|
||||||
resource: 'notice',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新公告',
|
|
||||||
description: '允许更新活动公告',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'notice:delete',
|
|
||||||
resource: 'notice',
|
|
||||||
action: 'delete',
|
|
||||||
name: '删除公告',
|
|
||||||
description: '允许删除活动公告',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 报名管理权限
|
|
||||||
{
|
|
||||||
code: 'registration:read',
|
|
||||||
resource: 'registration',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看报名',
|
|
||||||
description: '允许查看报名列表',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'registration:audit',
|
|
||||||
resource: 'registration',
|
|
||||||
action: 'audit',
|
|
||||||
name: '审核报名',
|
|
||||||
description: '允许审核报名申请',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 作品管理权限
|
|
||||||
{
|
|
||||||
code: 'work:read',
|
|
||||||
resource: 'work',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看作品',
|
|
||||||
description: '允许查看参赛作品',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'work:update',
|
|
||||||
resource: 'work',
|
|
||||||
action: 'update',
|
|
||||||
name: '更新作品',
|
|
||||||
description: '允许更新作品状态',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 评审管理权限
|
|
||||||
{
|
|
||||||
code: 'review:read',
|
|
||||||
resource: 'review',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看评审',
|
|
||||||
description: '允许查看评审信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'review:assign',
|
|
||||||
resource: 'review',
|
|
||||||
action: 'assign',
|
|
||||||
name: '分配评审',
|
|
||||||
description: '允许分配作品给评委',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'review:score',
|
|
||||||
resource: 'review',
|
|
||||||
action: 'score',
|
|
||||||
name: '评分',
|
|
||||||
description: '允许对作品进行评分',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 评委管理权限
|
|
||||||
{
|
|
||||||
code: 'judge:create',
|
|
||||||
resource: 'judge',
|
|
||||||
action: 'create',
|
|
||||||
name: '添加评委',
|
|
||||||
description: '允许添加活动评委',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'judge:read',
|
|
||||||
resource: 'judge',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看评委',
|
|
||||||
description: '允许查看评委列表',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'judge:delete',
|
|
||||||
resource: 'judge',
|
|
||||||
action: 'delete',
|
|
||||||
name: '移除评委',
|
|
||||||
description: '允许移除活动评委',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 成果管理权限
|
|
||||||
{
|
|
||||||
code: 'result:read',
|
|
||||||
resource: 'result',
|
|
||||||
action: 'read',
|
|
||||||
name: '查看成果',
|
|
||||||
description: '允许查看成果信息',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'result:publish',
|
|
||||||
resource: 'result',
|
|
||||||
action: 'publish',
|
|
||||||
name: '发布成果',
|
|
||||||
description: '允许发布活动结果',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'result:award',
|
|
||||||
resource: 'result',
|
|
||||||
action: 'award',
|
|
||||||
name: '设置奖项',
|
|
||||||
description: '允许设置作品奖项',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 用户密码管理权限
|
|
||||||
{
|
|
||||||
code: 'user:password:update',
|
|
||||||
resource: 'user',
|
|
||||||
action: 'password:update',
|
|
||||||
name: '修改用户密码',
|
|
||||||
description: '允许修改用户密码',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function initAdminPermissions() {
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始为超级管理员(admin)用户初始化权限...\n');
|
|
||||||
|
|
||||||
// 1. 检查 admin 用户是否存在(先获取超级租户)
|
|
||||||
console.log('👤 步骤 1: 检查 admin 用户...');
|
|
||||||
const superTenant = await prisma.tenant.findUnique({
|
|
||||||
where: { code: 'super' },
|
|
||||||
});
|
|
||||||
if (!superTenant) {
|
|
||||||
console.error('❌ 错误: 超级租户不存在!');
|
|
||||||
console.error(' 请先运行 pnpm init:super-tenant 创建超级租户');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const adminUser = await prisma.user.findUnique({
|
|
||||||
where: { tenantId_username: { tenantId: superTenant.id, username: 'admin' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!adminUser) {
|
|
||||||
console.error('❌ 错误: admin 用户不存在!');
|
|
||||||
console.error(' 请先运行 pnpm init:admin 创建 admin 用户');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`✅ admin 用户存在: ${adminUser.username} (${adminUser.nickname})\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. 创建或更新所有权限
|
|
||||||
console.log('📝 步骤 2: 确保所有权限存在...');
|
|
||||||
const createdPermissions = [];
|
|
||||||
for (const perm of permissions) {
|
|
||||||
const permission = await prisma.permission.upsert({
|
|
||||||
where: { tenantId_code: { tenantId: superTenant.id, code: perm.code } },
|
|
||||||
update: {
|
|
||||||
name: perm.name,
|
|
||||||
resource: perm.resource,
|
|
||||||
action: perm.action,
|
|
||||||
description: perm.description,
|
|
||||||
},
|
|
||||||
create: { ...perm, tenantId: superTenant.id },
|
|
||||||
});
|
|
||||||
createdPermissions.push(permission);
|
|
||||||
}
|
|
||||||
console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`);
|
|
||||||
|
|
||||||
// 3. 创建或获取超级管理员角色
|
|
||||||
console.log('👤 步骤 3: 确保超级管理员角色存在...');
|
|
||||||
const adminRole = await prisma.role.upsert({
|
|
||||||
where: { tenantId_code: { tenantId: superTenant.id, code: 'super_admin' } },
|
|
||||||
update: {
|
|
||||||
name: '超级管理员',
|
|
||||||
description: '拥有系统所有权限的超级管理员角色',
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
name: '超级管理员',
|
|
||||||
code: 'super_admin',
|
|
||||||
description: '拥有系统所有权限的超级管理员角色',
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`✅ 超级管理员角色已确保存在: ${adminRole.name} (${adminRole.code})\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. 确保超级管理员角色拥有所有权限
|
|
||||||
console.log('🔗 步骤 4: 为超级管理员角色分配所有权限...');
|
|
||||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId: adminRole.id },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingPermissionIds = new Set(
|
|
||||||
existingRolePermissions.map((rp) => rp.permissionId),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
for (const permission of createdPermissions) {
|
|
||||||
if (!existingPermissionIds.has(permission.id)) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: {
|
|
||||||
roleId: adminRole.id,
|
|
||||||
permissionId: permission.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedCount > 0) {
|
|
||||||
console.log(`✅ 为超级管理员角色添加了 ${addedCount} 个权限\n`);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`✅ 超级管理员角色已拥有所有权限(${createdPermissions.length} 个)\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 确保 admin 用户拥有超级管理员角色
|
|
||||||
console.log('🔗 步骤 5: 确保 admin 用户拥有超级管理员角色...');
|
|
||||||
const existingUserRole = await prisma.userRole.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_roleId: {
|
|
||||||
userId: adminUser.id,
|
|
||||||
roleId: adminRole.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingUserRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
userId: adminUser.id,
|
|
||||||
roleId: adminRole.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`✅ 已为 admin 用户分配超级管理员角色\n`);
|
|
||||||
} else {
|
|
||||||
console.log(`✅ admin 用户已拥有超级管理员角色\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证结果
|
|
||||||
console.log('🔍 步骤 6: 验证结果...');
|
|
||||||
const userWithRoles = await prisma.user.findUnique({
|
|
||||||
where: { id: adminUser.id },
|
|
||||||
include: {
|
|
||||||
roles: {
|
|
||||||
include: {
|
|
||||||
role: {
|
|
||||||
include: {
|
|
||||||
permissions: {
|
|
||||||
include: {
|
|
||||||
permission: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || [];
|
|
||||||
const permissionCodes = new Set<string>();
|
|
||||||
userWithRoles?.roles.forEach((ur) => {
|
|
||||||
ur.role.permissions.forEach((rp) => {
|
|
||||||
permissionCodes.add(rp.permission.code);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n📊 初始化结果:`);
|
|
||||||
console.log(` 用户名: ${adminUser.username}`);
|
|
||||||
console.log(` 昵称: ${adminUser.nickname}`);
|
|
||||||
console.log(` 角色: ${roleCodes.join(', ')}`);
|
|
||||||
console.log(` 权限数量: ${permissionCodes.size}`);
|
|
||||||
console.log(` 权限列表:`);
|
|
||||||
Array.from(permissionCodes)
|
|
||||||
.sort()
|
|
||||||
.forEach((code) => {
|
|
||||||
console.log(` - ${code}`);
|
|
||||||
});
|
|
||||||
console.log(`\n✅ 超级管理员权限初始化完成!`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 初始化失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行初始化
|
|
||||||
initAdminPermissions()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 权限初始化脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 权限初始化脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,277 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 初始化超级管理员脚本(支持多租户)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 超级管理员基础权限
|
|
||||||
const permissions = [
|
|
||||||
// 工作台
|
|
||||||
{ code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' },
|
|
||||||
// 用户管理
|
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
|
|
||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
|
||||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' },
|
|
||||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' },
|
|
||||||
// 角色管理
|
|
||||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' },
|
|
||||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色列表和详情' },
|
|
||||||
{ code: 'role:update', resource: 'role', action: 'update', name: '更新角色', description: '允许更新角色信息' },
|
|
||||||
{ code: 'role:delete', resource: 'role', action: 'delete', name: '删除角色', description: '允许删除角色' },
|
|
||||||
{ code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' },
|
|
||||||
// 权限管理
|
|
||||||
{ code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' },
|
|
||||||
// 菜单管理
|
|
||||||
{ code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单列表' },
|
|
||||||
// 租户管理
|
|
||||||
{ code: 'tenant:create', resource: 'tenant', action: 'create', name: '创建租户', description: '允许创建租户' },
|
|
||||||
{ code: 'tenant:read', resource: 'tenant', action: 'read', name: '查看租户', description: '允许查看租户列表' },
|
|
||||||
{ code: 'tenant:update', resource: 'tenant', action: 'update', name: '更新租户', description: '允许更新租户信息' },
|
|
||||||
{ code: 'tenant:delete', resource: 'tenant', 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:finish', resource: 'contest', action: 'finish', name: '结束活动', description: '允许结束活动' },
|
|
||||||
// 评审规则
|
|
||||||
{ code: 'review-rule:create', resource: 'review-rule', action: 'create', name: '创建评审规则', description: '允许创建评审规则' },
|
|
||||||
{ code: 'review-rule:read', resource: 'review-rule', action: 'read', name: '查看评审规则', description: '允许查看评审规则' },
|
|
||||||
{ code: 'review-rule:update', resource: 'review-rule', action: 'update', name: '更新评审规则', description: '允许更新评审规则' },
|
|
||||||
{ code: 'review-rule:delete', resource: 'review-rule', action: 'delete', name: '删除评审规则', description: '允许删除评审规则' },
|
|
||||||
// 评委管理
|
|
||||||
{ code: 'judge:create', resource: 'judge', action: 'create', name: '添加评委', description: '允许添加评委' },
|
|
||||||
{ code: 'judge:read', resource: 'judge', action: 'read', name: '查看评委', description: '允许查看评委列表' },
|
|
||||||
{ code: 'judge:update', resource: 'judge', action: 'update', name: '更新评委', description: '允许更新评委信息' },
|
|
||||||
{ code: 'judge:delete', resource: 'judge', action: 'delete', name: '删除评委', description: '允许删除评委' },
|
|
||||||
{ code: 'judge:assign', resource: 'judge', action: 'assign', name: '分配评委', description: '允许为活动分配评委' },
|
|
||||||
// 报名管理
|
|
||||||
{ code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' },
|
|
||||||
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
|
||||||
// 作品管理
|
|
||||||
{ code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' },
|
|
||||||
// 公告管理
|
|
||||||
{ code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建活动公告' },
|
|
||||||
{ code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看活动公告' },
|
|
||||||
{ code: 'notice:update', resource: 'notice', action: 'update', name: '更新公告', description: '允许更新公告信息' },
|
|
||||||
{ code: 'notice:delete', resource: 'notice', 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: '允许删除系统日志' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function initAdmin() {
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始初始化超级管理员...\n');
|
|
||||||
|
|
||||||
// 1. 获取或创建超级租户
|
|
||||||
console.log('🏢 步骤 1: 获取超级租户...');
|
|
||||||
let superTenant = await prisma.tenant.findFirst({
|
|
||||||
where: { isSuper: 1, validState: 1 }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!superTenant) {
|
|
||||||
console.log(' 未找到超级租户,正在创建...');
|
|
||||||
superTenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: '超级租户',
|
|
||||||
code: 'super',
|
|
||||||
isSuper: 1,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建超级租户: ${superTenant.name} (${superTenant.code})`);
|
|
||||||
} else {
|
|
||||||
console.log(` ✓ 找到超级租户: ${superTenant.name} (ID: ${superTenant.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = superTenant.id;
|
|
||||||
|
|
||||||
// 2. 创建权限
|
|
||||||
console.log('\n📝 步骤 2: 创建基础权限...');
|
|
||||||
const createdPermissions: any[] = [];
|
|
||||||
|
|
||||||
for (const perm of permissions) {
|
|
||||||
// 使用 tenantId + code 作为唯一约束
|
|
||||||
let permission = await prisma.permission.findFirst({
|
|
||||||
where: { tenantId, code: perm.code }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (permission) {
|
|
||||||
permission = await prisma.permission.update({
|
|
||||||
where: { id: permission.id },
|
|
||||||
data: { ...perm, tenantId }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
permission = await prisma.permission.create({
|
|
||||||
data: { ...perm, tenantId, validState: 1 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createdPermissions.push(permission);
|
|
||||||
}
|
|
||||||
console.log(` ✓ 共创建/更新 ${createdPermissions.length} 个权限`);
|
|
||||||
|
|
||||||
// 3. 创建超级管理员角色
|
|
||||||
console.log('\n👤 步骤 3: 创建超级管理员角色...');
|
|
||||||
let adminRole = await prisma.role.findFirst({
|
|
||||||
where: { tenantId, code: 'super_admin' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (adminRole) {
|
|
||||||
adminRole = await prisma.role.update({
|
|
||||||
where: { id: adminRole.id },
|
|
||||||
data: {
|
|
||||||
name: '超级管理员',
|
|
||||||
description: '拥有系统所有权限的超级管理员角色',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 更新角色: ${adminRole.name}`);
|
|
||||||
} else {
|
|
||||||
adminRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
name: '超级管理员',
|
|
||||||
code: 'super_admin',
|
|
||||||
description: '拥有系统所有权限的超级管理员角色',
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建角色: ${adminRole.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 分配权限给角色
|
|
||||||
console.log('\n🔗 步骤 4: 分配权限给角色...');
|
|
||||||
// 先获取已有的角色权限
|
|
||||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId: adminRole.id },
|
|
||||||
select: { permissionId: true }
|
|
||||||
});
|
|
||||||
const existingPermissionIds = new Set(existingRolePermissions.map(rp => rp.permissionId));
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
for (const perm of createdPermissions) {
|
|
||||||
if (!existingPermissionIds.has(perm.id)) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: {
|
|
||||||
roleId: adminRole.id,
|
|
||||||
permissionId: perm.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✓ 新增 ${addedCount} 个权限分配`);
|
|
||||||
|
|
||||||
// 5. 创建 admin 用户
|
|
||||||
console.log('\n👤 步骤 5: 创建 admin 用户...');
|
|
||||||
const password = `admin@${superTenant.code}`;
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
|
||||||
|
|
||||||
let adminUser = await prisma.user.findFirst({
|
|
||||||
where: { tenantId, username: 'admin' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (adminUser) {
|
|
||||||
adminUser = await prisma.user.update({
|
|
||||||
where: { id: adminUser.id },
|
|
||||||
data: {
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: '超级管理员',
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 更新用户: ${adminUser.username}`);
|
|
||||||
} else {
|
|
||||||
adminUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
username: 'admin',
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: '超级管理员',
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建用户: ${adminUser.username}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 给用户分配角色
|
|
||||||
console.log('\n🔗 步骤 6: 分配角色给用户...');
|
|
||||||
const existingUserRole = await prisma.userRole.findFirst({
|
|
||||||
where: { userId: adminUser.id, roleId: adminRole.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingUserRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
userId: adminUser.id,
|
|
||||||
roleId: adminRole.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 分配角色: ${adminRole.name}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ✓ 角色已分配: ${adminRole.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 输出结果
|
|
||||||
console.log('\n' + '='.repeat(50));
|
|
||||||
console.log('🎉 超级管理员初始化完成!');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log(` 租户编码: ${superTenant.code}`);
|
|
||||||
console.log(` 用户名: admin`);
|
|
||||||
console.log(` 密码: ${password}`);
|
|
||||||
console.log(` 角色: ${adminRole.name}`);
|
|
||||||
console.log(` 权限数量: ${createdPermissions.length}`);
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log('\n💡 提示: 请运行以下命令初始化菜单:');
|
|
||||||
console.log(' npm run init:menus');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 初始化失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行初始化
|
|
||||||
initAdmin()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ 初始化脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 初始化脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,438 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 批量创建开发测试租户脚本
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 要创建的租户配置
|
|
||||||
// ============================================
|
|
||||||
const devTenants = [
|
|
||||||
{
|
|
||||||
name: '学校管理端',
|
|
||||||
code: 'school',
|
|
||||||
description: '学校管理员端,管理学校信息、教师、学生等',
|
|
||||||
roles: [
|
|
||||||
{
|
|
||||||
code: 'school_admin',
|
|
||||||
name: '学校管理员',
|
|
||||||
description: '学校管理员',
|
|
||||||
isDefault: true,
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
|
||||||
'role:read',
|
|
||||||
'permission:read',
|
|
||||||
'school:create', 'school:read', 'school:update',
|
|
||||||
'department:create', 'department:read', 'department:update', 'department:delete',
|
|
||||||
'grade:create', 'grade:read', 'grade:update', 'grade:delete',
|
|
||||||
'class:create', 'class:read', 'class:update', 'class:delete',
|
|
||||||
'teacher:create', 'teacher:read', 'teacher:update', 'teacher:delete',
|
|
||||||
'student:create', 'student:read', 'student:update', 'student:delete',
|
|
||||||
'activity:read', 'notice:read',
|
|
||||||
'registration:read', 'work:read',
|
|
||||||
'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish',
|
|
||||||
'homework-submission:read',
|
|
||||||
'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete',
|
|
||||||
'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
menus: ['工作台', '学校管理', '我的评审', '作业管理'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '教师端',
|
|
||||||
code: 'teacher',
|
|
||||||
description: '教师端,可以报名活动、指导学生、管理作业',
|
|
||||||
roles: [
|
|
||||||
{
|
|
||||||
code: 'teacher',
|
|
||||||
name: '教师',
|
|
||||||
description: '教师角色',
|
|
||||||
isDefault: true,
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
'grade:read', 'class:read', 'student:read',
|
|
||||||
'activity:read', 'activity:guidance', 'notice:read',
|
|
||||||
'registration:create', 'registration:read', 'registration:update', 'registration:delete',
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit',
|
|
||||||
'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish',
|
|
||||||
'homework-submission:read',
|
|
||||||
'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete',
|
|
||||||
'homework-score:create', 'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
menus: ['工作台', '我的评审', '作业管理'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '学生端',
|
|
||||||
code: 'student',
|
|
||||||
description: '学生端,可以查看活动、上传作品、提交作业',
|
|
||||||
roles: [
|
|
||||||
{
|
|
||||||
code: 'student',
|
|
||||||
name: '学生',
|
|
||||||
description: '学生角色',
|
|
||||||
isDefault: true,
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
'activity:read', 'notice:read',
|
|
||||||
'registration:read',
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit',
|
|
||||||
'homework:read',
|
|
||||||
'homework-submission:create', 'homework-submission:read', 'homework-submission:update',
|
|
||||||
'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
menus: ['工作台', '我的评审', '作业管理'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '评委端',
|
|
||||||
code: 'judge',
|
|
||||||
description: '评委端,可以评审作品、打分',
|
|
||||||
roles: [
|
|
||||||
{
|
|
||||||
code: 'judge',
|
|
||||||
name: '评委',
|
|
||||||
description: '评委角色',
|
|
||||||
isDefault: true,
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
'activity:read', 'notice:read',
|
|
||||||
'work:read',
|
|
||||||
'judge:read', 'judge:assign',
|
|
||||||
'review:read', 'review:create', 'review:update',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
menus: ['工作台', '我的评审'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 权限定义(完整列表)
|
|
||||||
// ============================================
|
|
||||||
const allPermissions = [
|
|
||||||
// 工作台
|
|
||||||
{ code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' },
|
|
||||||
|
|
||||||
// 用户管理
|
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
|
|
||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
|
||||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' },
|
|
||||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' },
|
|
||||||
|
|
||||||
// 角色管理
|
|
||||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' },
|
|
||||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色列表和详情' },
|
|
||||||
{ code: 'role:update', resource: 'role', action: 'update', name: '更新角色', description: '允许更新角色信息' },
|
|
||||||
{ code: 'role:delete', resource: 'role', action: 'delete', name: '删除角色', description: '允许删除角色' },
|
|
||||||
{ code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' },
|
|
||||||
|
|
||||||
// 权限管理
|
|
||||||
{ code: 'permission:read', resource: 'permission', action: 'read', 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: '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: '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: '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: 'activity:read', resource: 'activity', action: 'read', name: '查看我的评审', description: '允许查看已发布的我的评审' },
|
|
||||||
{ code: 'activity:guidance', resource: 'activity', action: 'guidance', name: '指导学生', description: '允许指导学生参赛' },
|
|
||||||
|
|
||||||
// 活动报名
|
|
||||||
{ code: 'registration:create', resource: 'registration', action: 'create', name: '创建报名', description: '允许报名活动' },
|
|
||||||
{ code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' },
|
|
||||||
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
|
||||||
{ code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' },
|
|
||||||
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
|
||||||
|
|
||||||
// 参赛作品
|
|
||||||
{ code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' },
|
|
||||||
{ code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' },
|
|
||||||
{ code: 'work:update', resource: 'work', action: 'update', name: '更新作品', description: '允许更新作品信息' },
|
|
||||||
{ code: 'work:delete', resource: 'work', action: 'delete', name: '删除作品', description: '允许删除作品' },
|
|
||||||
{ code: 'work:submit', resource: 'work', action: 'submit', name: '提交作品', description: '允许提交作品' },
|
|
||||||
|
|
||||||
// 活动公告
|
|
||||||
{ code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看活动公告' },
|
|
||||||
|
|
||||||
// 评委管理
|
|
||||||
{ code: 'judge:create', resource: 'judge', action: 'create', name: '创建评委', description: '允许创建评委' },
|
|
||||||
{ code: 'judge:read', resource: 'judge', action: 'read', name: '查看评委', description: '允许查看评委' },
|
|
||||||
{ code: 'judge:update', resource: 'judge', action: 'update', name: '更新评委', description: '允许更新评委' },
|
|
||||||
{ code: 'judge:delete', resource: 'judge', action: 'delete', name: '删除评委', description: '允许删除评委' },
|
|
||||||
{ code: 'judge:assign', resource: 'judge', action: 'assign', name: '分配评委', description: '允许分配评委' },
|
|
||||||
|
|
||||||
// 评审
|
|
||||||
{ code: 'review:create', resource: 'review', action: 'create', name: '创建评审', description: '允许创建评审' },
|
|
||||||
{ code: 'review:read', resource: 'review', action: 'read', name: '查看评审', description: '允许查看评审' },
|
|
||||||
{ code: 'review:update', resource: 'review', action: 'update', name: '更新评审', description: '允许更新评审' },
|
|
||||||
{ code: 'review:delete', resource: 'review', action: 'delete', name: '删除评审', description: '允许删除评审' },
|
|
||||||
|
|
||||||
// 作业管理
|
|
||||||
{ code: 'homework:create', resource: 'homework', action: 'create', name: '创建作业', description: '允许创建作业' },
|
|
||||||
{ code: 'homework:read', resource: 'homework', action: 'read', name: '查看作业', description: '允许查看作业列表' },
|
|
||||||
{ code: 'homework:update', resource: 'homework', action: 'update', name: '更新作业', description: '允许更新作业信息' },
|
|
||||||
{ code: 'homework:delete', resource: 'homework', action: 'delete', name: '删除作业', description: '允许删除作业' },
|
|
||||||
{ code: 'homework:publish', resource: 'homework', action: 'publish', name: '发布作业', description: '允许发布作业' },
|
|
||||||
|
|
||||||
// 作业提交
|
|
||||||
{ code: 'homework-submission:create', resource: 'homework-submission', action: 'create', name: '提交作业', description: '允许提交作业' },
|
|
||||||
{ code: 'homework-submission:read', resource: 'homework-submission', action: 'read', name: '查看作业提交', description: '允许查看作业提交记录' },
|
|
||||||
{ code: 'homework-submission:update', resource: 'homework-submission', action: 'update', name: '更新作业提交', description: '允许更新提交的作业' },
|
|
||||||
|
|
||||||
// 作业评审规则
|
|
||||||
{ code: 'homework-review-rule:create', resource: 'homework-review-rule', action: 'create', name: '创建作业评审规则', description: '允许创建作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:read', resource: 'homework-review-rule', action: 'read', name: '查看作业评审规则', description: '允许查看作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:update', resource: 'homework-review-rule', action: 'update', name: '更新作业评审规则', description: '允许更新作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:delete', resource: 'homework-review-rule', action: 'delete', name: '删除作业评审规则', description: '允许删除作业评审规则' },
|
|
||||||
|
|
||||||
// 作业评分
|
|
||||||
{ code: 'homework-score:create', resource: 'homework-score', action: 'create', name: '作业评分', description: '允许对作业评分' },
|
|
||||||
{ code: 'homework-score:read', resource: 'homework-score', action: 'read', name: '查看作业评分', description: '允许查看作业评分' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function initDevTenants() {
|
|
||||||
console.log('🚀 开始批量创建开发测试租户...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取所有菜单
|
|
||||||
const allMenus = await prisma.menu.findMany({
|
|
||||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
|
|
||||||
for (const tenantConfig of devTenants) {
|
|
||||||
console.log(`\n${'='.repeat(50)}`);
|
|
||||||
console.log(`📦 创建租户: ${tenantConfig.name}`);
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|
||||||
// 检查是否已存在
|
|
||||||
const existingTenant = await prisma.tenant.findFirst({
|
|
||||||
where: { code: tenantConfig.code }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingTenant) {
|
|
||||||
console.log(` ⚠️ 租户 "${tenantConfig.code}" 已存在,跳过创建`);
|
|
||||||
results.push({
|
|
||||||
name: tenantConfig.name,
|
|
||||||
code: tenantConfig.code,
|
|
||||||
status: 'skipped',
|
|
||||||
username: 'admin',
|
|
||||||
password: `admin@${tenantConfig.code}`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 创建租户
|
|
||||||
console.log('\n 🏢 步骤 1: 创建租户...');
|
|
||||||
const tenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: tenantConfig.name,
|
|
||||||
code: tenantConfig.code,
|
|
||||||
description: tenantConfig.description,
|
|
||||||
isSuper: 0,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 租户创建成功: ${tenant.name} (${tenant.code})`);
|
|
||||||
|
|
||||||
const tenantId = tenant.id;
|
|
||||||
|
|
||||||
// 2. 创建权限
|
|
||||||
console.log('\n 📝 步骤 2: 创建权限...');
|
|
||||||
const createdPermissions: { [code: string]: number } = {};
|
|
||||||
|
|
||||||
for (const perm of allPermissions) {
|
|
||||||
const existingPerm = await prisma.permission.findFirst({
|
|
||||||
where: { code: perm.code, tenantId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingPerm) {
|
|
||||||
const permission = await prisma.permission.create({
|
|
||||||
data: { ...perm, tenantId, validState: 1 }
|
|
||||||
});
|
|
||||||
createdPermissions[perm.code] = permission.id;
|
|
||||||
} else {
|
|
||||||
createdPermissions[perm.code] = existingPerm.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✓ 共 ${Object.keys(createdPermissions).length} 个权限`);
|
|
||||||
|
|
||||||
// 3. 创建角色并分配权限
|
|
||||||
console.log('\n 👥 步骤 3: 创建角色...');
|
|
||||||
let defaultRoleId: number | null = null;
|
|
||||||
|
|
||||||
for (const roleConfig of tenantConfig.roles) {
|
|
||||||
const role = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
name: roleConfig.name,
|
|
||||||
code: roleConfig.code,
|
|
||||||
description: roleConfig.description,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 分配权限
|
|
||||||
for (const permCode of roleConfig.permissions) {
|
|
||||||
const permissionId = createdPermissions[permCode];
|
|
||||||
if (permissionId) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: { roleId: role.id, permissionId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleConfig.isDefault) {
|
|
||||||
defaultRoleId = role.id;
|
|
||||||
}
|
|
||||||
console.log(` ✓ 角色: ${role.name} (${roleConfig.permissions.length} 个权限)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 创建管理员用户
|
|
||||||
console.log('\n 👤 步骤 4: 创建管理员用户...');
|
|
||||||
const password = `admin@${tenantConfig.code}`;
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
|
||||||
|
|
||||||
const adminUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
username: 'admin',
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: `${tenantConfig.name}管理员`,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 用户: admin`);
|
|
||||||
|
|
||||||
// 5. 分配角色
|
|
||||||
if (defaultRoleId) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: { userId: adminUser.id, roleId: defaultRoleId }
|
|
||||||
});
|
|
||||||
console.log(` ✓ 已分配默认角色`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 分配菜单
|
|
||||||
console.log('\n 📋 步骤 5: 分配菜单...');
|
|
||||||
const menuIds = new Set<number>();
|
|
||||||
|
|
||||||
for (const menu of allMenus) {
|
|
||||||
// 顶级菜单
|
|
||||||
if (!menu.parentId && tenantConfig.menus.includes(menu.name)) {
|
|
||||||
menuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
// 子菜单
|
|
||||||
if (menu.parentId) {
|
|
||||||
const parentMenu = allMenus.find(m => m.id === menu.parentId);
|
|
||||||
if (parentMenu && tenantConfig.menus.includes(parentMenu.name)) {
|
|
||||||
menuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const menuId of menuIds) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: { tenantId: tenant.id, menuId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(` ✓ 分配 ${menuIds.size} 个菜单`);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
name: tenantConfig.name,
|
|
||||||
code: tenantConfig.code,
|
|
||||||
status: 'created',
|
|
||||||
username: 'admin',
|
|
||||||
password: password,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输出汇总
|
|
||||||
console.log('\n\n' + '='.repeat(60));
|
|
||||||
console.log('🎉 开发测试租户创建完成!');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('\n📝 登录账号汇总:\n');
|
|
||||||
console.log('| 端\t\t| 租户编码\t| 用户名\t| 密码\t\t\t|');
|
|
||||||
console.log('|---------------|---------------|---------------|-----------------------|');
|
|
||||||
for (const r of results) {
|
|
||||||
console.log(`| ${r.name}\t| ${r.code}\t\t| ${r.username}\t\t| ${r.password}\t\t|`);
|
|
||||||
}
|
|
||||||
console.log('\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 创建失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行初始化
|
|
||||||
initDevTenants()
|
|
||||||
.then(() => {
|
|
||||||
console.log('✅ 脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('💥 脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* 广东省立中山图书馆 - 租户初始化脚本
|
|
||||||
*
|
|
||||||
* 运行方式:
|
|
||||||
* npx ts-node -r tsconfig-paths/register scripts/init-guangdong-library.ts
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* 1. 创建广东省图租户(library 类型)
|
|
||||||
* 2. 创建管理员账号
|
|
||||||
* 3. 分配必要的角色和权限
|
|
||||||
* 4. 分配菜单
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('开始初始化广东省图数据...\n');
|
|
||||||
|
|
||||||
// 1. 创建租户
|
|
||||||
const tenantCode = 'gdlib';
|
|
||||||
let tenant = await prisma.tenant.findUnique({ where: { code: tenantCode } });
|
|
||||||
|
|
||||||
if (tenant) {
|
|
||||||
console.log(`租户 ${tenantCode} 已存在 (ID: ${tenant.id}),跳过创建`);
|
|
||||||
} else {
|
|
||||||
tenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: '广东省立中山图书馆',
|
|
||||||
code: tenantCode,
|
|
||||||
tenantType: 'library',
|
|
||||||
description: '广东省图少儿绘本创作活动主办方',
|
|
||||||
isSuper: 0,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`创建租户: ${tenant.name} (ID: ${tenant.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = tenant.id;
|
|
||||||
|
|
||||||
// 2. 创建权限
|
|
||||||
const permissions = [
|
|
||||||
// 活动管理
|
|
||||||
{ 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:finish', resource: 'contest', action: 'finish', name: '结束活动', description: '允许结束活动' },
|
|
||||||
// 报名管理
|
|
||||||
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
|
||||||
{ code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' },
|
|
||||||
// 评委管理
|
|
||||||
{ code: 'judge:read', resource: 'judge', action: 'read', name: '查看评委', description: '允许查看评委' },
|
|
||||||
{ code: 'judge:create', resource: 'judge', action: 'create', name: '添加评委', description: '允许添加评委' },
|
|
||||||
{ code: 'judge:assign', resource: 'judge', action: 'assign', name: '分配评委', description: '允许分配评委' },
|
|
||||||
// 评审规则
|
|
||||||
{ code: 'review-rule:read', resource: 'review-rule', action: 'read', name: '查看评审规则', description: '允许查看评审规则' },
|
|
||||||
{ code: 'review-rule:create', resource: 'review-rule', action: 'create', name: '创建评审规则', description: '允许创建评审规则' },
|
|
||||||
// 成果发布
|
|
||||||
{ code: 'result:read', resource: 'result', action: 'read', name: '查看成果', description: '允许查看活动成果' },
|
|
||||||
{ code: 'result:publish', resource: 'result', action: 'publish', name: '发布成果', description: '允许发布活动成果' },
|
|
||||||
// 公告
|
|
||||||
{ code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建活动公告' },
|
|
||||||
{ code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看活动公告' },
|
|
||||||
// 用户管理
|
|
||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户' },
|
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建用户' },
|
|
||||||
// 角色管理
|
|
||||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色' },
|
|
||||||
// 菜单
|
|
||||||
{ code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const permissionIds: number[] = [];
|
|
||||||
for (const perm of permissions) {
|
|
||||||
const existing = await prisma.permission.findFirst({
|
|
||||||
where: { tenantId, code: perm.code },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
permissionIds.push(existing.id);
|
|
||||||
} else {
|
|
||||||
const created = await prisma.permission.create({
|
|
||||||
data: { ...perm, tenantId, validState: 1 },
|
|
||||||
});
|
|
||||||
permissionIds.push(created.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`权限已创建/确认: ${permissionIds.length} 个`);
|
|
||||||
|
|
||||||
// 3. 创建管理员角色
|
|
||||||
let adminRole = await prisma.role.findFirst({
|
|
||||||
where: { tenantId, code: 'tenant_admin' },
|
|
||||||
});
|
|
||||||
if (!adminRole) {
|
|
||||||
adminRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
name: '机构管理员',
|
|
||||||
code: 'tenant_admin',
|
|
||||||
description: '广东省图机构管理员,管理活动和报名',
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`创建角色: ${adminRole.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分配所有权限给管理员角色
|
|
||||||
for (const permId of permissionIds) {
|
|
||||||
const existing = await prisma.rolePermission.findFirst({
|
|
||||||
where: { roleId: adminRole.id, permissionId: permId },
|
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: { roleId: adminRole.id, permissionId: permId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`角色权限已分配`);
|
|
||||||
|
|
||||||
// 4. 创建管理员账号
|
|
||||||
const adminUsername = 'admin';
|
|
||||||
let adminUser = await prisma.user.findFirst({
|
|
||||||
where: { tenantId, username: adminUsername },
|
|
||||||
});
|
|
||||||
if (!adminUser) {
|
|
||||||
const hashedPassword = await bcrypt.hash('admin@gdlib', 10);
|
|
||||||
adminUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
username: adminUsername,
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: '广东省图管理员',
|
|
||||||
userSource: 'admin_created',
|
|
||||||
status: 'enabled',
|
|
||||||
validState: 1,
|
|
||||||
roles: {
|
|
||||||
create: [{ roleId: adminRole.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`创建管理员: ${adminUsername} / admin@gdlib`);
|
|
||||||
} else {
|
|
||||||
console.log(`管理员 ${adminUsername} 已存在`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 分配菜单(活动管理 + 系统管理)
|
|
||||||
const menuNames = ['活动管理', '系统管理'];
|
|
||||||
const menus = await prisma.menu.findMany({
|
|
||||||
where: { name: { in: menuNames }, parentId: null },
|
|
||||||
include: { children: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const menu of menus) {
|
|
||||||
// 分配父菜单
|
|
||||||
const existing = await prisma.tenantMenu.findFirst({
|
|
||||||
where: { tenantId, menuId: menu.id },
|
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: { tenantId, menuId: menu.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 分配子菜单
|
|
||||||
for (const child of menu.children || []) {
|
|
||||||
const childExisting = await prisma.tenantMenu.findFirst({
|
|
||||||
where: { tenantId, menuId: child.id },
|
|
||||||
});
|
|
||||||
if (!childExisting) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: { tenantId, menuId: child.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`菜单已分配: ${menuNames.join(', ')}`);
|
|
||||||
|
|
||||||
console.log('\n广东省图初始化完成!');
|
|
||||||
console.log(`登录地址: /${tenantCode}/login`);
|
|
||||||
console.log(`账号: admin / admin@gdlib`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error('初始化失败:', e);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 加载环境变量(必须在其他导入之前)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
// scripts 目录的父目录就是 backend 目录
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
// 尝试加载环境特定的配置文件
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必要的环境变量
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
console.error(` 请确保存在以下文件之一:`);
|
|
||||||
console.error(` - ${envPath}`);
|
|
||||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
|
||||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('🚀 开始创建 LinkSea 普通租户...\n');
|
|
||||||
|
|
||||||
const tenantCode = 'linksea';
|
|
||||||
const menuNames = ['活动管理', '系统管理'];
|
|
||||||
|
|
||||||
// 1. 查找或创建租户
|
|
||||||
console.log(`📋 步骤 1: 查找或创建租户 "${tenantCode}"...`);
|
|
||||||
let tenant = await prisma.tenant.findUnique({
|
|
||||||
where: { code: tenantCode },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
// 创建普通租户
|
|
||||||
tenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: 'LinkSea 租户',
|
|
||||||
code: tenantCode,
|
|
||||||
domain: tenantCode,
|
|
||||||
description: 'LinkSea 普通租户',
|
|
||||||
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. 查找指定的菜单(顶级菜单)
|
|
||||||
console.log(`📋 步骤 2: 查找菜单 "${menuNames.join('", "')}"...`);
|
|
||||||
const menus = await prisma.menu.findMany({
|
|
||||||
where: {
|
|
||||||
name: { in: menuNames },
|
|
||||||
parentId: null, // 只查找顶级菜单
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (menus.length === 0) {
|
|
||||||
console.error(`❌ 错误: 未找到指定的菜单!`);
|
|
||||||
console.error(` 请确保菜单 "${menuNames.join('", "')}" 已初始化`);
|
|
||||||
console.error(` 运行: pnpm init:menus`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menus.length !== menuNames.length) {
|
|
||||||
const foundMenuNames = menus.map((m) => m.name);
|
|
||||||
const missingMenus = menuNames.filter(
|
|
||||||
(name) => !foundMenuNames.includes(name),
|
|
||||||
);
|
|
||||||
console.warn(`⚠️ 警告: 部分菜单未找到: ${missingMenus.join(', ')}`);
|
|
||||||
console.log(` 找到的菜单: ${foundMenuNames.join(', ')}\n`);
|
|
||||||
} else {
|
|
||||||
console.log(`✅ 找到 ${menus.length} 个菜单:`);
|
|
||||||
menus.forEach((menu) => {
|
|
||||||
console.log(` ✓ ${menu.name}`);
|
|
||||||
});
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 递归获取菜单及其所有子菜单
|
|
||||||
console.log(`📋 步骤 3: 获取菜单及其所有子菜单...`);
|
|
||||||
const menuIds = new Set<number>();
|
|
||||||
|
|
||||||
// 递归函数:获取菜单及其所有子菜单的ID
|
|
||||||
async function getMenuAndChildrenIds(menuId: number) {
|
|
||||||
menuIds.add(menuId);
|
|
||||||
|
|
||||||
// 获取所有子菜单
|
|
||||||
const children = await prisma.menu.findMany({
|
|
||||||
where: {
|
|
||||||
parentId: menuId,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 递归获取子菜单的子菜单
|
|
||||||
for (const child of children) {
|
|
||||||
await getMenuAndChildrenIds(child.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个顶级菜单获取所有子菜单
|
|
||||||
for (const menu of menus) {
|
|
||||||
await getMenuAndChildrenIds(menu.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuIdArray = Array.from(menuIds);
|
|
||||||
console.log(`✅ 共找到 ${menuIdArray.length} 个菜单(包括子菜单)\n`);
|
|
||||||
|
|
||||||
// 4. 获取租户已分配的菜单
|
|
||||||
console.log(`📋 步骤 4: 检查租户已分配的菜单...`);
|
|
||||||
const existingTenantMenus = await prisma.tenantMenu.findMany({
|
|
||||||
where: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
menuId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingMenuIds = new Set(existingTenantMenus.map((tm) => tm.menuId));
|
|
||||||
|
|
||||||
// 5. 为租户分配菜单(只分配新的菜单)
|
|
||||||
console.log(`📋 步骤 5: 为租户分配菜单...`);
|
|
||||||
const menusToAdd = menuIdArray.filter((id) => !existingMenuIds.has(id));
|
|
||||||
|
|
||||||
if (menusToAdd.length === 0) {
|
|
||||||
console.log(`✅ 租户已拥有所有指定的菜单\n`);
|
|
||||||
} else {
|
|
||||||
let addedCount = 0;
|
|
||||||
const menuNamesToAdd: string[] = [];
|
|
||||||
|
|
||||||
for (const menuId of menusToAdd) {
|
|
||||||
const menu = await prisma.menu.findUnique({
|
|
||||||
where: { id: menuId },
|
|
||||||
select: { name: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
menuId: menuId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
if (menu) {
|
|
||||||
menuNamesToAdd.push(menu.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 为租户添加了 ${addedCount} 个菜单:`);
|
|
||||||
menuNamesToAdd.forEach((name) => {
|
|
||||||
console.log(` ✓ ${name}`);
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`\n✅ 租户现在拥有 ${menuIdArray.length} 个菜单(包括子菜单)\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证结果
|
|
||||||
console.log('📊 初始化结果:');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('租户信息:');
|
|
||||||
console.log(` 租户编码: ${tenant.code}`);
|
|
||||||
console.log(` 租户名称: ${tenant.name}`);
|
|
||||||
console.log(` 租户类型: ${tenant.isSuper === 1 ? '超级租户' : '普通租户'}`);
|
|
||||||
console.log(` 访问链接: http://your-domain.com/?tenant=${tenant.code}`);
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('分配的菜单:');
|
|
||||||
console.log(` 顶级菜单: ${menuNames.join(', ')}`);
|
|
||||||
console.log(` 菜单总数: ${menuIdArray.length} 个(包括子菜单)`);
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('\n💡 提示:');
|
|
||||||
console.log(' 如需创建管理员账号,请运行: pnpm init:tenant-admin linksea');
|
|
||||||
console.log('========================================');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 LinkSea 租户创建脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 LinkSea 租户创建脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@ -1,277 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 加载环境变量(必须在其他导入之前)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
// scripts 目录的父目录就是 backend 目录
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
// 尝试加载环境特定的配置文件
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必要的环境变量
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
console.error(` 请确保存在以下文件之一:`);
|
|
||||||
console.error(` - ${envPath}`);
|
|
||||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
|
||||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 从 JSON 文件加载菜单数据
|
|
||||||
const menusFilePath = path.resolve(backendDir, 'data', 'menus.json');
|
|
||||||
|
|
||||||
if (!fs.existsSync(menusFilePath)) {
|
|
||||||
console.error(`❌ 错误: 菜单数据文件不存在: ${menusFilePath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
|
|
||||||
|
|
||||||
// 超级租户可见的菜单名称
|
|
||||||
const SUPER_TENANT_MENUS = ['我的评审', '活动监管', '内容管理', '活动管理', '机构管理', '用户中心', '系统设置'];
|
|
||||||
|
|
||||||
// 普通租户可见的菜单名称
|
|
||||||
const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '活动管理', '系统设置'];
|
|
||||||
|
|
||||||
// 普通租户在系统设置下排除的子菜单(只保留用户管理和角色管理)
|
|
||||||
const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理'];
|
|
||||||
|
|
||||||
// 普通租户在我的评审下排除的子菜单(只保留评审任务)
|
|
||||||
const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['预设评语'];
|
|
||||||
|
|
||||||
async function initMenus() {
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始初始化菜单数据...\n');
|
|
||||||
|
|
||||||
// 递归创建菜单
|
|
||||||
async function createMenu(menuData: any, parentId: number | null = null) {
|
|
||||||
const { children, ...menuFields } = menuData;
|
|
||||||
|
|
||||||
// 查找是否已存在相同名称和父菜单的菜单
|
|
||||||
const existingMenu = await prisma.menu.findFirst({
|
|
||||||
where: {
|
|
||||||
name: menuFields.name,
|
|
||||||
parentId: parentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let menu;
|
|
||||||
if (existingMenu) {
|
|
||||||
// 更新现有菜单
|
|
||||||
menu = await prisma.menu.update({
|
|
||||||
where: { id: existingMenu.id },
|
|
||||||
data: {
|
|
||||||
name: menuFields.name,
|
|
||||||
path: menuFields.path || null,
|
|
||||||
icon: menuFields.icon || null,
|
|
||||||
component: menuFields.component || null,
|
|
||||||
permission: menuFields.permission || null,
|
|
||||||
parentId: parentId,
|
|
||||||
sort: menuFields.sort || 0,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 创建新菜单
|
|
||||||
menu = await prisma.menu.create({
|
|
||||||
data: {
|
|
||||||
name: menuFields.name,
|
|
||||||
path: menuFields.path || null,
|
|
||||||
icon: menuFields.icon || null,
|
|
||||||
component: menuFields.component || null,
|
|
||||||
permission: menuFields.permission || null,
|
|
||||||
parentId: parentId,
|
|
||||||
sort: menuFields.sort || 0,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` ✓ ${menu.name} (${menu.path || '无路径'})`);
|
|
||||||
|
|
||||||
// 如果有子菜单,递归创建
|
|
||||||
if (children && children.length > 0) {
|
|
||||||
for (const child of children) {
|
|
||||||
await createMenu(child, menu.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空现有菜单(重新初始化)
|
|
||||||
console.log('🗑️ 清空现有菜单和租户菜单关联...');
|
|
||||||
// 先删除租户菜单关联
|
|
||||||
await prisma.tenantMenu.deleteMany({});
|
|
||||||
// 再删除所有子菜单,再删除父菜单(避免外键约束问题)
|
|
||||||
await prisma.menu.deleteMany({
|
|
||||||
where: {
|
|
||||||
parentId: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.menu.deleteMany({
|
|
||||||
where: {
|
|
||||||
parentId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 已清空现有菜单\n');
|
|
||||||
|
|
||||||
// 创建所有菜单
|
|
||||||
console.log('📝 创建菜单...\n');
|
|
||||||
for (const menu of menus) {
|
|
||||||
await createMenu(menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果
|
|
||||||
console.log('\n🔍 验证结果...');
|
|
||||||
const allMenus = await prisma.menu.findMany({
|
|
||||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
|
||||||
include: {
|
|
||||||
children: {
|
|
||||||
orderBy: {
|
|
||||||
sort: 'asc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const topLevelMenus = allMenus.filter((m) => !m.parentId);
|
|
||||||
const totalMenus = allMenus.length;
|
|
||||||
|
|
||||||
console.log(`\n📊 初始化结果:`);
|
|
||||||
console.log(` 顶级菜单数量: ${topLevelMenus.length}`);
|
|
||||||
console.log(` 总菜单数量: ${totalMenus}`);
|
|
||||||
console.log(`\n📋 菜单结构:`);
|
|
||||||
|
|
||||||
function printMenuTree(menu: any, indent: string = '') {
|
|
||||||
console.log(`${indent}├─ ${menu.name} (${menu.path || '无路径'})`);
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
menu.children.forEach((child: any, index: number) => {
|
|
||||||
const isLast = index === menu.children.length - 1;
|
|
||||||
const childIndent = indent + (isLast ? ' ' : '│ ');
|
|
||||||
printMenuTree(child, childIndent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
topLevelMenus.forEach((menu) => {
|
|
||||||
printMenuTree(menu);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为所有现有租户分配菜单(区分超级租户和普通租户)
|
|
||||||
console.log(`\n📋 为所有租户分配菜单...`);
|
|
||||||
const allTenants = await prisma.tenant.findMany({
|
|
||||||
where: { validState: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allTenants.length === 0) {
|
|
||||||
console.log('⚠️ 没有找到任何有效租户,跳过菜单分配\n');
|
|
||||||
} else {
|
|
||||||
console.log(` 找到 ${allTenants.length} 个租户\n`);
|
|
||||||
|
|
||||||
// 获取超级租户菜单ID(工作台、我的评审、活动管理、系统管理及其子菜单)
|
|
||||||
const superTenantMenuIds = new Set<number>();
|
|
||||||
for (const menu of allMenus) {
|
|
||||||
// 顶级菜单
|
|
||||||
if (!menu.parentId && SUPER_TENANT_MENUS.includes(menu.name)) {
|
|
||||||
superTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
// 子菜单(检查父菜单是否在超级租户菜单中)
|
|
||||||
if (menu.parentId) {
|
|
||||||
const parentMenu = allMenus.find(m => m.id === menu.parentId);
|
|
||||||
if (parentMenu && SUPER_TENANT_MENUS.includes(parentMenu.name)) {
|
|
||||||
superTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取普通租户菜单ID(工作台、学校管理、我的评审、作业管理、部分系统管理)
|
|
||||||
const normalTenantMenuIds = new Set<number>();
|
|
||||||
for (const menu of allMenus) {
|
|
||||||
// 顶级菜单
|
|
||||||
if (!menu.parentId && NORMAL_TENANT_MENUS.includes(menu.name)) {
|
|
||||||
normalTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
// 子菜单
|
|
||||||
if (menu.parentId) {
|
|
||||||
const parentMenu = allMenus.find(m => m.id === menu.parentId);
|
|
||||||
if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) {
|
|
||||||
// 系统设置下排除部分子菜单
|
|
||||||
if (parentMenu.name === '系统设置' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 我的评审下排除部分子菜单
|
|
||||||
if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
normalTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tenant of allTenants) {
|
|
||||||
const isSuperTenant = tenant.isSuper === 1;
|
|
||||||
|
|
||||||
// 确定要分配的菜单
|
|
||||||
const menusToAssign = isSuperTenant
|
|
||||||
? allMenus.filter(m => superTenantMenuIds.has(m.id))
|
|
||||||
: allMenus.filter(m => normalTenantMenuIds.has(m.id));
|
|
||||||
|
|
||||||
// 为租户分配菜单
|
|
||||||
let addedMenuCount = 0;
|
|
||||||
for (const menu of menusToAssign) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
menuId: menu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
addedMenuCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantType = isSuperTenant ? '(超级租户)' : '(普通租户)';
|
|
||||||
console.log(
|
|
||||||
` ✓ 租户 "${tenant.name}" ${tenantType}: 分配了 ${addedMenuCount} 个菜单`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`\n✅ 菜单分配完成!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ 菜单初始化完成!`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n💥 初始化菜单失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行初始化
|
|
||||||
initMenus()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 菜单初始化脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 菜单初始化脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,576 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('DATABASE_URL not found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 权限定义
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 基础权限(所有角色共享的权限池)
|
|
||||||
const allPermissions = [
|
|
||||||
// AI 3D建模
|
|
||||||
{ code: 'ai-3d:read', resource: 'ai-3d', action: 'read', name: '使用3D建模实验室', description: '允许使用AI 3D建模实验室' },
|
|
||||||
{ code: 'ai-3d:create', resource: 'ai-3d', action: 'create', name: '创建3D模型任务', description: '允许创建AI 3D模型生成任务' },
|
|
||||||
|
|
||||||
// 用户管理
|
|
||||||
{ 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: 'tenant:create', resource: 'tenant', action: 'create', name: '创建租户', description: '允许创建租户' },
|
|
||||||
{ code: 'tenant:read', resource: 'tenant', action: 'read', name: '查看租户', description: '允许查看租户列表' },
|
|
||||||
{ code: 'tenant:update', resource: 'tenant', action: 'update', name: '更新租户', description: '允许更新租户信息' },
|
|
||||||
{ code: 'tenant:delete', resource: 'tenant', 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: '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: '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: '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:finish', resource: 'contest', action: 'finish', name: '结束活动', description: '允许结束活动' },
|
|
||||||
|
|
||||||
// 评审规则管理
|
|
||||||
{ code: 'review-rule:create', resource: 'review-rule', action: 'create', name: '创建评审规则', description: '允许创建评审规则' },
|
|
||||||
{ code: 'review-rule:read', resource: 'review-rule', action: 'read', name: '查看评审规则', description: '允许查看评审规则' },
|
|
||||||
{ code: 'review-rule:update', resource: 'review-rule', action: 'update', name: '更新评审规则', description: '允许更新评审规则' },
|
|
||||||
{ code: 'review-rule:delete', resource: 'review-rule', action: 'delete', name: '删除评审规则', description: '允许删除评审规则' },
|
|
||||||
|
|
||||||
// 评委管理
|
|
||||||
{ code: 'judge:create', resource: 'judge', action: 'create', name: '添加评委', description: '允许添加评委' },
|
|
||||||
{ code: 'judge:read', resource: 'judge', action: 'read', name: '查看评委', description: '允许查看评委列表' },
|
|
||||||
{ code: 'judge:update', resource: 'judge', action: 'update', name: '更新评委', description: '允许更新评委信息' },
|
|
||||||
{ code: 'judge:delete', resource: 'judge', action: 'delete', name: '删除评委', description: '允许删除评委' },
|
|
||||||
{ code: 'judge:assign', resource: 'judge', action: 'assign', name: '分配评委', description: '允许为活动分配评委' },
|
|
||||||
|
|
||||||
// 活动报名(学校端)
|
|
||||||
{ code: 'registration:create', resource: 'registration', action: 'create', name: '创建报名', description: '允许报名活动' },
|
|
||||||
{ code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' },
|
|
||||||
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
|
||||||
{ code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' },
|
|
||||||
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
|
||||||
{ code: 'registration:audit', resource: 'registration', action: 'audit', name: '审核报名记录', description: '允许审核报名记录' },
|
|
||||||
|
|
||||||
// 参赛作品
|
|
||||||
{ code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' },
|
|
||||||
{ code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' },
|
|
||||||
{ code: 'work:update', resource: 'work', action: 'update', name: '更新作品', description: '允许更新作品信息' },
|
|
||||||
{ code: 'work:delete', resource: 'work', action: 'delete', name: '删除作品', description: '允许删除作品' },
|
|
||||||
{ code: 'work:submit', resource: 'work', action: 'submit', name: '提交作品', description: '允许提交作品' },
|
|
||||||
|
|
||||||
// 作品评审(评委端)
|
|
||||||
{ code: 'review:read', resource: 'review', action: 'read', name: '查看评审任务', description: '允许查看待评审作品' },
|
|
||||||
{ code: 'review:score', resource: 'review', action: 'score', name: '评审打分', description: '允许对作品打分' },
|
|
||||||
{ code: 'review:assign', resource: 'review', action: 'assign', name: '分配评审', description: '允许分配作品给评委' },
|
|
||||||
|
|
||||||
// 成果管理
|
|
||||||
{ code: 'result:read', resource: 'result', action: 'read', name: '查看成果', description: '允许查看活动结果' },
|
|
||||||
{ code: 'result:publish', resource: 'result', action: 'publish', name: '发布成果', description: '允许发布活动结果' },
|
|
||||||
{ code: 'result:award', resource: 'result', action: 'award', name: '设置奖项', description: '允许设置奖项等级' },
|
|
||||||
|
|
||||||
// 活动公告
|
|
||||||
{ code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建活动公告' },
|
|
||||||
{ code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看活动公告' },
|
|
||||||
{ code: 'notice:update', resource: 'notice', action: 'update', name: '更新公告', description: '允许更新公告信息' },
|
|
||||||
{ code: 'notice:delete', resource: 'notice', action: 'delete', name: '删除公告', description: '允许删除公告' },
|
|
||||||
|
|
||||||
// 作业管理
|
|
||||||
{ code: 'homework:create', resource: 'homework', action: 'create', name: '创建作业', description: '允许创建作业' },
|
|
||||||
{ code: 'homework:read', resource: 'homework', action: 'read', name: '查看作业', description: '允许查看作业列表' },
|
|
||||||
{ code: 'homework:update', resource: 'homework', action: 'update', name: '更新作业', description: '允许更新作业信息' },
|
|
||||||
{ code: 'homework:delete', resource: 'homework', action: 'delete', name: '删除作业', description: '允许删除作业' },
|
|
||||||
{ code: 'homework:publish', resource: 'homework', action: 'publish', name: '发布作业', description: '允许发布作业' },
|
|
||||||
|
|
||||||
// 作业提交
|
|
||||||
{ code: 'homework-submission:create', resource: 'homework-submission', action: 'create', name: '提交作业', description: '允许提交作业' },
|
|
||||||
{ code: 'homework-submission:read', resource: 'homework-submission', action: 'read', name: '查看作业提交', description: '允许查看作业提交记录' },
|
|
||||||
{ code: 'homework-submission:update', resource: 'homework-submission', action: 'update', name: '更新作业提交', description: '允许更新提交的作业' },
|
|
||||||
|
|
||||||
// 作业评审规则
|
|
||||||
{ code: 'homework-review-rule:create', resource: 'homework-review-rule', action: 'create', name: '创建作业评审规则', description: '允许创建作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:read', resource: 'homework-review-rule', action: 'read', name: '查看作业评审规则', description: '允许查看作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:update', resource: 'homework-review-rule', action: 'update', name: '更新作业评审规则', description: '允许更新作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:delete', resource: 'homework-review-rule', action: 'delete', name: '删除作业评审规则', description: '允许删除作业评审规则' },
|
|
||||||
|
|
||||||
// 作业评分
|
|
||||||
{ code: 'homework-score:create', resource: 'homework-score', action: 'create', name: '作业评分', description: '允许对作业评分' },
|
|
||||||
{ code: 'homework-score:read', resource: 'homework-score', action: 'read', 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: 'activity:read', resource: 'activity', action: 'read', name: '查看我的评审', description: '允许查看已发布的我的评审' },
|
|
||||||
{ code: 'activity:guidance', resource: 'activity', action: 'guidance', name: '指导学生', description: '允许指导学生参赛' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 角色定义和权限映射
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 超级租户角色
|
|
||||||
const superTenantRoles = [
|
|
||||||
{
|
|
||||||
code: 'super_admin',
|
|
||||||
name: '超级管理员',
|
|
||||||
description: '系统超级管理员,管理活动和系统配置',
|
|
||||||
permissions: [
|
|
||||||
// 系统管理
|
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete', 'user:password:update',
|
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
|
||||||
'permission:create', 'permission:read', 'permission:update', 'permission:delete',
|
|
||||||
'menu:create', 'menu:read', 'menu:update', 'menu:delete',
|
|
||||||
'tenant:create', 'tenant:read', 'tenant:update', 'tenant:delete',
|
|
||||||
'dict:create', 'dict:read', 'dict:update', 'dict:delete',
|
|
||||||
'config:create', 'config:read', 'config:update', 'config:delete',
|
|
||||||
'log:read', 'log:delete',
|
|
||||||
// 活动管理
|
|
||||||
'contest:create', 'contest:read', 'contest:update', 'contest:delete', 'contest:publish', 'contest:finish',
|
|
||||||
'review-rule:create', 'review-rule:read', 'review-rule:update', 'review-rule:delete',
|
|
||||||
'judge:create', 'judge:read', 'judge:update', 'judge:delete', 'judge:assign',
|
|
||||||
'registration:read', 'registration:approve', 'registration:audit',
|
|
||||||
'work:read', 'work:update',
|
|
||||||
'review:read', 'review:assign',
|
|
||||||
'result:read', 'result:publish', 'result:award',
|
|
||||||
'notice:create', 'notice:read', 'notice:update', 'notice:delete',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'judge',
|
|
||||||
name: '评委',
|
|
||||||
description: '活动评委,可以评审作品',
|
|
||||||
permissions: [
|
|
||||||
'activity:read', // 查看我的评审
|
|
||||||
'work:read', // 查看待评审作品
|
|
||||||
'review:read', // 查看评审任务
|
|
||||||
'review:score', // 评审打分
|
|
||||||
'notice:read', // 查看公告
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 普通租户(学校)角色
|
|
||||||
const normalTenantRoles = [
|
|
||||||
{
|
|
||||||
code: 'school_admin',
|
|
||||||
name: '学校管理员',
|
|
||||||
description: '学校管理员,管理学校信息、教师、学生等',
|
|
||||||
permissions: [
|
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
|
||||||
'permission:read',
|
|
||||||
'menu:read',
|
|
||||||
// 学校管理
|
|
||||||
'school:create', 'school:read', 'school:update', 'school:delete',
|
|
||||||
'department:create', 'department:read', 'department:update', 'department:delete',
|
|
||||||
'grade:create', 'grade:read', 'grade:update', 'grade:delete',
|
|
||||||
'class:create', 'class:read', 'class:update', 'class:delete',
|
|
||||||
'teacher:create', 'teacher:read', 'teacher:update', 'teacher:delete',
|
|
||||||
'student:create', 'student:read', 'student:update', 'student:delete',
|
|
||||||
// 我的评审
|
|
||||||
'activity:read',
|
|
||||||
'notice:read',
|
|
||||||
// 可以查看报名和作品
|
|
||||||
'registration:read',
|
|
||||||
'work:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'teacher',
|
|
||||||
name: '教师',
|
|
||||||
description: '教师角色,可以报名活动、指导学生、管理作业',
|
|
||||||
permissions: [
|
|
||||||
// AI 3D建模(工作台入口)
|
|
||||||
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
|
|
||||||
// 查看基础信息
|
|
||||||
'grade:read',
|
|
||||||
'class:read',
|
|
||||||
'student:read',
|
|
||||||
// 我的评审
|
|
||||||
'activity:read', // 查看我的评审列表
|
|
||||||
'activity:guidance', // 指导学生参赛
|
|
||||||
'notice:read', // 查看活动公告
|
|
||||||
'registration:create', 'registration:read', 'registration:update', 'registration:delete', // 报名管理
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit', // 指导学生上传作品
|
|
||||||
// 作业管理
|
|
||||||
'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish',
|
|
||||||
'homework-submission:read',
|
|
||||||
'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete',
|
|
||||||
'homework-score:create', 'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'student',
|
|
||||||
name: '学生',
|
|
||||||
description: '学生角色,可以查看活动、上传作品、提交作业',
|
|
||||||
permissions: [
|
|
||||||
// AI 3D建模(工作台入口)
|
|
||||||
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
|
|
||||||
// 我的评审
|
|
||||||
'activity:read', // 查看我的评审列表
|
|
||||||
'notice:read', // 查看活动公告
|
|
||||||
'registration:read', // 查看自己的报名记录
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit', // 上传/管理自己的作品
|
|
||||||
// 作业
|
|
||||||
'homework:read', // 查看作业
|
|
||||||
'homework-submission:create', 'homework-submission:read', 'homework-submission:update', // 提交作业
|
|
||||||
'homework-score:read', // 查看自己的作业评分
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 初始化函数
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为租户创建权限
|
|
||||||
*/
|
|
||||||
async function createPermissions(tenantId: number, permissionCodes: string[]) {
|
|
||||||
const createdPermissions: { [code: string]: number } = {};
|
|
||||||
|
|
||||||
for (const code of permissionCodes) {
|
|
||||||
const permDef = allPermissions.find(p => p.code === code);
|
|
||||||
if (!permDef) {
|
|
||||||
console.log(` ⚠️ 权限定义不存在: ${code}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已存在
|
|
||||||
let permission = await prisma.permission.findFirst({
|
|
||||||
where: { tenantId, code },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!permission) {
|
|
||||||
permission = await prisma.permission.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
code: permDef.code,
|
|
||||||
resource: permDef.resource,
|
|
||||||
action: permDef.action,
|
|
||||||
name: permDef.name,
|
|
||||||
description: permDef.description,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建权限: ${code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
createdPermissions[code] = permission.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdPermissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为租户创建角色并分配权限
|
|
||||||
*/
|
|
||||||
async function createRoleWithPermissions(
|
|
||||||
tenantId: number,
|
|
||||||
roleConfig: { code: string; name: string; description: string; permissions: string[] },
|
|
||||||
permissionMap: { [code: string]: number }
|
|
||||||
) {
|
|
||||||
// 创建或获取角色
|
|
||||||
let role = await prisma.role.findFirst({
|
|
||||||
where: { tenantId, code: roleConfig.code },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
role = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
code: roleConfig.code,
|
|
||||||
name: roleConfig.name,
|
|
||||||
description: roleConfig.description,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建角色: ${roleConfig.name} (${roleConfig.code})`);
|
|
||||||
} else {
|
|
||||||
// 更新角色信息
|
|
||||||
role = await prisma.role.update({
|
|
||||||
where: { id: role.id },
|
|
||||||
data: {
|
|
||||||
name: roleConfig.name,
|
|
||||||
description: roleConfig.description,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(` ✓ 更新角色: ${roleConfig.name} (${roleConfig.code})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分配权限
|
|
||||||
const existingRolePermissions = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId: role.id },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingPermissionIds = new Set(existingRolePermissions.map(rp => rp.permissionId));
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
for (const permCode of roleConfig.permissions) {
|
|
||||||
const permissionId = permissionMap[permCode];
|
|
||||||
if (!permissionId) {
|
|
||||||
console.log(` ⚠️ 权限不存在: ${permCode}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingPermissionIds.has(permissionId)) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: {
|
|
||||||
roleId: role.id,
|
|
||||||
permissionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedCount > 0) {
|
|
||||||
console.log(` 添加了 ${addedCount} 个权限`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化超级租户的角色和权限
|
|
||||||
*/
|
|
||||||
async function initSuperTenantRoles() {
|
|
||||||
console.log('\n🚀 开始初始化超级租户角色和权限...\n');
|
|
||||||
|
|
||||||
// 查找超级租户
|
|
||||||
const superTenant = await prisma.tenant.findFirst({
|
|
||||||
where: { isSuper: 1, validState: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!superTenant) {
|
|
||||||
console.error('❌ 超级租户不存在!请先运行 init:super-tenant');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`找到超级租户: ${superTenant.name} (${superTenant.code})\n`);
|
|
||||||
|
|
||||||
// 收集所有需要的权限码
|
|
||||||
const allPermissionCodes = new Set<string>();
|
|
||||||
superTenantRoles.forEach(role => {
|
|
||||||
role.permissions.forEach(code => allPermissionCodes.add(code));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建权限
|
|
||||||
console.log('📝 创建权限...');
|
|
||||||
const permissionMap = await createPermissions(superTenant.id, Array.from(allPermissionCodes));
|
|
||||||
console.log(`✅ 共 ${Object.keys(permissionMap).length} 个权限\n`);
|
|
||||||
|
|
||||||
// 创建角色
|
|
||||||
console.log('👥 创建角色...');
|
|
||||||
for (const roleConfig of superTenantRoles) {
|
|
||||||
await createRoleWithPermissions(superTenant.id, roleConfig, permissionMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ 超级租户角色和权限初始化完成!');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化普通租户的角色和权限
|
|
||||||
*/
|
|
||||||
async function initNormalTenantRoles(tenantCode: string) {
|
|
||||||
console.log(`\n🚀 开始初始化租户 "${tenantCode}" 的角色和权限...\n`);
|
|
||||||
|
|
||||||
// 查找租户
|
|
||||||
const tenant = await prisma.tenant.findFirst({
|
|
||||||
where: { code: tenantCode, validState: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
console.error(`❌ 租户 "${tenantCode}" 不存在!`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenant.isSuper === 1) {
|
|
||||||
console.log('⚠️ 这是超级租户,请使用 --super 选项');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`找到租户: ${tenant.name} (${tenant.code})\n`);
|
|
||||||
|
|
||||||
// 收集所有需要的权限码
|
|
||||||
const allPermissionCodes = new Set<string>();
|
|
||||||
normalTenantRoles.forEach(role => {
|
|
||||||
role.permissions.forEach(code => allPermissionCodes.add(code));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建权限
|
|
||||||
console.log('📝 创建权限...');
|
|
||||||
const permissionMap = await createPermissions(tenant.id, Array.from(allPermissionCodes));
|
|
||||||
console.log(`✅ 共 ${Object.keys(permissionMap).length} 个权限\n`);
|
|
||||||
|
|
||||||
// 创建角色
|
|
||||||
console.log('👥 创建角色...');
|
|
||||||
for (const roleConfig of normalTenantRoles) {
|
|
||||||
await createRoleWithPermissions(tenant.id, roleConfig, permissionMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输出角色信息
|
|
||||||
console.log('\n📊 角色权限概览:');
|
|
||||||
for (const roleConfig of normalTenantRoles) {
|
|
||||||
console.log(` ${roleConfig.name} (${roleConfig.code}): ${roleConfig.permissions.length} 个权限`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ 租户 "${tenantCode}" 角色和权限初始化完成!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化所有普通租户的角色和权限
|
|
||||||
*/
|
|
||||||
async function initAllNormalTenantRoles() {
|
|
||||||
console.log('\n🚀 开始初始化所有普通租户的角色和权限...\n');
|
|
||||||
|
|
||||||
// 查找所有普通租户
|
|
||||||
const normalTenants = await prisma.tenant.findMany({
|
|
||||||
where: { isSuper: { not: 1 }, validState: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (normalTenants.length === 0) {
|
|
||||||
console.log('⚠️ 没有找到普通租户');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`找到 ${normalTenants.length} 个普通租户\n`);
|
|
||||||
|
|
||||||
for (const tenant of normalTenants) {
|
|
||||||
await initNormalTenantRoles(tenant.code);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ 所有普通租户角色和权限初始化完成!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 主函数
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const isSuper = args.includes('--super');
|
|
||||||
const isAll = args.includes('--all');
|
|
||||||
const tenantCode = args.find(arg => !arg.startsWith('--'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isSuper) {
|
|
||||||
await initSuperTenantRoles();
|
|
||||||
} else if (isAll) {
|
|
||||||
await initAllNormalTenantRoles();
|
|
||||||
} else if (tenantCode) {
|
|
||||||
await initNormalTenantRoles(tenantCode);
|
|
||||||
} else {
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log(' 初始化超级租户角色: ts-node scripts/init-roles-permissions.ts --super');
|
|
||||||
console.log(' 初始化指定租户角色: ts-node scripts/init-roles-permissions.ts <租户编码>');
|
|
||||||
console.log(' 初始化所有普通租户: ts-node scripts/init-roles-permissions.ts --all');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,322 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 加载环境变量(必须在其他导入之前)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
// scripts 目录的父目录就是 backend 目录
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
// 尝试加载环境特定的配置文件
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
// 如果环境特定文件不存在,尝试加载默认的 .env 文件
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必要的环境变量
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
console.error(` 请确保存在以下文件之一:`);
|
|
||||||
console.error(` - ${envPath}`);
|
|
||||||
console.error(` - ${path.resolve(backendDir, '.env')}`);
|
|
||||||
console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('🚀 开始初始化超级租户...\n');
|
|
||||||
|
|
||||||
// 检查是否已存在超级租户
|
|
||||||
let superTenant = await prisma.tenant.findFirst({
|
|
||||||
where: { isSuper: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (superTenant) {
|
|
||||||
console.log('⚠️ 超级租户已存在,将更新菜单分配');
|
|
||||||
console.log(` 租户编码: ${superTenant.code}\n`);
|
|
||||||
} else {
|
|
||||||
// 创建超级租户
|
|
||||||
superTenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: '超级租户',
|
|
||||||
code: 'super',
|
|
||||||
domain: 'super',
|
|
||||||
description: '系统超级租户,拥有所有权限',
|
|
||||||
isSuper: 1,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ 超级租户创建成功!');
|
|
||||||
console.log(` 租户ID: ${superTenant.id}`);
|
|
||||||
console.log(` 租户编码: ${superTenant.code}`);
|
|
||||||
console.log(` 租户名称: ${superTenant.name}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建或获取超级管理员用户
|
|
||||||
console.log('📋 步骤 2: 创建或获取超级管理员用户...\n');
|
|
||||||
let superAdmin = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
username: 'admin',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!superAdmin) {
|
|
||||||
const hashedPassword = await bcrypt.hash('admin@super', 10);
|
|
||||||
superAdmin = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
username: 'admin',
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: '超级管理员',
|
|
||||||
email: 'admin@super.com',
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 超级管理员用户创建成功!');
|
|
||||||
console.log(` 用户名: ${superAdmin.username}`);
|
|
||||||
console.log(` 密码: admin@super`);
|
|
||||||
console.log(` 用户ID: ${superAdmin.id}\n`);
|
|
||||||
} else {
|
|
||||||
console.log('✅ 超级管理员用户已存在');
|
|
||||||
console.log(` 用户名: ${superAdmin.username}`);
|
|
||||||
console.log(` 用户ID: ${superAdmin.id}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建或获取超级管理员角色
|
|
||||||
console.log('📋 步骤 3: 创建或获取超级管理员角色...\n');
|
|
||||||
let superAdminRole = await prisma.role.findFirst({
|
|
||||||
where: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
code: 'super_admin',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!superAdminRole) {
|
|
||||||
superAdminRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
name: '超级管理员',
|
|
||||||
code: 'super_admin',
|
|
||||||
description: '超级管理员角色,拥有所有权限',
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 超级管理员角色创建成功!');
|
|
||||||
console.log(` 角色编码: ${superAdminRole.code}\n`);
|
|
||||||
} else {
|
|
||||||
console.log('✅ 超级管理员角色已存在');
|
|
||||||
console.log(` 角色编码: ${superAdminRole.code}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将超级管理员角色分配给用户
|
|
||||||
const existingUserRole = await prisma.userRole.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_roleId: {
|
|
||||||
userId: superAdmin.id,
|
|
||||||
roleId: superAdminRole.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingUserRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
userId: superAdmin.id,
|
|
||||||
roleId: superAdminRole.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 超级管理员角色已分配给用户');
|
|
||||||
} else {
|
|
||||||
console.log('✅ 超级管理员角色已分配给用户,跳过');
|
|
||||||
}
|
|
||||||
console.log('💡 提示: 权限初始化请使用 init:admin:permissions 脚本\n');
|
|
||||||
|
|
||||||
// 为超级租户分配所有菜单
|
|
||||||
console.log('📋 步骤 4: 为超级租户分配所有菜单...\n');
|
|
||||||
|
|
||||||
// 获取所有有效菜单
|
|
||||||
const allMenus = await prisma.menu.findMany({
|
|
||||||
where: {
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allMenus.length === 0) {
|
|
||||||
console.log('⚠️ 警告: 数据库中没有任何菜单');
|
|
||||||
console.log(' 请先运行 pnpm init:menus 初始化菜单');
|
|
||||||
} else {
|
|
||||||
console.log(` 找到 ${allMenus.length} 个菜单\n`);
|
|
||||||
|
|
||||||
// 获取超级租户已分配的菜单
|
|
||||||
const existingTenantMenus = await prisma.tenantMenu.findMany({
|
|
||||||
where: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
menuId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingMenuIds = new Set(existingTenantMenus.map((tm) => tm.menuId));
|
|
||||||
|
|
||||||
// 为超级租户分配所有菜单(包括新增的菜单)
|
|
||||||
let addedCount = 0;
|
|
||||||
const menuNames: string[] = [];
|
|
||||||
|
|
||||||
for (const menu of allMenus) {
|
|
||||||
if (!existingMenuIds.has(menu.id)) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
menuId: menu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
menuNames.push(menu.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedCount > 0) {
|
|
||||||
console.log(`✅ 为超级租户添加了 ${addedCount} 个菜单:`);
|
|
||||||
menuNames.forEach((name) => {
|
|
||||||
console.log(` ✓ ${name}`);
|
|
||||||
});
|
|
||||||
console.log(`\n✅ 超级租户现在拥有 ${allMenus.length} 个菜单\n`);
|
|
||||||
} else {
|
|
||||||
console.log(`✅ 超级租户已拥有所有菜单(${allMenus.length} 个)\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建租户管理菜单(如果不存在)
|
|
||||||
console.log('📋 步骤 5: 创建租户管理菜单(如果不存在)...\n');
|
|
||||||
|
|
||||||
// 查找系统管理菜单(父菜单)
|
|
||||||
const systemMenu = await prisma.menu.findFirst({
|
|
||||||
where: {
|
|
||||||
name: '系统管理',
|
|
||||||
parentId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (systemMenu) {
|
|
||||||
// 检查租户管理菜单是否已存在
|
|
||||||
const existingTenantMenu = await prisma.menu.findFirst({
|
|
||||||
where: {
|
|
||||||
name: '租户管理',
|
|
||||||
path: '/system/tenants',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let tenantMenu;
|
|
||||||
if (!existingTenantMenu) {
|
|
||||||
tenantMenu = await prisma.menu.create({
|
|
||||||
data: {
|
|
||||||
name: '租户管理',
|
|
||||||
path: '/system/tenants',
|
|
||||||
icon: 'TeamOutlined',
|
|
||||||
component: 'system/tenants/Index',
|
|
||||||
parentId: systemMenu.id,
|
|
||||||
permission: 'tenant:update', // 只有超级租户才有此权限,普通租户只有 tenant:read
|
|
||||||
sort: 7,
|
|
||||||
validState: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 租户管理菜单创建成功');
|
|
||||||
|
|
||||||
// 为超级租户分配租户管理菜单
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
menuId: tenantMenu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 租户管理菜单已分配给超级租户\n');
|
|
||||||
} else {
|
|
||||||
tenantMenu = existingTenantMenu;
|
|
||||||
console.log('✅ 租户管理菜单已存在');
|
|
||||||
|
|
||||||
// 检查是否已分配
|
|
||||||
const existingTenantMenuRelation = await prisma.tenantMenu.findFirst({
|
|
||||||
where: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
menuId: tenantMenu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingTenantMenuRelation) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
menuId: tenantMenu.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✅ 租户管理菜单已分配给超级租户\n');
|
|
||||||
} else {
|
|
||||||
console.log('✅ 租户管理菜单已分配给超级租户,跳过\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ 警告:未找到系统管理菜单,无法创建租户管理菜单\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证菜单分配结果
|
|
||||||
const finalMenus = await prisma.tenantMenu.findMany({
|
|
||||||
where: {
|
|
||||||
tenantId: superTenant.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
menu: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('📊 初始化结果:');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('超级租户信息:');
|
|
||||||
console.log(` 租户编码: ${superTenant.code}`);
|
|
||||||
console.log(` 租户名称: ${superTenant.name}`);
|
|
||||||
console.log(` 访问链接: http://your-domain.com/?tenant=${superTenant.code}`);
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('超级管理员登录信息:');
|
|
||||||
console.log(` 用户名: ${superAdmin.username}`);
|
|
||||||
console.log(` 密码: admin@super`);
|
|
||||||
console.log(` 租户编码: ${superTenant.code}`);
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('菜单分配情况:');
|
|
||||||
console.log(` 已分配菜单数: ${finalMenus.length}`);
|
|
||||||
if (finalMenus.length > 0) {
|
|
||||||
const topLevelMenus = finalMenus.filter((tm) => !tm.menu.parentId);
|
|
||||||
console.log(` 顶级菜单数: ${topLevelMenus.length}`);
|
|
||||||
}
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('\n💡 提示:');
|
|
||||||
console.log(' 权限初始化请使用: pnpm init:admin:permissions');
|
|
||||||
console.log(' 菜单初始化请使用: pnpm init:menus');
|
|
||||||
console.log('========================================');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 初始化脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 初始化脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,429 +0,0 @@
|
|||||||
// 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,429 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
// 初始化普通租户脚本(包含角色)
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// 根据 NODE_ENV 加载对应的环境配置文件
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('❌ 错误: 未找到 DATABASE_URL 环境变量');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import * as readline from 'readline';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 权限定义
|
|
||||||
// ============================================
|
|
||||||
const allPermissions = [
|
|
||||||
// 工作台
|
|
||||||
{ code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' },
|
|
||||||
|
|
||||||
// 用户管理
|
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
|
|
||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
|
||||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' },
|
|
||||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' },
|
|
||||||
|
|
||||||
// 角色管理
|
|
||||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' },
|
|
||||||
{ code: 'role:read', resource: 'role', action: 'read', name: '查看角色', description: '允许查看角色列表和详情' },
|
|
||||||
{ code: 'role:update', resource: 'role', action: 'update', name: '更新角色', description: '允许更新角色信息' },
|
|
||||||
{ code: 'role:delete', resource: 'role', action: 'delete', name: '删除角色', description: '允许删除角色' },
|
|
||||||
{ code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' },
|
|
||||||
|
|
||||||
// 权限管理
|
|
||||||
{ code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' },
|
|
||||||
|
|
||||||
// 菜单管理
|
|
||||||
{ code: 'menu:read', resource: 'menu', action: 'read', 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: '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: '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: '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: 'activity:read', resource: 'activity', action: 'read', name: '查看我的评审', description: '允许查看已发布的我的评审' },
|
|
||||||
{ code: 'activity:guidance', resource: 'activity', action: 'guidance', name: '指导学生', description: '允许指导学生参赛' },
|
|
||||||
|
|
||||||
// 活动报名
|
|
||||||
{ code: 'registration:create', resource: 'registration', action: 'create', name: '创建报名', description: '允许报名活动' },
|
|
||||||
{ code: 'registration:read', resource: 'registration', action: 'read', name: '查看报名', description: '允许查看报名记录' },
|
|
||||||
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
|
||||||
{ code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' },
|
|
||||||
|
|
||||||
// 参赛作品
|
|
||||||
{ code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' },
|
|
||||||
{ code: 'work:read', resource: 'work', action: 'read', name: '查看作品', description: '允许查看参赛作品' },
|
|
||||||
{ code: 'work:update', resource: 'work', action: 'update', name: '更新作品', description: '允许更新作品信息' },
|
|
||||||
{ code: 'work:delete', resource: 'work', action: 'delete', name: '删除作品', description: '允许删除作品' },
|
|
||||||
{ code: 'work:submit', resource: 'work', action: 'submit', name: '提交作品', description: '允许提交作品' },
|
|
||||||
|
|
||||||
// 活动公告
|
|
||||||
{ code: 'notice:read', resource: 'notice', action: 'read', name: '查看公告', description: '允许查看活动公告' },
|
|
||||||
|
|
||||||
// 作业管理
|
|
||||||
{ code: 'homework:create', resource: 'homework', action: 'create', name: '创建作业', description: '允许创建作业' },
|
|
||||||
{ code: 'homework:read', resource: 'homework', action: 'read', name: '查看作业', description: '允许查看作业列表' },
|
|
||||||
{ code: 'homework:update', resource: 'homework', action: 'update', name: '更新作业', description: '允许更新作业信息' },
|
|
||||||
{ code: 'homework:delete', resource: 'homework', action: 'delete', name: '删除作业', description: '允许删除作业' },
|
|
||||||
{ code: 'homework:publish', resource: 'homework', action: 'publish', name: '发布作业', description: '允许发布作业' },
|
|
||||||
|
|
||||||
// 作业提交
|
|
||||||
{ code: 'homework-submission:create', resource: 'homework-submission', action: 'create', name: '提交作业', description: '允许提交作业' },
|
|
||||||
{ code: 'homework-submission:read', resource: 'homework-submission', action: 'read', name: '查看作业提交', description: '允许查看作业提交记录' },
|
|
||||||
{ code: 'homework-submission:update', resource: 'homework-submission', action: 'update', name: '更新作业提交', description: '允许更新提交的作业' },
|
|
||||||
|
|
||||||
// 作业评审规则
|
|
||||||
{ code: 'homework-review-rule:create', resource: 'homework-review-rule', action: 'create', name: '创建作业评审规则', description: '允许创建作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:read', resource: 'homework-review-rule', action: 'read', name: '查看作业评审规则', description: '允许查看作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:update', resource: 'homework-review-rule', action: 'update', name: '更新作业评审规则', description: '允许更新作业评审规则' },
|
|
||||||
{ code: 'homework-review-rule:delete', resource: 'homework-review-rule', action: 'delete', name: '删除作业评审规则', description: '允许删除作业评审规则' },
|
|
||||||
|
|
||||||
// 作业评分
|
|
||||||
{ code: 'homework-score:create', resource: 'homework-score', action: 'create', name: '作业评分', description: '允许对作业评分' },
|
|
||||||
{ code: 'homework-score:read', resource: 'homework-score', action: 'read', name: '查看作业评分', description: '允许查看作业评分' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 角色定义
|
|
||||||
// ============================================
|
|
||||||
const normalTenantRoles = [
|
|
||||||
{
|
|
||||||
code: 'school_admin',
|
|
||||||
name: '学校管理员',
|
|
||||||
description: '学校管理员,管理学校信息、教师、学生等',
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
|
||||||
'permission:read',
|
|
||||||
'menu:read',
|
|
||||||
// 学校管理
|
|
||||||
'school:create', 'school:read', 'school:update', 'school:delete',
|
|
||||||
'department:create', 'department:read', 'department:update', 'department:delete',
|
|
||||||
'grade:create', 'grade:read', 'grade:update', 'grade:delete',
|
|
||||||
'class:create', 'class:read', 'class:update', 'class:delete',
|
|
||||||
'teacher:create', 'teacher:read', 'teacher:update', 'teacher:delete',
|
|
||||||
'student:create', 'student:read', 'student:update', 'student:delete',
|
|
||||||
// 我的评审
|
|
||||||
'activity:read',
|
|
||||||
'notice:read',
|
|
||||||
// 可以查看报名和作品
|
|
||||||
'registration:read',
|
|
||||||
'work:read',
|
|
||||||
// 作业管理
|
|
||||||
'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish',
|
|
||||||
'homework-submission:read',
|
|
||||||
'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete',
|
|
||||||
'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'teacher',
|
|
||||||
name: '教师',
|
|
||||||
description: '教师角色,可以报名活动、指导学生、管理作业',
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
// 查看基础信息
|
|
||||||
'grade:read',
|
|
||||||
'class:read',
|
|
||||||
'student:read',
|
|
||||||
// 我的评审
|
|
||||||
'activity:read',
|
|
||||||
'activity:guidance',
|
|
||||||
'notice:read',
|
|
||||||
'registration:create', 'registration:read', 'registration:update', 'registration:delete',
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit',
|
|
||||||
// 作业管理
|
|
||||||
'homework:create', 'homework:read', 'homework:update', 'homework:delete', 'homework:publish',
|
|
||||||
'homework-submission:read',
|
|
||||||
'homework-review-rule:create', 'homework-review-rule:read', 'homework-review-rule:update', 'homework-review-rule:delete',
|
|
||||||
'homework-score:create', 'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'student',
|
|
||||||
name: '学生',
|
|
||||||
description: '学生角色,可以查看活动、上传作品、提交作业',
|
|
||||||
permissions: [
|
|
||||||
'workbench:read',
|
|
||||||
// 我的评审
|
|
||||||
'activity:read',
|
|
||||||
'notice:read',
|
|
||||||
'registration:read',
|
|
||||||
'work:create', 'work:read', 'work:update', 'work:submit',
|
|
||||||
// 作业
|
|
||||||
'homework:read',
|
|
||||||
'homework-submission:create', 'homework-submission:read', 'homework-submission:update',
|
|
||||||
'homework-score:read',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 创建 readline 接口用于用户输入
|
|
||||||
function createReadlineInterface(): readline.Interface {
|
|
||||||
return readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提示用户输入
|
|
||||||
function prompt(rl: readline.Interface, question: string): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.question(question, (answer) => {
|
|
||||||
resolve(answer.trim());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initTenant() {
|
|
||||||
const rl = createReadlineInterface();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始创建普通租户...\n');
|
|
||||||
|
|
||||||
// 获取租户信息
|
|
||||||
const tenantName = await prompt(rl, '请输入租户名称: ');
|
|
||||||
if (!tenantName) {
|
|
||||||
console.error('❌ 错误: 租户名称不能为空');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantCode = await prompt(rl, '请输入租户编码(英文): ');
|
|
||||||
if (!tenantCode) {
|
|
||||||
console.error('❌ 错误: 租户编码不能为空');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查租户编码是否已存在
|
|
||||||
const existingTenant = await prisma.tenant.findFirst({
|
|
||||||
where: { code: tenantCode }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingTenant) {
|
|
||||||
console.error(`❌ 错误: 租户编码 "${tenantCode}" 已存在`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
// 1. 创建租户
|
|
||||||
console.log('\n🏢 步骤 1: 创建租户...');
|
|
||||||
const tenant = await prisma.tenant.create({
|
|
||||||
data: {
|
|
||||||
name: tenantName,
|
|
||||||
code: tenantCode,
|
|
||||||
isSuper: 0,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建租户: ${tenant.name} (${tenant.code})`);
|
|
||||||
|
|
||||||
const tenantId = tenant.id;
|
|
||||||
|
|
||||||
// 2. 创建权限
|
|
||||||
console.log('\n📝 步骤 2: 创建基础权限...');
|
|
||||||
const createdPermissions: { [code: string]: number } = {};
|
|
||||||
|
|
||||||
for (const perm of allPermissions) {
|
|
||||||
const permission = await prisma.permission.create({
|
|
||||||
data: { ...perm, tenantId, validState: 1 }
|
|
||||||
});
|
|
||||||
createdPermissions[perm.code] = permission.id;
|
|
||||||
}
|
|
||||||
console.log(` ✓ 共创建 ${Object.keys(createdPermissions).length} 个权限`);
|
|
||||||
|
|
||||||
// 3. 创建角色并分配权限
|
|
||||||
console.log('\n👥 步骤 3: 创建角色并分配权限...');
|
|
||||||
const createdRoles: any[] = [];
|
|
||||||
|
|
||||||
for (const roleConfig of normalTenantRoles) {
|
|
||||||
// 创建角色
|
|
||||||
const role = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
name: roleConfig.name,
|
|
||||||
code: roleConfig.code,
|
|
||||||
description: roleConfig.description,
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 分配权限给角色
|
|
||||||
let permCount = 0;
|
|
||||||
for (const permCode of roleConfig.permissions) {
|
|
||||||
const permissionId = createdPermissions[permCode];
|
|
||||||
if (permissionId) {
|
|
||||||
await prisma.rolePermission.create({
|
|
||||||
data: {
|
|
||||||
roleId: role.id,
|
|
||||||
permissionId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
permCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createdRoles.push({ ...role, permCount });
|
|
||||||
console.log(` ✓ 创建角色: ${role.name} (${role.code}) - ${permCount} 个权限`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 创建 admin 用户
|
|
||||||
console.log('\n👤 步骤 4: 创建 admin 用户...');
|
|
||||||
const password = `admin@${tenant.code}`;
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
|
||||||
|
|
||||||
const adminUser = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
username: 'admin',
|
|
||||||
password: hashedPassword,
|
|
||||||
nickname: '管理员',
|
|
||||||
validState: 1,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 创建用户: ${adminUser.username}`);
|
|
||||||
|
|
||||||
// 5. 给用户分配 school_admin 角色
|
|
||||||
console.log('\n🔗 步骤 5: 分配角色给用户...');
|
|
||||||
const schoolAdminRole = createdRoles.find(r => r.code === 'school_admin');
|
|
||||||
if (schoolAdminRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: {
|
|
||||||
userId: adminUser.id,
|
|
||||||
roleId: schoolAdminRole.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(` ✓ 分配角色: ${schoolAdminRole.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 分配菜单给租户
|
|
||||||
console.log('\n📋 步骤 6: 分配菜单给租户...');
|
|
||||||
|
|
||||||
// 普通租户可见的菜单
|
|
||||||
const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '我的评审', '作业管理', '系统管理'];
|
|
||||||
const NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS = ['租户管理', '数据字典', '系统配置', '日志记录', '菜单管理', '权限管理'];
|
|
||||||
const NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS = ['我的报名', '我的作品'];
|
|
||||||
|
|
||||||
const allMenus = await prisma.menu.findMany({
|
|
||||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalTenantMenuIds = new Set<number>();
|
|
||||||
for (const menu of allMenus) {
|
|
||||||
// 顶级菜单
|
|
||||||
if (!menu.parentId && NORMAL_TENANT_MENUS.includes(menu.name)) {
|
|
||||||
normalTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
// 子菜单
|
|
||||||
if (menu.parentId) {
|
|
||||||
const parentMenu = allMenus.find(m => m.id === menu.parentId);
|
|
||||||
if (parentMenu && NORMAL_TENANT_MENUS.includes(parentMenu.name)) {
|
|
||||||
// 系统管理下排除部分子菜单
|
|
||||||
if (parentMenu.name === '系统管理' && NORMAL_TENANT_EXCLUDED_SYSTEM_MENUS.includes(menu.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 我的评审下排除部分子菜单(只保留活动列表)
|
|
||||||
if (parentMenu.name === '我的评审' && NORMAL_TENANT_EXCLUDED_ACTIVITY_MENUS.includes(menu.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
normalTenantMenuIds.add(menu.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let menuCount = 0;
|
|
||||||
for (const menuId of normalTenantMenuIds) {
|
|
||||||
await prisma.tenantMenu.create({
|
|
||||||
data: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
menuId: menuId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
menuCount++;
|
|
||||||
}
|
|
||||||
console.log(` ✓ 分配 ${menuCount} 个菜单`);
|
|
||||||
|
|
||||||
// 7. 输出结果
|
|
||||||
console.log('\n' + '='.repeat(50));
|
|
||||||
console.log('🎉 普通租户创建完成!');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log(` 租户名称: ${tenant.name}`);
|
|
||||||
console.log(` 租户编码: ${tenant.code}`);
|
|
||||||
console.log(` 用户名: admin`);
|
|
||||||
console.log(` 密码: ${password}`);
|
|
||||||
console.log(` 权限数量: ${Object.keys(createdPermissions).length}`);
|
|
||||||
console.log(` 菜单数量: ${menuCount}`);
|
|
||||||
console.log('\n 📊 角色列表:');
|
|
||||||
for (const role of createdRoles) {
|
|
||||||
console.log(` - ${role.name} (${role.code}): ${role.permCount} 个权限`);
|
|
||||||
}
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 创建失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行初始化
|
|
||||||
initTenant()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ 租户创建脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 租户创建脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
-- 为超级租户添加租户管理菜单
|
|
||||||
-- 注意:需要先查询系统管理菜单的ID,然后替换下面的 parent_id
|
|
||||||
|
|
||||||
-- 查询系统管理菜单的ID
|
|
||||||
-- SELECT id FROM menus WHERE name = '系统管理' AND parent_id IS NULL;
|
|
||||||
|
|
||||||
-- 假设系统管理菜单的ID为某个值(需要根据实际情况调整)
|
|
||||||
-- 这里使用子查询来动态获取系统管理菜单的ID
|
|
||||||
|
|
||||||
INSERT INTO menus (
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
icon,
|
|
||||||
component,
|
|
||||||
parent_id,
|
|
||||||
permission,
|
|
||||||
sort,
|
|
||||||
valid_state,
|
|
||||||
create_time,
|
|
||||||
modify_time
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
'租户管理',
|
|
||||||
'/system/tenants',
|
|
||||||
'TeamOutlined',
|
|
||||||
'system/tenants/Index',
|
|
||||||
id, -- 系统管理菜单的ID
|
|
||||||
'tenant:read',
|
|
||||||
7, -- 排序,放在其他系统管理菜单之后
|
|
||||||
1,
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM menus
|
|
||||||
WHERE name = '系统管理' AND parent_id IS NULL
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 如果系统管理菜单不存在,可以手动指定ID:
|
|
||||||
-- INSERT INTO menus (name, path, icon, component, parent_id, permission, sort, valid_state, create_time, modify_time)
|
|
||||||
-- VALUES ('租户管理', '/system/tenants', 'TeamOutlined', 'system/tenants/Index', 2, 'tenant:read', 7, 1, NOW(), NOW());
|
|
||||||
|
|
||||||
-- 为超级租户分配租户管理菜单
|
|
||||||
-- 假设超级租户的ID为1(需要根据实际情况调整)
|
|
||||||
-- 假设租户管理菜单的ID为刚插入的菜单ID
|
|
||||||
|
|
||||||
INSERT INTO tenant_menus (tenant_id, menu_id)
|
|
||||||
SELECT
|
|
||||||
t.id AS tenant_id,
|
|
||||||
m.id AS menu_id
|
|
||||||
FROM tenants t
|
|
||||||
CROSS JOIN menus m
|
|
||||||
WHERE t.code = 'super' AND t.is_super = 1
|
|
||||||
AND m.name = '租户管理' AND m.path = '/system/tenants'
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- 如果上面的查询没有结果,可以手动指定ID:
|
|
||||||
-- INSERT INTO tenant_menus (tenant_id, menu_id)
|
|
||||||
-- VALUES (1, (SELECT id FROM menus WHERE name = '租户管理' AND path = '/system/tenants' LIMIT 1));
|
|
||||||
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
-- ============================================
|
|
||||||
-- 活动管理模块数据库表结构
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- 1. 活动表
|
|
||||||
CREATE TABLE `t_contest` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_name` varchar(127) NOT NULL COMMENT '活动名称',
|
|
||||||
`contest_type` varchar(31) NOT NULL COMMENT '活动类型,字典:contest_type:individual/team',
|
|
||||||
`contest_state` varchar(31) NOT NULL DEFAULT 'unpublished' COMMENT '活动状态(未发布:unpublished 已发布:published)',
|
|
||||||
`start_time` datetime NOT NULL COMMENT '活动开始时间',
|
|
||||||
`end_time` datetime NOT NULL COMMENT '活动结束时间',
|
|
||||||
`address` varchar(512) DEFAULT NULL COMMENT '线下地址',
|
|
||||||
`content` text COMMENT '活动详情',
|
|
||||||
`contest_tenants` json DEFAULT NULL COMMENT '活动参赛范围(授权租户ID数组)',
|
|
||||||
`cover_url` varchar(255) DEFAULT NULL COMMENT '封面url',
|
|
||||||
`poster_url` varchar(255) DEFAULT NULL COMMENT '海报url',
|
|
||||||
`contact_name` varchar(63) DEFAULT NULL COMMENT '联系人',
|
|
||||||
`contact_phone` varchar(63) DEFAULT NULL COMMENT '联系电话',
|
|
||||||
`contact_qrcode` varchar(255) DEFAULT NULL COMMENT '联系人二维码',
|
|
||||||
`organizers` json DEFAULT NULL COMMENT '主办单位数组',
|
|
||||||
`co_organizers` json DEFAULT NULL COMMENT '协办单位数组',
|
|
||||||
`sponsors` json DEFAULT NULL COMMENT '赞助单位数组',
|
|
||||||
`register_start_time` datetime NOT NULL COMMENT '报名开始时间',
|
|
||||||
`register_end_time` datetime NOT NULL COMMENT '报名结束时间',
|
|
||||||
`register_state` varchar(31) DEFAULT NULL COMMENT '报名任务状态,映射写死:启动(started),已关闭(closed)',
|
|
||||||
`submit_rule` varchar(31) NOT NULL DEFAULT 'once' COMMENT '提交规则:once/resubmit',
|
|
||||||
`submit_start_time` datetime NOT NULL COMMENT '作品提交开始时间',
|
|
||||||
`submit_end_time` datetime NOT NULL COMMENT '作品提交结束时间',
|
|
||||||
`review_rule_id` int DEFAULT NULL COMMENT '评审规则id',
|
|
||||||
`review_start_time` datetime NOT NULL COMMENT '评审开始时间',
|
|
||||||
`review_end_time` datetime NOT NULL COMMENT '评审结束时间',
|
|
||||||
`result_publish_time` datetime DEFAULT NULL COMMENT '结果发布时间',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_contest_name` (`contest_name`),
|
|
||||||
KEY `idx_contest_state` (`contest_state`),
|
|
||||||
KEY `idx_contest_time` (`start_time`, `end_time`),
|
|
||||||
KEY `idx_review_rule` (`review_rule_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动表';
|
|
||||||
|
|
||||||
-- 2. 活动附件表
|
|
||||||
CREATE TABLE `t_contest_attachment` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`file_name` varchar(100) NOT NULL COMMENT '文件名',
|
|
||||||
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
|
|
||||||
`format` varchar(255) DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
|
||||||
`file_type` varchar(255) DEFAULT NULL COMMENT '素材类型(image,video)',
|
|
||||||
`size` varchar(255) DEFAULT '0' COMMENT '文件大小',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_contest` (`contest_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动附件';
|
|
||||||
|
|
||||||
-- 3. 评审规则表
|
|
||||||
CREATE TABLE `t_contest_review_rule` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`rule_name` varchar(127) NOT NULL COMMENT '规则名称',
|
|
||||||
`dimensions` json NOT NULL COMMENT '评分维度配置JSON',
|
|
||||||
`calculation_rule` varchar(31) DEFAULT 'average' COMMENT '计算规则:average/max/min/weighted',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_contest` (`contest_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='评审规则表';
|
|
||||||
|
|
||||||
-- 4. 活动团队表
|
|
||||||
CREATE TABLE `t_contest_team` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '团队所属租户ID',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)',
|
|
||||||
`leader_user_id` int NOT NULL COMMENT '团队负责人用户id',
|
|
||||||
`max_members` int DEFAULT NULL COMMENT '团队最大成员数',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_team_name` (`tenant_id`,`contest_id`,`team_name`),
|
|
||||||
KEY `idx_contest` (`contest_id`),
|
|
||||||
KEY `idx_leader` (`leader_user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动团队';
|
|
||||||
|
|
||||||
-- 5. 团队成员表
|
|
||||||
CREATE TABLE `t_contest_team_member` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '成员所属租户ID',
|
|
||||||
`team_id` int NOT NULL COMMENT '团队id',
|
|
||||||
`user_id` int NOT NULL COMMENT '成员用户id',
|
|
||||||
`role` varchar(31) NOT NULL DEFAULT 'member' COMMENT '成员角色:member/leader/mentor',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_member_once` (`tenant_id`,`team_id`,`user_id`),
|
|
||||||
KEY `idx_team` (`team_id`),
|
|
||||||
KEY `idx_user` (`user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='团队成员';
|
|
||||||
|
|
||||||
-- 6. 活动报名表
|
|
||||||
CREATE TABLE `t_contest_registration` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '所属租户ID(学校/机构)',
|
|
||||||
`registration_type` varchar(20) DEFAULT NULL COMMENT '报名类型:individual(个人)/team(团队)',
|
|
||||||
`team_id` int DEFAULT NULL COMMENT '团队id',
|
|
||||||
`team_name` varchar(255) DEFAULT NULL COMMENT '团队名称快照(团队参与)',
|
|
||||||
`user_id` int NOT NULL COMMENT '账号id',
|
|
||||||
`account_no` varchar(64) NOT NULL COMMENT '报名账号(记录报名快照)',
|
|
||||||
`account_name` varchar(100) NOT NULL COMMENT '报名账号名称(记录报名快照)',
|
|
||||||
`role` varchar(63) DEFAULT NULL COMMENT '报名角色快照:leader(队长)/member(队员)/mentor(指导教师)',
|
|
||||||
`registration_state` varchar(31) NOT NULL DEFAULT 'pending' COMMENT '报名状态:pending(待审核)、passed(已通过)、rejected(已拒绝)、withdrawn(已撤回)',
|
|
||||||
`registrant` int DEFAULT NULL COMMENT '实际报名人用户ID(老师报名填老师用户ID)',
|
|
||||||
`registration_time` datetime NOT NULL COMMENT '报名时间',
|
|
||||||
`reason` varchar(1023) DEFAULT NULL COMMENT '审核理由',
|
|
||||||
`operator` int DEFAULT NULL COMMENT '审核人用户ID',
|
|
||||||
`operation_date` datetime DEFAULT NULL COMMENT '审核时间',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_contest_tenant` (`contest_id`, `tenant_id`),
|
|
||||||
KEY `idx_user_contest` (`user_id`, `contest_id`),
|
|
||||||
KEY `idx_team` (`team_id`),
|
|
||||||
KEY `idx_registration_state` (`registration_state`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名人员记录表';
|
|
||||||
|
|
||||||
-- 7. 参赛作品表
|
|
||||||
CREATE TABLE `t_contest_work` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '作品所属租户ID',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`registration_id` int NOT NULL COMMENT '报名记录id(关联t_contest_registration.id)',
|
|
||||||
`work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)',
|
|
||||||
`title` varchar(255) NOT NULL COMMENT '作品标题',
|
|
||||||
`description` text DEFAULT NULL COMMENT '作品说明',
|
|
||||||
`files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)',
|
|
||||||
`version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)',
|
|
||||||
`is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否',
|
|
||||||
`status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted',
|
|
||||||
`submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
|
|
||||||
`submitter_user_id` int DEFAULT NULL COMMENT '提交人用户id',
|
|
||||||
`submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)',
|
|
||||||
`submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader',
|
|
||||||
`preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)',
|
|
||||||
`ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_work_no` (`work_no`),
|
|
||||||
KEY `idx_work_contest_latest` (`tenant_id`,`contest_id`,`is_latest`),
|
|
||||||
KEY `idx_work_registration` (`registration_id`),
|
|
||||||
KEY `idx_submit_filter` (`tenant_id`,`contest_id`,`submit_time`,`status`),
|
|
||||||
KEY `idx_contest_status` (`contest_id`, `status`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品';
|
|
||||||
|
|
||||||
-- 8. 作品附件文件表
|
|
||||||
CREATE TABLE `t_contest_work_attachment` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '所属租户ID',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`work_id` int NOT NULL COMMENT '作品id',
|
|
||||||
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
|
||||||
`file_url` varchar(255) NOT NULL COMMENT '文件路径',
|
|
||||||
`format` varchar(255) DEFAULT NULL COMMENT '文件类型(png,mp4)',
|
|
||||||
`file_type` varchar(255) DEFAULT NULL COMMENT '素材类型(image,video)',
|
|
||||||
`size` varchar(255) DEFAULT '0' COMMENT '文件大小',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_work_file` (`tenant_id`,`contest_id`,`work_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表';
|
|
||||||
|
|
||||||
-- 9. 活动评委关联表(活动与评委的多对多关系)
|
|
||||||
CREATE TABLE `t_contest_judge` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`judge_id` int NOT NULL COMMENT '评委用户id',
|
|
||||||
`specialty` varchar(255) DEFAULT NULL COMMENT '评审专业领域(可选)',
|
|
||||||
`weight` decimal(3,2) DEFAULT NULL COMMENT '评审权重(可选,用于加权平均计算)',
|
|
||||||
`description` text DEFAULT NULL COMMENT '评委在该活动中的说明',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_contest_judge` (`contest_id`, `judge_id`),
|
|
||||||
KEY `idx_contest` (`contest_id`),
|
|
||||||
KEY `idx_judge` (`judge_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动评委关联表';
|
|
||||||
|
|
||||||
-- 10. 作品分配表(评委分配作品)
|
|
||||||
CREATE TABLE `t_contest_work_judge_assignment` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`work_id` int NOT NULL COMMENT '作品id',
|
|
||||||
`judge_id` int NOT NULL COMMENT '评委用户id',
|
|
||||||
`assignment_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '分配时间',
|
|
||||||
`status` varchar(31) NOT NULL DEFAULT 'assigned' COMMENT '分配状态:assigned/reviewing/completed',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_work_judge` (`work_id`, `judge_id`),
|
|
||||||
KEY `idx_contest_judge` (`contest_id`, `judge_id`),
|
|
||||||
KEY `idx_work` (`work_id`),
|
|
||||||
KEY `idx_status` (`status`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作品分配表';
|
|
||||||
|
|
||||||
-- 11. 作品评分表
|
|
||||||
CREATE TABLE `t_contest_work_score` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`tenant_id` int NOT NULL COMMENT '所属租户ID',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`work_id` int NOT NULL COMMENT '作品id',
|
|
||||||
`assignment_id` int NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)',
|
|
||||||
`judge_id` int NOT NULL COMMENT '评委用户id',
|
|
||||||
`judge_name` varchar(127) NOT NULL COMMENT '评委姓名',
|
|
||||||
`dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}',
|
|
||||||
`total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)',
|
|
||||||
`comments` text DEFAULT NULL COMMENT '评语',
|
|
||||||
`score_time` datetime NOT NULL COMMENT '评分时间',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_contest_work_judge` (`contest_id`, `work_id`, `judge_id`),
|
|
||||||
KEY `idx_work` (`work_id`),
|
|
||||||
KEY `idx_assignment` (`assignment_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表';
|
|
||||||
|
|
||||||
-- 12. 活动公告表
|
|
||||||
CREATE TABLE `t_contest_notice` (
|
|
||||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
|
|
||||||
`contest_id` int NOT NULL COMMENT '活动id',
|
|
||||||
`title` varchar(255) NOT NULL COMMENT '公告标题',
|
|
||||||
`content` text NOT NULL COMMENT '公告内容',
|
|
||||||
`notice_type` varchar(31) NOT NULL DEFAULT 'manual' COMMENT '公告类型:system/manual/urgent',
|
|
||||||
`priority` int DEFAULT 0 COMMENT '优先级(数字越大优先级越高)',
|
|
||||||
`publish_time` datetime DEFAULT NULL COMMENT '发布时间',
|
|
||||||
`creator` int DEFAULT NULL COMMENT '创建人ID',
|
|
||||||
`modifier` int DEFAULT NULL COMMENT '修改人ID',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`valid_state` int NOT NULL DEFAULT 1 COMMENT '有效状态(1-有效,2-失效)',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_contest` (`contest_id`),
|
|
||||||
KEY `idx_publish_time` (`publish_time`),
|
|
||||||
KEY `idx_notice_type` (`notice_type`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动公告表';
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1,78 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
|
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
|
||||||
import { AuthModule } from './auth/auth.module';
|
|
||||||
import { UsersModule } from './users/users.module';
|
|
||||||
import { RolesModule } from './roles/roles.module';
|
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
|
||||||
import { MenusModule } from './menus/menus.module';
|
|
||||||
import { DictModule } from './dict/dict.module';
|
|
||||||
import { ConfigModule as SystemConfigModule } from './config/config.module';
|
|
||||||
import { LogsModule } from './logs/logs.module';
|
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
|
||||||
import { ContestsModule } from './contests/contests.module';
|
|
||||||
import { AnalyticsModule } from './contests/analytics/analytics.module';
|
|
||||||
import { JudgesManagementModule } from './judges-management/judges-management.module';
|
|
||||||
import { UploadModule } from './upload/upload.module';
|
|
||||||
import { HomeworkModule } from './homework/homework.module';
|
|
||||||
import { OssModule } from './oss/oss.module';
|
|
||||||
import { PublicModule } from './public/public.module';
|
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
|
||||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
isGlobal: true,
|
|
||||||
// envFilePath 数组中第一个文件优先级最高
|
|
||||||
envFilePath: [
|
|
||||||
`.env.${process.env.NODE_ENV || 'development'}`, // 优先加载环境特定配置
|
|
||||||
'.env', // 通用配置作为后备
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
PrismaModule,
|
|
||||||
AuthModule,
|
|
||||||
UsersModule,
|
|
||||||
RolesModule,
|
|
||||||
PermissionsModule,
|
|
||||||
MenusModule,
|
|
||||||
DictModule,
|
|
||||||
SystemConfigModule,
|
|
||||||
LogsModule,
|
|
||||||
TenantsModule,
|
|
||||||
ContestsModule,
|
|
||||||
AnalyticsModule,
|
|
||||||
JudgesManagementModule,
|
|
||||||
UploadModule,
|
|
||||||
HomeworkModule,
|
|
||||||
OssModule,
|
|
||||||
PublicModule,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: JwtAuthGuard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: RolesGuard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: APP_INTERCEPTOR,
|
|
||||||
useClass: LoggingInterceptor, // 日志拦截器,先执行
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: APP_INTERCEPTOR,
|
|
||||||
useClass: TransformInterceptor, // 响应转换拦截器
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: APP_FILTER,
|
|
||||||
useClass: HttpExceptionFilter,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { LoginDto } from './dto/login.dto';
|
|
||||||
import { Public } from './decorators/public.decorator';
|
|
||||||
|
|
||||||
@Controller('auth')
|
|
||||||
export class AuthController {
|
|
||||||
constructor(private authService: AuthService) {}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@UseGuards(AuthGuard('local'))
|
|
||||||
@Post('login')
|
|
||||||
async login(@Body() loginDto: LoginDto, @Request() req) {
|
|
||||||
// 从请求头或请求体获取租户ID
|
|
||||||
const tenantId = req.headers['x-tenant-id']
|
|
||||||
? parseInt(req.headers['x-tenant-id'], 10)
|
|
||||||
: req.user?.tenantId;
|
|
||||||
|
|
||||||
return this.authService.login(req.user, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@Get('user-info')
|
|
||||||
async getUserInfo(@Request() req) {
|
|
||||||
return this.authService.getUserInfo(req.user.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@Post('logout')
|
|
||||||
async logout() {
|
|
||||||
return { message: '登出成功' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
|
||||||
import { RolesGuard } from './guards/roles.guard';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
UsersModule,
|
|
||||||
PrismaModule,
|
|
||||||
PassportModule,
|
|
||||||
JwtModule.registerAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService) => ({
|
|
||||||
secret: config.get<string>('JWT_SECRET') || 'your-secret-key',
|
|
||||||
signOptions: { expiresIn: '7d' },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService, JwtStrategy, LocalStrategy, RolesGuard],
|
|
||||||
exports: [AuthService, RolesGuard],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
private usersService: UsersService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async validateUser(
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
tenantId?: number,
|
|
||||||
): Promise<any> {
|
|
||||||
const user = await this.usersService.findByUsername(username, tenantId);
|
|
||||||
if (user && (await bcrypt.compare(password, user.password))) {
|
|
||||||
// 验证租户是否匹配
|
|
||||||
if (tenantId && user.tenantId !== tenantId) {
|
|
||||||
throw new UnauthorizedException('用户不属于该租户');
|
|
||||||
}
|
|
||||||
const { password, ...result } = user;
|
|
||||||
password;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(user: any, tenantId?: number) {
|
|
||||||
// 确保租户ID存在
|
|
||||||
const finalTenantId = tenantId || user.tenantId;
|
|
||||||
if (!finalTenantId) {
|
|
||||||
throw new BadRequestException('无法确定租户信息');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证租户是否有效
|
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
|
||||||
where: { id: finalTenantId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
throw new BadRequestException('租户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenant.validState !== 1) {
|
|
||||||
throw new BadRequestException('租户已失效');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证用户是否属于该租户
|
|
||||||
if (user.tenantId !== finalTenantId) {
|
|
||||||
throw new UnauthorizedException('用户不属于该租户');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
username: user.username,
|
|
||||||
sub: user.id,
|
|
||||||
tenantId: finalTenantId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: this.jwtService.sign(payload),
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
nickname: user.nickname,
|
|
||||||
email: user.email,
|
|
||||||
avatar: user.avatar,
|
|
||||||
tenantId: finalTenantId,
|
|
||||||
tenantCode: tenant.code,
|
|
||||||
roles: user.roles?.map((ur: any) => ur.role.code) || [],
|
|
||||||
permissions: await this.getUserPermissions(user.id),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfo(userId: number) {
|
|
||||||
const user = await this.usersService.findOne(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('用户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
|
||||||
where: { id: user.tenantId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
nickname: user.nickname,
|
|
||||||
email: user.email,
|
|
||||||
avatar: user.avatar,
|
|
||||||
tenantId: user.tenantId,
|
|
||||||
tenantCode: tenant?.code,
|
|
||||||
roles: user.roles?.map((ur: any) => ur.role.code) || [],
|
|
||||||
permissions: await this.getUserPermissions(userId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserPermissions(userId: number): Promise<string[]> {
|
|
||||||
const user = await this.usersService.findOne(userId);
|
|
||||||
if (!user) return [];
|
|
||||||
|
|
||||||
const permissions = new Set<string>();
|
|
||||||
user.roles?.forEach((ur: any) => {
|
|
||||||
ur.role.permissions?.forEach((rp: any) => {
|
|
||||||
permissions.add(rp.permission.code);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserRoles(userId: number): Promise<string[]> {
|
|
||||||
const user = await this.usersService.findOne(userId);
|
|
||||||
if (!user) return [];
|
|
||||||
|
|
||||||
return user.roles?.map((ur: any) => ur.role.code) || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从请求中获取当前租户ID
|
|
||||||
* 优先从 req.tenantId 获取,其次从 req.user.tenantId 获取
|
|
||||||
*/
|
|
||||||
export const CurrentTenantId = createParamDecorator(
|
|
||||||
(data: unknown, ctx: ExecutionContext): number | undefined => {
|
|
||||||
const request = ctx.switchToHttp().getRequest();
|
|
||||||
return request.tenantId || request.user?.tenantId;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const PERMISSION_KEY = 'permission';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 权限装饰器
|
|
||||||
* @param permissions 单个权限或多个权限(多个权限为 OR 逻辑,满足其一即可)
|
|
||||||
*/
|
|
||||||
export const RequirePermission = (...permissions: string[]) =>
|
|
||||||
SetMetadata(PERMISSION_KEY, permissions);
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
|
||||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
tenantCode?: string; // 租户编码(可选,如果未提供则从请求头获取)
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
|
||||||
constructor(private reflector: Reflector) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isPublic) {
|
|
||||||
// 公开接口:尝试解析 JWT(有 token 就解析,没有就跳过)
|
|
||||||
// 这样公众端活动列表等接口可以根据用户身份做个性化过滤
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const authHeader = request.headers?.authorization;
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
||||||
// 有 token,尝试验证(但失败不阻塞)
|
|
||||||
return super.canActivate(context);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.canActivate(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 公开接口 JWT 验证失败时不抛异常,只是 user 为空
|
|
||||||
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isPublic && !user) {
|
|
||||||
return null; // 公开接口,无用户也放行
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err || !user) {
|
|
||||||
throw err || new (require('@nestjs/common').UnauthorizedException)();
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PermissionsGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private reflector: Reflector,
|
|
||||||
private authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(PERMISSION_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
if (!user || !user.userId) {
|
|
||||||
throw new ForbiddenException('未授权访问');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户角色并检查是否为超级管理员
|
|
||||||
const userRoles = await this.authService.getUserRoles(user.userId);
|
|
||||||
if (userRoles.includes('super_admin')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户的所有权限
|
|
||||||
const userPermissions = await this.authService.getUserPermissions(user.userId);
|
|
||||||
|
|
||||||
// OR 逻辑:满足其中任意一个权限即可
|
|
||||||
const hasPermission = requiredPermissions.some(perm => userPermissions.includes(perm));
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new ForbiddenException(`缺少权限: ${requiredPermissions.join(' 或 ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RolesGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private reflector: Reflector,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!requiredRoles) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
if (!user || !user.userId) {
|
|
||||||
throw new ForbiddenException('未授权访问');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从数据库获取用户的角色
|
|
||||||
const userWithRoles = await this.prisma.user.findUnique({
|
|
||||||
where: { id: user.userId },
|
|
||||||
include: {
|
|
||||||
roles: {
|
|
||||||
include: {
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userWithRoles) {
|
|
||||||
throw new ForbiddenException('用户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRoles = userWithRoles.roles?.map((ur: any) => ur.role.code) || [];
|
|
||||||
|
|
||||||
// 检查用户是否有任一所需角色
|
|
||||||
const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role));
|
|
||||||
|
|
||||||
if (!hasRequiredRole) {
|
|
||||||
throw new ForbiddenException(`需要以下角色之一: ${requiredRoles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
ignoreExpiration: false,
|
|
||||||
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(payload: any) {
|
|
||||||
// 查询租户是否为超级租户
|
|
||||||
let isSuperTenant = false;
|
|
||||||
if (payload.tenantId) {
|
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
|
||||||
where: { id: payload.tenantId },
|
|
||||||
select: { isSuper: true },
|
|
||||||
});
|
|
||||||
isSuperTenant = tenant?.isSuper === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: payload.sub,
|
|
||||||
username: payload.username,
|
|
||||||
tenantId: payload.tenantId,
|
|
||||||
isSuperTenant,
|
|
||||||
userType: payload.userType || 'adult',
|
|
||||||
parentUserId: payload.parentUserId || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Strategy } from 'passport-local';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
|
||||||
private readonly logger = new Logger(LocalStrategy.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private authService: AuthService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
usernameField: 'username',
|
|
||||||
passwordField: 'password',
|
|
||||||
passReqToCallback: true, // 允许访问request对象
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(req: any, username: string, password: string): Promise<any> {
|
|
||||||
// 从请求体或请求头获取租户信息
|
|
||||||
const tenantCode = req.body?.tenantCode || req.headers['x-tenant-code'];
|
|
||||||
const tenantId = req.headers['x-tenant-id'];
|
|
||||||
|
|
||||||
this.logger.debug(`Login attempt: username=${username}, tenantCode=${tenantCode}, tenantId=${tenantId}`);
|
|
||||||
|
|
||||||
let finalTenantId: number | undefined;
|
|
||||||
|
|
||||||
if (tenantId) {
|
|
||||||
finalTenantId = parseInt(tenantId, 10);
|
|
||||||
} else if (tenantCode) {
|
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
|
||||||
where: { code: tenantCode },
|
|
||||||
});
|
|
||||||
this.logger.debug(`Tenant lookup result: ${JSON.stringify(tenant)}`);
|
|
||||||
if (!tenant) {
|
|
||||||
throw new UnauthorizedException('租户不存在');
|
|
||||||
}
|
|
||||||
finalTenantId = tenant.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.authService.validateUser(username, password, finalTenantId);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('用户名或密码错误');
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
ExceptionFilter,
|
|
||||||
Catch,
|
|
||||||
ArgumentsHost,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
// import { LogsService } from '../../logs/logs.service'; // TODO: 待实现
|
|
||||||
|
|
||||||
@Catch()
|
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
|
||||||
// constructor(private logsService: LogsService) {} // TODO: 待实现
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse<Response>();
|
|
||||||
const request = ctx.getRequest<Request>();
|
|
||||||
|
|
||||||
const status =
|
|
||||||
exception instanceof HttpException
|
|
||||||
? exception.getStatus()
|
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
||||||
|
|
||||||
const message =
|
|
||||||
exception instanceof HttpException
|
|
||||||
? exception.getResponse()
|
|
||||||
: 'Internal server error';
|
|
||||||
|
|
||||||
const errorMessage =
|
|
||||||
typeof message === 'string'
|
|
||||||
? message
|
|
||||||
: (message as any).message || 'Error';
|
|
||||||
|
|
||||||
const errorResponse = {
|
|
||||||
code: status,
|
|
||||||
message: errorMessage,
|
|
||||||
data: null,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 记录错误日志(仅记录 500 及以上错误)
|
|
||||||
// 跳过日志接口本身,避免循环记录
|
|
||||||
if (status >= 500 && !request.url.startsWith('/logs')) {
|
|
||||||
const user = (request as any).user;
|
|
||||||
const userId = user?.userId || null;
|
|
||||||
console.error(
|
|
||||||
'[HttpExceptionFilter]',
|
|
||||||
request.method,
|
|
||||||
request.url,
|
|
||||||
userId,
|
|
||||||
exception,
|
|
||||||
);
|
|
||||||
|
|
||||||
// const errorContent = {
|
|
||||||
// status,
|
|
||||||
// message: errorMessage,
|
|
||||||
// method: request.method,
|
|
||||||
// url: request.url,
|
|
||||||
// error: exception instanceof Error ? exception.stack : String(exception),
|
|
||||||
// };
|
|
||||||
// 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB)
|
|
||||||
// const content = this.truncateContent(JSON.stringify(errorContent), 50000);
|
|
||||||
|
|
||||||
// this.logsService
|
|
||||||
// .create({
|
|
||||||
// userId,
|
|
||||||
// action: `ERROR ${request.method} ${request.url}`,
|
|
||||||
// content,
|
|
||||||
// ip: request.ip || '',
|
|
||||||
// userAgent: request.headers['user-agent'] || '',
|
|
||||||
// })
|
|
||||||
// .catch((error) => {
|
|
||||||
// console.error('Failed to log error:', error);
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
response.status(status).json(errorResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 截断内容,避免超过数据库字段限制
|
|
||||||
private truncateContent(content: string, maxLength: number): string {
|
|
||||||
if (!content || content.length <= maxLength) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { LogsService } from '../../logs/logs.service';
|
|
||||||
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LoggingInterceptor implements NestInterceptor {
|
|
||||||
constructor(
|
|
||||||
private logsService: LogsService,
|
|
||||||
private reflector: Reflector,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const { method, url, ip, headers } = request;
|
|
||||||
const userAgent = headers['user-agent'] || '';
|
|
||||||
|
|
||||||
// 检查是否为公共接口,公共接口不记录日志
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 跳过日志接口本身,避免循环记录
|
|
||||||
if (url.startsWith('/logs') || isPublic) {
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户信息(如果已认证)
|
|
||||||
const user = (request as any).user;
|
|
||||||
const userId = user?.userId || null;
|
|
||||||
|
|
||||||
// 构建操作内容
|
|
||||||
const action = `${method} ${url}`;
|
|
||||||
const contentData = {
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
query: request.query,
|
|
||||||
body: this.sanitizeBody(request.body),
|
|
||||||
};
|
|
||||||
// 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB)
|
|
||||||
console.log('[LoggingInterceptor]', contentData);
|
|
||||||
const content = this.truncateContent(JSON.stringify(contentData), 50000);
|
|
||||||
|
|
||||||
// 异步记录日志,不阻塞请求
|
|
||||||
this.logsService
|
|
||||||
.create({
|
|
||||||
userId,
|
|
||||||
action,
|
|
||||||
content,
|
|
||||||
ip: ip || request.ip || '',
|
|
||||||
userAgent,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// 日志记录失败不影响主流程,只打印错误
|
|
||||||
console.error('Failed to log request:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理敏感信息(如密码)
|
|
||||||
private sanitizeBody(body: any): any {
|
|
||||||
if (!body || typeof body !== 'object') {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = { ...body };
|
|
||||||
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'token'];
|
|
||||||
|
|
||||||
sensitiveFields.forEach((field) => {
|
|
||||||
if (sanitized[field]) {
|
|
||||||
sanitized[field] = '***';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 截断内容,避免超过数据库字段限制
|
|
||||||
private truncateContent(content: string, maxLength: number): string {
|
|
||||||
if (!content || content.length <= maxLength) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { map } from 'rxjs/operators';
|
|
||||||
|
|
||||||
export interface Response<T> {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TransformInterceptor<T>
|
|
||||||
implements NestInterceptor<T, Response<T>>
|
|
||||||
{
|
|
||||||
intercept(
|
|
||||||
context: ExecutionContext,
|
|
||||||
next: CallHandler,
|
|
||||||
): Observable<Response<T>> {
|
|
||||||
return next.handle().pipe(
|
|
||||||
map((data) => ({
|
|
||||||
code: 200,
|
|
||||||
message: 'success',
|
|
||||||
data,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 配置验证控制器
|
|
||||||
* 用于验证环境配置文件是否正确加载
|
|
||||||
*/
|
|
||||||
@Controller('config-verification')
|
|
||||||
export class ConfigVerificationController {
|
|
||||||
constructor(private configService: ConfigService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公开接口,用于验证配置加载
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('env-info')
|
|
||||||
getEnvInfo() {
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const expectedEnvFile = `.env.${nodeEnv}`; // 匹配实际文件名格式:.development.env
|
|
||||||
const envFilePath = path.join(process.cwd(), expectedEnvFile);
|
|
||||||
const fallbackEnvPath = path.join(process.cwd(), '.env');
|
|
||||||
|
|
||||||
// 检查文件是否存在
|
|
||||||
const envFileExists = fs.existsSync(envFilePath);
|
|
||||||
const fallbackExists = fs.existsSync(fallbackEnvPath);
|
|
||||||
|
|
||||||
// 获取一些关键配置(不暴露敏感信息)
|
|
||||||
const config = {
|
|
||||||
nodeEnv,
|
|
||||||
expectedEnvFile,
|
|
||||||
envFileExists,
|
|
||||||
fallbackExists,
|
|
||||||
envFilePath,
|
|
||||||
fallbackEnvPath,
|
|
||||||
loadedFrom: envFileExists
|
|
||||||
? expectedEnvFile
|
|
||||||
: fallbackExists
|
|
||||||
? '.env'
|
|
||||||
: '环境变量',
|
|
||||||
// 显示具体配置信息(包括实际值)
|
|
||||||
configs: {
|
|
||||||
PORT: this.configService.get('PORT') || process.env.PORT || 3001,
|
|
||||||
DATABASE_URL:
|
|
||||||
this.configService.get('DATABASE_URL') ||
|
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
'未配置',
|
|
||||||
JWT_SECRET:
|
|
||||||
this.configService.get('JWT_SECRET') ||
|
|
||||||
process.env.JWT_SECRET ||
|
|
||||||
'未配置',
|
|
||||||
NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv,
|
|
||||||
},
|
|
||||||
publicConfigs: {
|
|
||||||
PORT: this.configService.get('PORT') || process.env.PORT || 3001,
|
|
||||||
NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
message: '配置信息',
|
|
||||||
data: config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 需要认证的接口,显示更多配置详情(仍隐藏敏感信息)
|
|
||||||
*/
|
|
||||||
@Get('detailed')
|
|
||||||
getDetailedConfig() {
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const expectedEnvFile = `.env.${nodeEnv}`;
|
|
||||||
const envFilePath = path.join(process.cwd(), expectedEnvFile);
|
|
||||||
|
|
||||||
// 读取文件内容(用于验证,但不返回敏感信息)
|
|
||||||
let fileContent = '';
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(envFilePath)) {
|
|
||||||
fileContent = fs.readFileSync(envFilePath, 'utf-8');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 忽略读取错误
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计配置项数量
|
|
||||||
const configKeys = fileContent
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.trim() && !line.trim().startsWith('#'))
|
|
||||||
.map((line) => line.split('=')[0]?.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
message: '详细配置信息',
|
|
||||||
data: {
|
|
||||||
nodeEnv,
|
|
||||||
expectedEnvFile,
|
|
||||||
fileExists: fs.existsSync(envFilePath),
|
|
||||||
configKeysCount: configKeys.length,
|
|
||||||
configKeys: configKeys, // 只显示键名,不显示值
|
|
||||||
// 验证关键配置是否加载
|
|
||||||
verification: {
|
|
||||||
DATABASE_URL: !!this.configService.get('DATABASE_URL'),
|
|
||||||
JWT_SECRET: !!this.configService.get('JWT_SECRET'),
|
|
||||||
PORT: !!this.configService.get('PORT'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Patch,
|
|
||||||
Param,
|
|
||||||
Delete,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from './config.service';
|
|
||||||
import { CreateConfigDto } from './dto/create-config.dto';
|
|
||||||
import { UpdateConfigDto } from './dto/update-config.dto';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@Controller('config')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class ConfigController {
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
create(@Body() createConfigDto: CreateConfigDto, @Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
if (!tenantId) {
|
|
||||||
throw new Error('无法确定租户信息');
|
|
||||||
}
|
|
||||||
return this.configService.create(createConfigDto, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
findAll(
|
|
||||||
@Query('page') page?: string,
|
|
||||||
@Query('pageSize') pageSize?: string,
|
|
||||||
@Request() req?: any,
|
|
||||||
) {
|
|
||||||
const tenantId = req?.tenantId || req?.user?.tenantId;
|
|
||||||
return this.configService.findAll(
|
|
||||||
page ? parseInt(page) : 1,
|
|
||||||
pageSize ? parseInt(pageSize) : 10,
|
|
||||||
tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('key/:key')
|
|
||||||
findByKey(@Param('key') key: string, @Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.configService.findByKey(key, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
findOne(@Param('id') id: string, @Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.configService.findOne(+id, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id')
|
|
||||||
update(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() updateConfigDto: UpdateConfigDto,
|
|
||||||
@Request() req,
|
|
||||||
) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.configService.update(+id, updateConfigDto, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
remove(@Param('id') id: string, @Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.configService.remove(+id, tenantId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigService as SystemConfigService } from './config.service';
|
|
||||||
import { ConfigController } from './config.controller';
|
|
||||||
import { ConfigVerificationController } from './config-verification.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ConfigController, ConfigVerificationController],
|
|
||||||
providers: [SystemConfigService],
|
|
||||||
})
|
|
||||||
export class ConfigModule {}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { CreateConfigDto } from './dto/create-config.dto';
|
|
||||||
import { UpdateConfigDto } from './dto/update-config.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ConfigService {
|
|
||||||
constructor(private prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async create(createConfigDto: CreateConfigDto, tenantId: number) {
|
|
||||||
return this.prisma.config.create({
|
|
||||||
data: {
|
|
||||||
...createConfigDto,
|
|
||||||
tenantId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
|
|
||||||
const skip = (page - 1) * pageSize;
|
|
||||||
const where = tenantId ? { tenantId } : {};
|
|
||||||
|
|
||||||
const [list, total] = await Promise.all([
|
|
||||||
this.prisma.config.findMany({
|
|
||||||
where,
|
|
||||||
skip,
|
|
||||||
take: pageSize,
|
|
||||||
}),
|
|
||||||
this.prisma.config.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
list,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: number, tenantId?: number) {
|
|
||||||
const where: any = { id };
|
|
||||||
if (tenantId) {
|
|
||||||
where.tenantId = tenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await this.prisma.config.findFirst({
|
|
||||||
where,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
throw new NotFoundException('配置不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByKey(key: string, tenantId?: number) {
|
|
||||||
if (!tenantId) {
|
|
||||||
throw new NotFoundException('无法确定租户信息');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.config.findFirst({
|
|
||||||
where: {
|
|
||||||
key,
|
|
||||||
tenantId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: number, updateConfigDto: UpdateConfigDto, tenantId?: number) {
|
|
||||||
// 验证配置是否存在且属于该租户
|
|
||||||
await this.findOne(id, tenantId);
|
|
||||||
|
|
||||||
return this.prisma.config.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateConfigDto,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: number, tenantId?: number) {
|
|
||||||
// 验证配置是否存在且属于该租户
|
|
||||||
await this.findOne(id, tenantId);
|
|
||||||
|
|
||||||
return this.prisma.config.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { IsString, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateConfigDto {
|
|
||||||
@IsString()
|
|
||||||
key: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
value: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { IsString, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateConfigDto {
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
key?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
value?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
||||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
|
||||||
import { AnalyticsService } from './analytics.service';
|
|
||||||
|
|
||||||
@Controller('analytics')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class AnalyticsController {
|
|
||||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
|
||||||
|
|
||||||
@Get('overview')
|
|
||||||
@RequirePermission('contest:read')
|
|
||||||
getOverview(
|
|
||||||
@Request() req,
|
|
||||||
@Query('timeRange') timeRange?: string,
|
|
||||||
@Query('contestId') contestId?: string,
|
|
||||||
) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.analyticsService.getOverview(tenantId, {
|
|
||||||
timeRange,
|
|
||||||
contestId: contestId ? parseInt(contestId) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('review')
|
|
||||||
@RequirePermission('contest:read')
|
|
||||||
getReviewAnalysis(
|
|
||||||
@Request() req,
|
|
||||||
@Query('contestId') contestId?: string,
|
|
||||||
) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.analyticsService.getReviewAnalysis(tenantId, {
|
|
||||||
contestId: contestId ? parseInt(contestId) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AnalyticsController } from './analytics.controller';
|
|
||||||
import { AnalyticsService } from './analytics.service';
|
|
||||||
import { PrismaModule } from '../../prisma/prisma.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule],
|
|
||||||
controllers: [AnalyticsController],
|
|
||||||
providers: [AnalyticsService],
|
|
||||||
})
|
|
||||||
export class AnalyticsModule {}
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AnalyticsService {
|
|
||||||
constructor(private prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查活动是否对租户可见
|
|
||||||
*/
|
|
||||||
private isContestVisibleToTenant(contest: any, tenantId: number): boolean {
|
|
||||||
if (contest.contestState !== 'published') return false;
|
|
||||||
if (!contest.contestTenants) return true;
|
|
||||||
try {
|
|
||||||
const ids = Array.isArray(contest.contestTenants)
|
|
||||||
? contest.contestTenants
|
|
||||||
: JSON.parse(contest.contestTenants as string);
|
|
||||||
return ids.includes(tenantId);
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 运营概览
|
|
||||||
*/
|
|
||||||
async getOverview(tenantId: number, params: { timeRange?: string; contestId?: number }) {
|
|
||||||
const { contestId } = params;
|
|
||||||
|
|
||||||
// 获取该租户可见的活动
|
|
||||||
const allContests = await this.prisma.contest.findMany({
|
|
||||||
where: { contestState: 'published' },
|
|
||||||
select: { id: true, contestTenants: true, contestState: true, contestName: true },
|
|
||||||
});
|
|
||||||
let visibleContestIds = allContests
|
|
||||||
.filter(c => this.isContestVisibleToTenant(c, tenantId))
|
|
||||||
.map(c => c.id);
|
|
||||||
|
|
||||||
if (contestId) {
|
|
||||||
visibleContestIds = visibleContestIds.filter(id => id === contestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const regWhere: any = { tenantId, contestId: { in: visibleContestIds } };
|
|
||||||
const workWhere: any = { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true };
|
|
||||||
|
|
||||||
// 核心指标
|
|
||||||
const [totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks] = await Promise.all([
|
|
||||||
this.prisma.contestRegistration.count({ where: regWhere }),
|
|
||||||
this.prisma.contestRegistration.count({ where: { ...regWhere, registrationState: 'passed' } }),
|
|
||||||
this.prisma.contestWork.count({ where: workWhere }),
|
|
||||||
this.prisma.contestWork.count({ where: { ...workWhere, status: { in: ['accepted', 'awarded'] } } }),
|
|
||||||
this.prisma.contestWork.count({ where: { ...workWhere, awardName: { not: null } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 漏斗数据
|
|
||||||
const funnel = {
|
|
||||||
registered: totalRegistrations,
|
|
||||||
passed: passedRegistrations,
|
|
||||||
submitted: totalWorks,
|
|
||||||
reviewed: reviewedWorks,
|
|
||||||
awarded: awardedWorks,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 月度趋势(最近6个月)
|
|
||||||
const sixMonthsAgo = new Date();
|
|
||||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5);
|
|
||||||
sixMonthsAgo.setDate(1);
|
|
||||||
sixMonthsAgo.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const registrationsByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
|
|
||||||
SELECT DATE_FORMAT(registration_time, '%Y-%m') as month, COUNT(*) as count
|
|
||||||
FROM t_contest_registration
|
|
||||||
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
|
|
||||||
AND registration_time >= ?
|
|
||||||
GROUP BY month ORDER BY month
|
|
||||||
`, tenantId, sixMonthsAgo);
|
|
||||||
|
|
||||||
const worksByMonth = await this.prisma.$queryRawUnsafe<any[]>(`
|
|
||||||
SELECT DATE_FORMAT(submit_time, '%Y-%m') as month, COUNT(*) as count
|
|
||||||
FROM t_contest_work
|
|
||||||
WHERE tenant_id = ? AND contest_id IN (${visibleContestIds.join(',') || '0'})
|
|
||||||
AND valid_state = 1 AND is_latest = 1
|
|
||||||
AND submit_time >= ?
|
|
||||||
GROUP BY month ORDER BY month
|
|
||||||
`, tenantId, sixMonthsAgo);
|
|
||||||
|
|
||||||
// 构建连续6个月数据
|
|
||||||
const monthlyTrend: { month: string; registrations: number; works: number }[] = [];
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setMonth(d.getMonth() - 5 + i);
|
|
||||||
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
const regRow = registrationsByMonth.find((r: any) => r.month === m);
|
|
||||||
const workRow = worksByMonth.find((r: any) => r.month === m);
|
|
||||||
monthlyTrend.push({
|
|
||||||
month: m,
|
|
||||||
registrations: Number(regRow?.count || 0),
|
|
||||||
works: Number(workRow?.count || 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 活动对比
|
|
||||||
const contestComparison: any[] = [];
|
|
||||||
for (const cid of visibleContestIds) {
|
|
||||||
const contest = allContests.find(c => c.id === cid);
|
|
||||||
if (!contest) continue;
|
|
||||||
|
|
||||||
const [regTotal, regPassed, worksTotal, worksReviewed, worksAwarded] = await Promise.all([
|
|
||||||
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid } }),
|
|
||||||
this.prisma.contestRegistration.count({ where: { tenantId, contestId: cid, registrationState: 'passed' } }),
|
|
||||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true } }),
|
|
||||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, status: { in: ['accepted', 'awarded'] } } }),
|
|
||||||
this.prisma.contestWork.count({ where: { tenantId, contestId: cid, validState: 1, isLatest: true, awardName: { not: null } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const avgScore = await this.prisma.contestWork.aggregate({
|
|
||||||
where: { tenantId, contestId: cid, validState: 1, isLatest: true, finalScore: { not: null } },
|
|
||||||
_avg: { finalScore: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
contestComparison.push({
|
|
||||||
contestId: cid,
|
|
||||||
contestName: contest.contestName,
|
|
||||||
registrations: regTotal,
|
|
||||||
passRate: regTotal > 0 ? Math.round(regPassed / regTotal * 100) : 0,
|
|
||||||
submitRate: regPassed > 0 ? Math.round(worksTotal / regPassed * 100) : 0,
|
|
||||||
reviewRate: worksTotal > 0 ? Math.round(worksReviewed / worksTotal * 100) : 0,
|
|
||||||
awardRate: worksTotal > 0 ? Math.round(worksAwarded / worksTotal * 100) : 0,
|
|
||||||
avgScore: avgScore._avg.finalScore ? Number(Number(avgScore._avg.finalScore).toFixed(2)) : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary: {
|
|
||||||
totalContests: visibleContestIds.length,
|
|
||||||
totalRegistrations,
|
|
||||||
passedRegistrations,
|
|
||||||
totalWorks,
|
|
||||||
reviewedWorks,
|
|
||||||
awardedWorks,
|
|
||||||
},
|
|
||||||
funnel,
|
|
||||||
monthlyTrend,
|
|
||||||
contestComparison,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 评审分析
|
|
||||||
*/
|
|
||||||
async getReviewAnalysis(tenantId: number, params: { contestId?: number }) {
|
|
||||||
const { contestId } = params;
|
|
||||||
|
|
||||||
// 获取可见活动
|
|
||||||
const allContests = await this.prisma.contest.findMany({
|
|
||||||
where: { contestState: 'published' },
|
|
||||||
select: { id: true, contestTenants: true, contestState: true },
|
|
||||||
});
|
|
||||||
let visibleContestIds = allContests
|
|
||||||
.filter(c => this.isContestVisibleToTenant(c, tenantId))
|
|
||||||
.map(c => c.id);
|
|
||||||
|
|
||||||
if (contestId) {
|
|
||||||
visibleContestIds = visibleContestIds.filter(id => id === contestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleContestIds.length === 0) {
|
|
||||||
return {
|
|
||||||
efficiency: { avgReviewDays: 0, dailyReviewCount: 0, pendingAssignments: 0, avgScoreStddev: 0 },
|
|
||||||
judgeWorkload: [],
|
|
||||||
awardDistribution: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const contestIdList = visibleContestIds.join(',');
|
|
||||||
|
|
||||||
// 评审效率
|
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
|
|
||||||
const [pendingAssignments, recentScoreCount] = await Promise.all([
|
|
||||||
this.prisma.contestWorkJudgeAssignment.count({
|
|
||||||
where: { contestId: { in: visibleContestIds }, status: 'assigned' },
|
|
||||||
}),
|
|
||||||
this.prisma.contestWorkScore.count({
|
|
||||||
where: { contestId: { in: visibleContestIds }, scoreTime: { gte: thirtyDaysAgo } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 平均评审周期:从作品提交到第一次评分的天数
|
|
||||||
let avgReviewDays = 0;
|
|
||||||
try {
|
|
||||||
const reviewDaysResult = await this.prisma.$queryRawUnsafe<any[]>(`
|
|
||||||
SELECT AVG(DATEDIFF(s.score_time, w.submit_time)) as avg_days
|
|
||||||
FROM t_contest_work_score s
|
|
||||||
JOIN t_contest_work w ON s.work_id = w.id
|
|
||||||
WHERE s.contest_id IN (${contestIdList})
|
|
||||||
AND w.valid_state = 1
|
|
||||||
`);
|
|
||||||
avgReviewDays = reviewDaysResult[0]?.avg_days ? Number(Number(reviewDaysResult[0].avg_days).toFixed(1)) : 0;
|
|
||||||
} catch { /* */ }
|
|
||||||
|
|
||||||
// 评分标准差(评委间一致性)
|
|
||||||
let avgScoreStddev = 0;
|
|
||||||
try {
|
|
||||||
const stddevResult = await this.prisma.$queryRawUnsafe<any[]>(`
|
|
||||||
SELECT AVG(stddev_score) as avg_stddev
|
|
||||||
FROM (
|
|
||||||
SELECT work_id, STDDEV(total_score) as stddev_score
|
|
||||||
FROM t_contest_work_score
|
|
||||||
WHERE contest_id IN (${contestIdList}) AND valid_state = 1
|
|
||||||
GROUP BY work_id
|
|
||||||
HAVING COUNT(*) > 1
|
|
||||||
) sub
|
|
||||||
`);
|
|
||||||
avgScoreStddev = stddevResult[0]?.avg_stddev ? Number(Number(stddevResult[0].avg_stddev).toFixed(1)) : 0;
|
|
||||||
} catch { /* */ }
|
|
||||||
|
|
||||||
// 评委工作量
|
|
||||||
const judges = await this.prisma.contestJudge.findMany({
|
|
||||||
where: { contestId: { in: visibleContestIds }, validState: 1 },
|
|
||||||
include: {
|
|
||||||
judge: { select: { id: true, nickname: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 按评委去重
|
|
||||||
const judgeMap = new Map<number, any>();
|
|
||||||
for (const j of judges) {
|
|
||||||
if (!judgeMap.has(j.judgeId)) {
|
|
||||||
judgeMap.set(j.judgeId, {
|
|
||||||
judgeId: j.judgeId,
|
|
||||||
judgeName: j.judge?.nickname || j.judge?.username || '-',
|
|
||||||
contestIds: new Set<number>(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
judgeMap.get(j.judgeId).contestIds.add(j.contestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const judgeWorkload: any[] = [];
|
|
||||||
for (const [judgeId, info] of judgeMap) {
|
|
||||||
const [assignedCount, scoredCount, scores] = await Promise.all([
|
|
||||||
this.prisma.contestWorkJudgeAssignment.count({
|
|
||||||
where: { judgeId, contestId: { in: visibleContestIds } },
|
|
||||||
}),
|
|
||||||
this.prisma.contestWorkScore.count({
|
|
||||||
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
|
|
||||||
}),
|
|
||||||
this.prisma.contestWorkScore.findMany({
|
|
||||||
where: { judgeId, contestId: { in: visibleContestIds }, validState: 1 },
|
|
||||||
select: { totalScore: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const scoreValues = scores.map(s => Number(s.totalScore));
|
|
||||||
const avg = scoreValues.length > 0 ? scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length : 0;
|
|
||||||
const variance = scoreValues.length > 1
|
|
||||||
? scoreValues.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / (scoreValues.length - 1)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
judgeWorkload.push({
|
|
||||||
judgeId,
|
|
||||||
judgeName: info.judgeName,
|
|
||||||
contestCount: info.contestIds.size,
|
|
||||||
assignedCount,
|
|
||||||
scoredCount,
|
|
||||||
completionRate: assignedCount > 0 ? Math.round(scoredCount / assignedCount * 100) : 0,
|
|
||||||
avgScore: scoreValues.length > 0 ? Number(avg.toFixed(2)) : null,
|
|
||||||
scoreStddev: scoreValues.length > 1 ? Number(Math.sqrt(variance).toFixed(2)) : 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 奖项分布
|
|
||||||
const awardGroups = await this.prisma.contestWork.groupBy({
|
|
||||||
by: ['awardName'],
|
|
||||||
where: { tenantId, contestId: { in: visibleContestIds }, validState: 1, isLatest: true, awardName: { not: null } },
|
|
||||||
_count: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalAwarded = awardGroups.reduce((sum, g) => sum + g._count.id, 0);
|
|
||||||
const awardDistribution = awardGroups.map(g => ({
|
|
||||||
awardName: g.awardName,
|
|
||||||
count: g._count.id,
|
|
||||||
percentage: totalAwarded > 0 ? Math.round(g._count.id / totalAwarded * 100) : 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
efficiency: {
|
|
||||||
avgReviewDays,
|
|
||||||
dailyReviewCount: Number((recentScoreCount / 30).toFixed(1)),
|
|
||||||
pendingAssignments,
|
|
||||||
avgScoreStddev,
|
|
||||||
},
|
|
||||||
judgeWorkload,
|
|
||||||
awardDistribution,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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 {}
|
|
||||||
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateAttachmentDto } from './create-attachment.dto';
|
|
||||||
|
|
||||||
export class UpdateAttachmentDto extends PartialType(CreateAttachmentDto) {}
|
|
||||||
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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';
|
|
||||||
import { ResultsModule } from './results/results.module';
|
|
||||||
import { PresetCommentsModule } from './preset-comments/preset-comments.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
// 子模块必须在 ContestsCoreModule 之前导入
|
|
||||||
// 否则 /contests/:id 会先匹配,导致 /contests/notices 等路由失效
|
|
||||||
AttachmentsModule,
|
|
||||||
ReviewRulesModule,
|
|
||||||
RegistrationsModule,
|
|
||||||
TeamsModule,
|
|
||||||
WorksModule,
|
|
||||||
ReviewsModule,
|
|
||||||
NoticesModule,
|
|
||||||
JudgesModule,
|
|
||||||
ResultsModule,
|
|
||||||
PresetCommentsModule,
|
|
||||||
// ContestsCoreModule 放在最后,因为它有通配符路由 /contests/:id
|
|
||||||
ContestsCoreModule,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
ContestsCoreModule,
|
|
||||||
AttachmentsModule,
|
|
||||||
ReviewRulesModule,
|
|
||||||
RegistrationsModule,
|
|
||||||
TeamsModule,
|
|
||||||
WorksModule,
|
|
||||||
ReviewsModule,
|
|
||||||
NoticesModule,
|
|
||||||
JudgesModule,
|
|
||||||
ResultsModule,
|
|
||||||
PresetCommentsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class ContestsModule {}
|
|
||||||
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Patch,
|
|
||||||
Param,
|
|
||||||
Delete,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
ParseIntPipe,
|
|
||||||
BadRequestException,
|
|
||||||
} 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?.userId;
|
|
||||||
return this.contestsService.create(createContestDto, creatorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('stats')
|
|
||||||
@RequirePermission('contest:read')
|
|
||||||
getStats(@Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.contestsService.getStats(tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('dashboard')
|
|
||||||
@RequirePermission('contest:read')
|
|
||||||
getDashboard(@Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.contestsService.getTenantDashboard(tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@RequirePermission('contest:read')
|
|
||||||
findAll(@Query() queryDto: QueryContestDto, @Request() req) {
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
return this.contestsService.findAll(queryDto, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('my-contests')
|
|
||||||
@RequirePermission('contest:read', 'contest:activity:read')
|
|
||||||
findMyContests(@Query() queryDto: QueryContestDto, @Request() req) {
|
|
||||||
const userId = req.user?.userId;
|
|
||||||
const tenantId = req.tenantId || req.user?.tenantId;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('用户未登录');
|
|
||||||
}
|
|
||||||
return this.contestsService.findMyContests(queryDto, userId, tenantId, queryDto.role);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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?.userId;
|
|
||||||
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?.userId;
|
|
||||||
return this.contestsService.publish(id, publishDto.contestState, modifierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/finish')
|
|
||||||
@RequirePermission('contest:update')
|
|
||||||
finish(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
|
||||||
const modifierId = req.user?.userId;
|
|
||||||
return this.contestsService.finish(id, modifierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/reopen')
|
|
||||||
@RequirePermission('contest:update')
|
|
||||||
reopen(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
|
||||||
const modifierId = req.user?.userId;
|
|
||||||
return this.contestsService.reopen(id, modifierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@RequirePermission('contest:delete')
|
|
||||||
remove(@Param('id', ParseIntPipe) id: number) {
|
|
||||||
return this.contestsService.remove(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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 {}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,187 +0,0 @@
|
|||||||
import {
|
|
||||||
IsString,
|
|
||||||
IsDateString,
|
|
||||||
IsOptional,
|
|
||||||
IsEnum,
|
|
||||||
IsArray,
|
|
||||||
IsInt,
|
|
||||||
IsBoolean,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export enum ContestType {
|
|
||||||
INDIVIDUAL = 'individual',
|
|
||||||
TEAM = 'team',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SubmitRule {
|
|
||||||
ONCE = 'once',
|
|
||||||
RESUBMIT = 'resubmit',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ContestStatus {
|
|
||||||
ONGOING = 'ongoing',
|
|
||||||
FINISHED = 'finished',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WorkType {
|
|
||||||
IMAGE = 'image',
|
|
||||||
VIDEO = 'video',
|
|
||||||
DOCUMENT = 'document',
|
|
||||||
CODE = 'code',
|
|
||||||
OTHER = 'other',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RegisterState {
|
|
||||||
OPEN = 'open',
|
|
||||||
CLOSED = 'closed',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Visibility {
|
|
||||||
PUBLIC = 'public',
|
|
||||||
TARGETED = 'targeted',
|
|
||||||
DESIGNATED = 'designated',
|
|
||||||
INTERNAL = 'internal',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateContestDto {
|
|
||||||
@IsString()
|
|
||||||
contestName: string;
|
|
||||||
|
|
||||||
@IsEnum(ContestType)
|
|
||||||
contestType: ContestType;
|
|
||||||
|
|
||||||
@IsEnum(Visibility)
|
|
||||||
@IsOptional()
|
|
||||||
visibility?: Visibility;
|
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
@IsOptional()
|
|
||||||
targetCities?: string[]; // 定向推送目标城市
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
ageMin?: number; // 最小年龄限制
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
ageMax?: number; // 最大年龄限制
|
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
organizers?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
coOrganizers?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
sponsors?: string;
|
|
||||||
|
|
||||||
// 报名配置
|
|
||||||
@IsDateString()
|
|
||||||
registerStartTime: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
registerEndTime: string;
|
|
||||||
|
|
||||||
@IsEnum(RegisterState)
|
|
||||||
@IsOptional()
|
|
||||||
registerState?: RegisterState;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
@IsOptional()
|
|
||||||
requireAudit?: boolean;
|
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsInt({ each: true })
|
|
||||||
@IsOptional()
|
|
||||||
allowedGrades?: number[];
|
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsInt({ each: true })
|
|
||||||
@IsOptional()
|
|
||||||
allowedClasses?: number[];
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
teamMinMembers?: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
teamMaxMembers?: number;
|
|
||||||
|
|
||||||
// 作品配置
|
|
||||||
@IsEnum(SubmitRule)
|
|
||||||
@IsOptional()
|
|
||||||
submitRule?: SubmitRule;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
submitStartTime: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
submitEndTime: string;
|
|
||||||
|
|
||||||
@IsEnum(WorkType)
|
|
||||||
@IsOptional()
|
|
||||||
workType?: WorkType;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
workRequirement?: string;
|
|
||||||
|
|
||||||
// 评审配置
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
reviewRuleId?: number;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
reviewStartTime: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
reviewEndTime: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
@IsOptional()
|
|
||||||
resultPublishTime?: string;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { ContestState } from './query-contest.dto';
|
|
||||||
|
|
||||||
export class PublishContestDto {
|
|
||||||
@IsEnum(ContestState)
|
|
||||||
contestState: ContestState;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export enum ContestState {
|
|
||||||
UNPUBLISHED = 'unpublished',
|
|
||||||
PUBLISHED = 'published',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ContestStatus {
|
|
||||||
ONGOING = 'ongoing',
|
|
||||||
FINISHED = 'finished',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum UserRole {
|
|
||||||
STUDENT = 'student',
|
|
||||||
TEACHER = 'teacher',
|
|
||||||
JUDGE = 'judge',
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
@IsEnum(ContestStatus)
|
|
||||||
@IsOptional()
|
|
||||||
status?: ContestStatus;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
contestType?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
visibility?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
stage?: string; // unpublished / registering / submitting / reviewing / finished
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsOptional()
|
|
||||||
creatorTenantId?: number; // 按创建活动的租户筛选
|
|
||||||
|
|
||||||
@IsEnum(UserRole)
|
|
||||||
@IsOptional()
|
|
||||||
role?: UserRole;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateContestDto } from './create-contest.dto';
|
|
||||||
|
|
||||||
export class UpdateContestDto extends PartialType(CreateContestDto) {}
|
|
||||||
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateJudgeDto } from './create-judge.dto';
|
|
||||||
|
|
||||||
export class UpdateJudgeDto extends PartialType(CreateJudgeDto) {}
|
|
||||||
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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 {}
|
|
||||||
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
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,
|
|
||||||
phone: true,
|
|
||||||
gender: true,
|
|
||||||
status: true,
|
|
||||||
tenantId: true,
|
|
||||||
tenant: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: 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,
|
|
||||||
phone: true,
|
|
||||||
gender: true,
|
|
||||||
status: true,
|
|
||||||
tenantId: true,
|
|
||||||
tenant: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contestJudges: {
|
|
||||||
where: {
|
|
||||||
validState: 1,
|
|
||||||
contest: {
|
|
||||||
validState: 1,
|
|
||||||
status: 'ongoing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
contest: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
contestName: true,
|
|
||||||
status: 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,
|
|
||||||
phone: true,
|
|
||||||
gender: true,
|
|
||||||
status: true,
|
|
||||||
tenantId: true,
|
|
||||||
tenant: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: 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,
|
|
||||||
phone: true,
|
|
||||||
gender: true,
|
|
||||||
status: true,
|
|
||||||
tenantId: true,
|
|
||||||
tenant: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: number) {
|
|
||||||
await this.findOne(id);
|
|
||||||
|
|
||||||
// 软删除
|
|
||||||
return this.prisma.contestJudge.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
validState: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export class QueryNoticeDto {
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsOptional()
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(100)
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsOptional()
|
|
||||||
pageSize?: number = 10;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
publishDate?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
publishStartDate?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
publishEndDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { IsString, IsInt, IsEnum, IsOptional, IsDateString, Min, Max } from 'class-validator';
|
|
||||||
import { NoticeType } from './create-notice.dto';
|
|
||||||
|
|
||||||
export class UpdateNoticeDto {
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
contestId?: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
content?: string;
|
|
||||||
|
|
||||||
@IsEnum(NoticeType)
|
|
||||||
@IsOptional()
|
|
||||||
noticeType?: NoticeType;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
@Max(100)
|
|
||||||
@IsOptional()
|
|
||||||
priority?: number;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
@IsOptional()
|
|
||||||
publishTime?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Patch,
|
|
||||||
Param,
|
|
||||||
Delete,
|
|
||||||
Query,
|
|
||||||
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 { QueryNoticeDto } from './dto/query-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()
|
|
||||||
@RequirePermission('notice:read')
|
|
||||||
findAllNotices(@Query() queryDto: QueryNoticeDto) {
|
|
||||||
return this.noticesService.findAllNotices(queryDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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 {}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user