初始提交:幼儿园阅读平台三端代码
- reading-platform-backend:NestJS 后端 - reading-platform-frontend:Vue3 前端 - reading-platform-java:Spring Boot 服务端
This commit is contained in:
commit
7f757b6a63
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
# 依赖目录
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# 环境变量(含敏感信息,不提交)
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# 保留开发环境配置(可按需注释掉)
|
||||
# .env.development
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# 日志
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# 只提交 reading-platform 三个子项目
|
||||
test-website/
|
||||
归档.zip
|
||||
6
reading-platform-backend/.env.development
Normal file
6
reading-platform-backend/.env.development
Normal file
@ -0,0 +1,6 @@
|
||||
DATABASE_URL="file:/Users/retirado/ccProgram/reading-platform-backend/dev.db"
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
JWT_SECRET="your-super-secret-jwt-key"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
FRONTEND_URL="http://localhost:5173"
|
||||
10
reading-platform-backend/nest-cli.json
Normal file
10
reading-platform-backend/nest-cli.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false,
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
11688
reading-platform-backend/package-lock.json
generated
Normal file
11688
reading-platform-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
reading-platform-backend/package.json
Normal file
66
reading-platform-backend/package.json
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "reading-platform-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "幼儿阅读教学服务平台后端",
|
||||
"main": "dist/src/main.js",
|
||||
"scripts": {
|
||||
"prestart:dev": "npx tsc",
|
||||
"start:dev": "node dist/src/main.js",
|
||||
"build": "npx tsc",
|
||||
"start": "node dist/src/main.js",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.22",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/throttler": "^5.2.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"ali-oss": "^6.18.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"compression": "^1.8.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"exceljs": "^4.4.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
||||
"@typescript-eslint/parser": "^6.18.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
385
reading-platform-backend/prisma/migrate-v1-to-v2.ts
Normal file
385
reading-platform-backend/prisma/migrate-v1-to-v2.ts
Normal file
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 数据迁移脚本:V1 -> V2
|
||||
*
|
||||
* 迁移内容:
|
||||
* 1. 初始化主题字典(6个默认主题)
|
||||
* 2. 为现有课程包创建默认的 CourseLesson(集体课)
|
||||
* 3. 迁移 CourseScript → LessonStep
|
||||
* 4. 迁移 CourseActivity → CourseLesson(领域课)
|
||||
* 5. 创建默认套餐(按年级分组)
|
||||
* 6. 迁移租户授权 TenantCourse → TenantPackage
|
||||
*
|
||||
* 执行命令:npx ts-node prisma/migrate-v1-to-v2.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 默认主题
|
||||
const DEFAULT_THEMES = [
|
||||
{ name: '我爱幼儿园', description: '适应幼儿园生活,培养基本生活习惯', sortOrder: 1 },
|
||||
{ name: '认识自我', description: '认识身体、情绪、能力,建立自我意识', sortOrder: 2 },
|
||||
{ name: '我的家', description: '了解家庭成员,培养亲情和责任感', sortOrder: 3 },
|
||||
{ name: '美丽的自然', description: '探索自然现象,培养环保意识', sortOrder: 4 },
|
||||
{ name: '奇妙的世界', description: '认识社会和世界,拓展视野', sortOrder: 5 },
|
||||
{ name: '成长的快乐', description: '培养学习兴趣和良好习惯', sortOrder: 6 },
|
||||
];
|
||||
|
||||
// 年级映射
|
||||
const GRADE_LEVELS = ['小班', '中班', '大班'];
|
||||
|
||||
async function main() {
|
||||
console.log('开始数据迁移...\n');
|
||||
|
||||
try {
|
||||
// ==================== Step 1: 初始化主题字典 ====================
|
||||
console.log('Step 1: 初始化主题字典...');
|
||||
const existingThemes = await prisma.theme.count();
|
||||
|
||||
if (existingThemes === 0) {
|
||||
for (const theme of DEFAULT_THEMES) {
|
||||
await prisma.theme.create({
|
||||
data: {
|
||||
name: theme.name,
|
||||
description: theme.description,
|
||||
sortOrder: theme.sortOrder,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✅ 已创建 ${DEFAULT_THEMES.length} 个默认主题`);
|
||||
} else {
|
||||
console.log(` ⏭️ 主题已存在 (${existingThemes} 个),跳过`);
|
||||
}
|
||||
|
||||
// ==================== Step 2: 创建 CourseLesson(集体课)并迁移 CourseScript ====================
|
||||
console.log('\nStep 2: 创建 CourseLesson 并迁移 CourseScript...');
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { isLatest: true },
|
||||
include: {
|
||||
scripts: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let lessonCount = 0;
|
||||
let stepCount = 0;
|
||||
|
||||
for (const course of courses) {
|
||||
// 检查是否已有集体课
|
||||
const existingLesson = await prisma.courseLesson.findFirst({
|
||||
where: { courseId: course.id, lessonType: 'COLLECTIVE' },
|
||||
});
|
||||
|
||||
if (existingLesson) {
|
||||
console.log(` ⏭️ 课程 "${course.name}" 已有集体课,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建集体课 CourseLesson
|
||||
const courseLesson = await prisma.courseLesson.create({
|
||||
data: {
|
||||
courseId: course.id,
|
||||
lessonType: 'COLLECTIVE',
|
||||
name: `${course.name} - 集体课`,
|
||||
description: course.description,
|
||||
duration: course.duration || 25,
|
||||
pptPath: course.pptPath,
|
||||
pptName: course.pptName,
|
||||
objectives: course.lessonPlanData ? JSON.parse(course.lessonPlanData).objectives : null,
|
||||
preparation: course.tools || course.studentMaterials,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
lessonCount++;
|
||||
|
||||
// 迁移 CourseScript → LessonStep
|
||||
for (const script of course.scripts) {
|
||||
await prisma.lessonStep.create({
|
||||
data: {
|
||||
lessonId: courseLesson.id,
|
||||
name: script.stepName || `环节 ${script.stepIndex}`,
|
||||
content: script.teacherScript,
|
||||
duration: script.duration || 5,
|
||||
objective: script.objective,
|
||||
resourceIds: script.resourceIds,
|
||||
sortOrder: script.sortOrder || script.stepIndex,
|
||||
},
|
||||
});
|
||||
stepCount++;
|
||||
}
|
||||
|
||||
console.log(` ✅ 课程 "${course.name}":创建集体课 + ${course.scripts.length} 个环节`);
|
||||
}
|
||||
|
||||
console.log(` 📊 共创建 ${lessonCount} 个集体课,${stepCount} 个教学环节`);
|
||||
|
||||
// ==================== Step 3: 迁移 CourseActivity → CourseLesson(领域课) ====================
|
||||
console.log('\nStep 3: 迁移 CourseActivity → CourseLesson(领域课)...');
|
||||
const activities = await prisma.courseActivity.findMany({
|
||||
include: { course: true },
|
||||
});
|
||||
|
||||
// 按课程分组
|
||||
const activitiesByCourse = new Map<number, typeof activities>();
|
||||
for (const activity of activities) {
|
||||
if (!activitiesByCourse.has(activity.courseId)) {
|
||||
activitiesByCourse.set(activity.courseId, []);
|
||||
}
|
||||
activitiesByCourse.get(activity.courseId)!.push(activity);
|
||||
}
|
||||
|
||||
let activityLessonCount = 0;
|
||||
|
||||
for (const [courseId, courseActivities] of activitiesByCourse) {
|
||||
const course = courseActivities[0].course;
|
||||
|
||||
for (const activity of courseActivities) {
|
||||
// 确定课程类型
|
||||
let lessonType = 'DOMAIN';
|
||||
const domainMap: Record<string, string> = {
|
||||
'健康': 'HEALTH',
|
||||
'语言': 'LANGUAGE',
|
||||
'社会': 'SOCIAL',
|
||||
'科学': 'SCIENCE',
|
||||
'艺术': 'ART',
|
||||
};
|
||||
if (activity.domain && domainMap[activity.domain]) {
|
||||
lessonType = domainMap[activity.domain];
|
||||
}
|
||||
|
||||
// 检查是否已有该类型的领域课
|
||||
const existingActivityLesson = await prisma.courseLesson.findFirst({
|
||||
where: { courseId, lessonType },
|
||||
});
|
||||
|
||||
if (existingActivityLesson) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.courseLesson.create({
|
||||
data: {
|
||||
courseId,
|
||||
lessonType,
|
||||
name: activity.name,
|
||||
description: activity.activityGuide,
|
||||
duration: activity.duration || 25,
|
||||
objectives: activity.objectives,
|
||||
preparation: activity.offlineMaterials,
|
||||
extension: activity.onlineMaterials,
|
||||
sortOrder: activity.sortOrder || 0,
|
||||
},
|
||||
});
|
||||
activityLessonCount++;
|
||||
}
|
||||
|
||||
console.log(` ✅ 课程 "${course.name}":创建 ${courseActivities.length} 个领域课`);
|
||||
}
|
||||
|
||||
console.log(` 📊 共创建 ${activityLessonCount} 个领域课`);
|
||||
|
||||
// ==================== Step 4: 创建默认套餐(按年级分组) ====================
|
||||
console.log('\nStep 4: 创建默认套餐...');
|
||||
const existingPackages = await prisma.coursePackage.count();
|
||||
|
||||
if (existingPackages === 0) {
|
||||
// 获取所有已发布的课程
|
||||
const publishedCourses = await prisma.course.findMany({
|
||||
where: { status: 'PUBLISHED' },
|
||||
});
|
||||
|
||||
// 年级标签映射(统一格式)
|
||||
const gradeTagMap: Record<string, string> = {
|
||||
'SMALL': '小班',
|
||||
'small': '小班',
|
||||
'MIDDLE': '中班',
|
||||
'middle': '中班',
|
||||
'BIG': '大班',
|
||||
'big': '大班',
|
||||
};
|
||||
|
||||
// 按年级分组课程
|
||||
const coursesByGrade = new Map<string, typeof publishedCourses>();
|
||||
for (const course of publishedCourses) {
|
||||
let gradeTags: string[] = [];
|
||||
try {
|
||||
gradeTags = JSON.parse(course.gradeTags || '[]');
|
||||
} catch {
|
||||
gradeTags = [];
|
||||
}
|
||||
|
||||
for (const rawGrade of gradeTags) {
|
||||
const grade = gradeTagMap[rawGrade] || rawGrade;
|
||||
if (!coursesByGrade.has(grade)) {
|
||||
coursesByGrade.set(grade, []);
|
||||
}
|
||||
coursesByGrade.get(grade)!.push(course);
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个年级创建套餐
|
||||
for (const grade of GRADE_LEVELS) {
|
||||
const gradeCourses = coursesByGrade.get(grade) || [];
|
||||
|
||||
if (gradeCourses.length === 0) {
|
||||
console.log(` ⏭️ 年级 "${grade}" 没有课程,跳过套餐创建`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageName = `${grade}课程套餐`;
|
||||
const coursePackage = await prisma.coursePackage.create({
|
||||
data: {
|
||||
name: packageName,
|
||||
description: `包含 ${gradeCourses.length} 个课程包,覆盖${grade}全学期教学内容`,
|
||||
price: gradeCourses.length * 10000, // 假设每个课程包 100 元
|
||||
gradeLevels: JSON.stringify([grade]),
|
||||
status: 'PUBLISHED',
|
||||
courseCount: gradeCourses.length,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 创建套餐-课程关联
|
||||
for (let i = 0; i < gradeCourses.length; i++) {
|
||||
const course = gradeCourses[i];
|
||||
await prisma.coursePackageCourse.create({
|
||||
data: {
|
||||
packageId: coursePackage.id,
|
||||
courseId: course.id,
|
||||
gradeLevel: grade,
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✅ 创建套餐 "${packageName}":包含 ${gradeCourses.length} 个课程包`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⏭️ 套餐已存在 (${existingPackages} 个),跳过`);
|
||||
}
|
||||
|
||||
// ==================== Step 5: 迁移租户授权 TenantCourse → TenantPackage ====================
|
||||
console.log('\nStep 5: 迁移租户授权...');
|
||||
|
||||
// 年级标签映射(统一格式)
|
||||
const gradeTagMapForAuth: Record<string, string> = {
|
||||
'SMALL': '小班',
|
||||
'small': '小班',
|
||||
'MIDDLE': '中班',
|
||||
'middle': '中班',
|
||||
'BIG': '大班',
|
||||
'big': '大班',
|
||||
};
|
||||
|
||||
// 获取所有套餐
|
||||
const packages = await prisma.coursePackage.findMany();
|
||||
const packageByGrade = new Map<string, typeof packages[0]>();
|
||||
for (const pkg of packages) {
|
||||
const grades = JSON.parse(pkg.gradeLevels || '[]');
|
||||
for (const grade of grades) {
|
||||
packageByGrade.set(grade, pkg);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取租户课程授权
|
||||
const tenantCourses = await prisma.tenantCourse.findMany({
|
||||
include: { course: true, tenant: true },
|
||||
});
|
||||
|
||||
// 按租户分组
|
||||
const tenantCoursesByTenant = new Map<number, typeof tenantCourses>();
|
||||
for (const tc of tenantCourses) {
|
||||
if (!tenantCoursesByTenant.has(tc.tenantId)) {
|
||||
tenantCoursesByTenant.set(tc.tenantId, []);
|
||||
}
|
||||
tenantCoursesByTenant.get(tc.tenantId)!.push(tc);
|
||||
}
|
||||
|
||||
let tenantPackageCount = 0;
|
||||
|
||||
for (const [tenantId, tcs] of tenantCoursesByTenant) {
|
||||
const tenant = tcs[0].tenant;
|
||||
|
||||
// 获取该租户需要的套餐
|
||||
const neededPackages = new Set<typeof packages[0]>();
|
||||
for (const tc of tcs) {
|
||||
const gradeTags = JSON.parse(tc.course.gradeTags || '[]');
|
||||
for (const rawGrade of gradeTags) {
|
||||
const grade = gradeTagMapForAuth[rawGrade] || rawGrade;
|
||||
const pkg = packageByGrade.get(grade);
|
||||
if (pkg) {
|
||||
neededPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 为租户创建套餐授权
|
||||
for (const pkg of neededPackages) {
|
||||
// 检查是否已有授权
|
||||
const existingAuth = await prisma.tenantPackage.findFirst({
|
||||
where: { tenantId, packageId: pkg.id },
|
||||
});
|
||||
|
||||
if (existingAuth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用租户的订阅日期
|
||||
const startDate = tenant.startDate || new Date().toISOString().split('T')[0];
|
||||
const endDate = tenant.expireDate || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
await prisma.tenantPackage.create({
|
||||
data: {
|
||||
tenantId,
|
||||
packageId: pkg.id,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'ACTIVE',
|
||||
pricePaid: pkg.price,
|
||||
},
|
||||
});
|
||||
tenantPackageCount++;
|
||||
}
|
||||
|
||||
console.log(` ✅ 租户 "${tenant.name}":授权 ${neededPackages.size} 个套餐`);
|
||||
}
|
||||
|
||||
console.log(` 📊 共创建 ${tenantPackageCount} 个租户套餐授权`);
|
||||
|
||||
// ==================== 完成 ====================
|
||||
console.log('\n✅ 数据迁移完成!');
|
||||
|
||||
// 输出统计信息
|
||||
const stats = {
|
||||
themes: await prisma.theme.count(),
|
||||
courses: await prisma.course.count({ where: { isLatest: true } }),
|
||||
courseLessons: await prisma.courseLesson.count(),
|
||||
lessonSteps: await prisma.lessonStep.count(),
|
||||
packages: await prisma.coursePackage.count(),
|
||||
tenantPackages: await prisma.tenantPackage.count(),
|
||||
};
|
||||
|
||||
console.log('\n📊 迁移后数据统计:');
|
||||
console.log(` 主题:${stats.themes}`);
|
||||
console.log(` 课程包:${stats.courses}`);
|
||||
console.log(` 课程(CourseLesson):${stats.courseLessons}`);
|
||||
console.log(` 教学环节:${stats.lessonSteps}`);
|
||||
console.log(` 套餐:${stats.packages}`);
|
||||
console.log(` 租户套餐授权:${stats.tenantPackages}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 迁移失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@ -0,0 +1,262 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenants" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"contact_person" TEXT,
|
||||
"contact_phone" TEXT,
|
||||
"logo_url" TEXT,
|
||||
"package_type" TEXT NOT NULL DEFAULT 'STANDARD',
|
||||
"teacher_quota" INTEGER NOT NULL DEFAULT 20,
|
||||
"student_quota" INTEGER NOT NULL DEFAULT 200,
|
||||
"storage_quota" BIGINT NOT NULL DEFAULT 5368709120,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"expire_date" TEXT NOT NULL,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"storage_used" BIGINT NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "teachers" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"login_account" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"class_ids" TEXT DEFAULT '[]',
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"feedback_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"last_login_at" DATETIME,
|
||||
CONSTRAINT "teachers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "classes" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"teacher_id" BIGINT,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "classes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "classes_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "students" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"class_id" BIGINT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"gender" TEXT,
|
||||
"birth_date" DATETIME,
|
||||
"parent_phone" TEXT,
|
||||
"parent_name" TEXT,
|
||||
"reading_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "students_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "students_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "courses" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture_book_id" INTEGER,
|
||||
"picture_book_name" TEXT,
|
||||
"grade_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"domain_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"duration" INTEGER NOT NULL DEFAULT 25,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"version" TEXT NOT NULL DEFAULT '1.0',
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"avg_rating" REAL NOT NULL DEFAULT 0,
|
||||
"created_by" INTEGER,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"published_at" DATETIME
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_resources" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"course_id" BIGINT NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_name" TEXT NOT NULL,
|
||||
"file_url" TEXT NOT NULL,
|
||||
"file_size" BIGINT,
|
||||
"mime_type" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_resources_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_scripts" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"course_id" BIGINT NOT NULL,
|
||||
"step_index" INTEGER NOT NULL,
|
||||
"step_name" TEXT NOT NULL,
|
||||
"step_type" TEXT NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"objective" TEXT,
|
||||
"teacher_script" TEXT,
|
||||
"interaction_points" TEXT,
|
||||
"resource_ids" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_scripts_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_script_pages" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"script_id" BIGINT NOT NULL,
|
||||
"page_number" INTEGER NOT NULL,
|
||||
"questions" TEXT,
|
||||
"interaction_component" TEXT,
|
||||
"teacher_notes" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_script_pages_script_id_fkey" FOREIGN KEY ("script_id") REFERENCES "course_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_activities" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"course_id" BIGINT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT,
|
||||
"domain_tag_id" INTEGER,
|
||||
"activity_type" TEXT NOT NULL,
|
||||
"duration" INTEGER,
|
||||
"online_materials" TEXT,
|
||||
"offlineMaterials" TEXT,
|
||||
"activityGuide" TEXT,
|
||||
"objectives" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_activities_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lessons" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"teacher_id" BIGINT NOT NULL,
|
||||
"class_id" BIGINT NOT NULL,
|
||||
"course_id" BIGINT NOT NULL,
|
||||
"planned_datetime" DATETIME,
|
||||
"start_datetime" DATETIME,
|
||||
"end_datetime" DATETIME,
|
||||
"actual_duration" INTEGER,
|
||||
"status" TEXT NOT NULL DEFAULT 'PLANNED',
|
||||
"overall_rating" TEXT,
|
||||
"participation_rating" TEXT,
|
||||
"completion_note" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lessons_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lesson_feedbacks" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"lesson_id" BIGINT NOT NULL,
|
||||
"teacher_id" BIGINT NOT NULL,
|
||||
"design_quality" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"goal_achievement" INTEGER,
|
||||
"step_feedbacks" TEXT,
|
||||
"pros" TEXT,
|
||||
"suggestions" TEXT,
|
||||
"activities_done" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lesson_feedbacks_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "lesson_feedbacks_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "student_records" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"lesson_id" BIGINT NOT NULL,
|
||||
"student_id" BIGINT NOT NULL,
|
||||
"focus" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"interest" INTEGER,
|
||||
"understanding" INTEGER,
|
||||
"domainAchievements" TEXT,
|
||||
"notes" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "student_records_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "student_records_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"level" INTEGER NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parent_id" BIGINT,
|
||||
"description" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_courses" (
|
||||
"id" BIGINT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"course_id" BIGINT NOT NULL,
|
||||
"authorized" BOOLEAN NOT NULL DEFAULT true,
|
||||
"authorized_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "tenant_courses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tenant_courses_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "teachers_login_account_key" ON "teachers"("login_account");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "course_scripts_course_id_step_index_key" ON "course_scripts"("course_id", "step_index");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "course_script_pages_script_id_page_number_key" ON "course_script_pages"("script_id", "page_number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "lesson_feedbacks_lesson_id_teacher_id_key" ON "lesson_feedbacks"("lesson_id", "teacher_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "student_records_lesson_id_student_id_key" ON "student_records"("lesson_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_code_key" ON "tags"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_courses_tenant_id_course_id_key" ON "tenant_courses"("tenant_id", "course_id");
|
||||
@ -0,0 +1,323 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `classes` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `classes` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `teacher_id` on the `classes` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `tenant_id` on the `classes` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `course_activities` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `course_id` on the `course_activities` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `course_activities` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `course_resources` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `course_id` on the `course_resources` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `file_size` on the `course_resources` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `course_resources` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `course_script_pages` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `course_script_pages` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `script_id` on the `course_script_pages` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `course_scripts` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `course_id` on the `course_scripts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `course_scripts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `courses` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `courses` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `lesson_feedbacks` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `lesson_feedbacks` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `lesson_id` on the `lesson_feedbacks` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `teacher_id` on the `lesson_feedbacks` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `lessons` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `class_id` on the `lessons` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `course_id` on the `lessons` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `lessons` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `teacher_id` on the `lessons` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `tenant_id` on the `lessons` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `student_records` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `student_records` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `lesson_id` on the `student_records` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `student_id` on the `student_records` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `students` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `class_id` on the `students` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `students` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `tenant_id` on the `students` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `tags` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `tags` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `parent_id` on the `tags` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `teachers` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `teachers` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `tenant_id` on the `teachers` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `tenant_courses` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `course_id` on the `tenant_courses` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `id` on the `tenant_courses` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- You are about to alter the column `tenant_id` on the `tenant_courses` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- The primary key for the `tenants` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `tenants` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
|
||||
- Made the column `picture_book_id` on table `courses` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_classes" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"teacher_id" INTEGER,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "classes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "classes_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_classes" ("created_at", "grade", "id", "lesson_count", "name", "student_count", "teacher_id", "tenant_id", "updated_at") SELECT "created_at", "grade", "id", "lesson_count", "name", "student_count", "teacher_id", "tenant_id", "updated_at" FROM "classes";
|
||||
DROP TABLE "classes";
|
||||
ALTER TABLE "new_classes" RENAME TO "classes";
|
||||
CREATE TABLE "new_course_activities" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT,
|
||||
"domain_tag_id" INTEGER,
|
||||
"activity_type" TEXT NOT NULL,
|
||||
"duration" INTEGER,
|
||||
"online_materials" TEXT,
|
||||
"offlineMaterials" TEXT,
|
||||
"activityGuide" TEXT,
|
||||
"objectives" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_activities_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_course_activities" ("activityGuide", "activity_type", "course_id", "created_at", "domain", "domain_tag_id", "duration", "id", "name", "objectives", "offlineMaterials", "online_materials", "sort_order") SELECT "activityGuide", "activity_type", "course_id", "created_at", "domain", "domain_tag_id", "duration", "id", "name", "objectives", "offlineMaterials", "online_materials", "sort_order" FROM "course_activities";
|
||||
DROP TABLE "course_activities";
|
||||
ALTER TABLE "new_course_activities" RENAME TO "course_activities";
|
||||
CREATE TABLE "new_course_resources" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_name" TEXT NOT NULL,
|
||||
"file_url" TEXT NOT NULL,
|
||||
"file_size" INTEGER,
|
||||
"mime_type" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_resources_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_course_resources" ("course_id", "created_at", "file_size", "file_url", "id", "metadata", "mime_type", "resource_name", "resource_type", "sort_order") SELECT "course_id", "created_at", "file_size", "file_url", "id", "metadata", "mime_type", "resource_name", "resource_type", "sort_order" FROM "course_resources";
|
||||
DROP TABLE "course_resources";
|
||||
ALTER TABLE "new_course_resources" RENAME TO "course_resources";
|
||||
CREATE TABLE "new_course_script_pages" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"script_id" INTEGER NOT NULL,
|
||||
"page_number" INTEGER NOT NULL,
|
||||
"questions" TEXT,
|
||||
"interaction_component" TEXT,
|
||||
"teacher_notes" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_script_pages_script_id_fkey" FOREIGN KEY ("script_id") REFERENCES "course_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_course_script_pages" ("created_at", "id", "interaction_component", "page_number", "questions", "script_id", "teacher_notes", "updated_at") SELECT "created_at", "id", "interaction_component", "page_number", "questions", "script_id", "teacher_notes", "updated_at" FROM "course_script_pages";
|
||||
DROP TABLE "course_script_pages";
|
||||
ALTER TABLE "new_course_script_pages" RENAME TO "course_script_pages";
|
||||
CREATE UNIQUE INDEX "course_script_pages_script_id_page_number_key" ON "course_script_pages"("script_id", "page_number");
|
||||
CREATE TABLE "new_course_scripts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"step_index" INTEGER NOT NULL,
|
||||
"step_name" TEXT NOT NULL,
|
||||
"step_type" TEXT NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"objective" TEXT,
|
||||
"teacher_script" TEXT,
|
||||
"interaction_points" TEXT,
|
||||
"resource_ids" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_scripts_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_course_scripts" ("course_id", "created_at", "duration", "id", "interaction_points", "objective", "resource_ids", "sort_order", "step_index", "step_name", "step_type", "teacher_script", "updated_at") SELECT "course_id", "created_at", "duration", "id", "interaction_points", "objective", "resource_ids", "sort_order", "step_index", "step_name", "step_type", "teacher_script", "updated_at" FROM "course_scripts";
|
||||
DROP TABLE "course_scripts";
|
||||
ALTER TABLE "new_course_scripts" RENAME TO "course_scripts";
|
||||
CREATE UNIQUE INDEX "course_scripts_course_id_step_index_key" ON "course_scripts"("course_id", "step_index");
|
||||
CREATE TABLE "new_courses" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture_book_id" INTEGER NOT NULL,
|
||||
"picture_book_name" TEXT,
|
||||
"grade_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"domain_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"duration" INTEGER NOT NULL DEFAULT 25,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"version" TEXT NOT NULL DEFAULT '1.0',
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"avg_rating" REAL NOT NULL DEFAULT 0,
|
||||
"created_by" INTEGER,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"published_at" DATETIME
|
||||
);
|
||||
INSERT INTO "new_courses" ("avg_rating", "created_at", "created_by", "description", "domain_tags", "duration", "grade_tags", "id", "name", "picture_book_id", "picture_book_name", "published_at", "status", "teacher_count", "updated_at", "usage_count", "version") SELECT "avg_rating", "created_at", "created_by", "description", "domain_tags", "duration", "grade_tags", "id", "name", "picture_book_id", "picture_book_name", "published_at", "status", "teacher_count", "updated_at", "usage_count", "version" FROM "courses";
|
||||
DROP TABLE "courses";
|
||||
ALTER TABLE "new_courses" RENAME TO "courses";
|
||||
CREATE TABLE "new_lesson_feedbacks" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"lesson_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER NOT NULL,
|
||||
"design_quality" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"goal_achievement" INTEGER,
|
||||
"step_feedbacks" TEXT,
|
||||
"pros" TEXT,
|
||||
"suggestions" TEXT,
|
||||
"activities_done" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lesson_feedbacks_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "lesson_feedbacks_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_lesson_feedbacks" ("activities_done", "created_at", "design_quality", "goal_achievement", "id", "lesson_id", "participation", "pros", "step_feedbacks", "suggestions", "teacher_id", "updated_at") SELECT "activities_done", "created_at", "design_quality", "goal_achievement", "id", "lesson_id", "participation", "pros", "step_feedbacks", "suggestions", "teacher_id", "updated_at" FROM "lesson_feedbacks";
|
||||
DROP TABLE "lesson_feedbacks";
|
||||
ALTER TABLE "new_lesson_feedbacks" RENAME TO "lesson_feedbacks";
|
||||
CREATE UNIQUE INDEX "lesson_feedbacks_lesson_id_teacher_id_key" ON "lesson_feedbacks"("lesson_id", "teacher_id");
|
||||
CREATE TABLE "new_lessons" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"planned_datetime" DATETIME,
|
||||
"start_datetime" DATETIME,
|
||||
"end_datetime" DATETIME,
|
||||
"actual_duration" INTEGER,
|
||||
"status" TEXT NOT NULL DEFAULT 'PLANNED',
|
||||
"overall_rating" TEXT,
|
||||
"participation_rating" TEXT,
|
||||
"completion_note" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lessons_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_lessons" ("actual_duration", "class_id", "completion_note", "course_id", "created_at", "end_datetime", "id", "overall_rating", "participation_rating", "planned_datetime", "start_datetime", "status", "teacher_id", "tenant_id", "updated_at") SELECT "actual_duration", "class_id", "completion_note", "course_id", "created_at", "end_datetime", "id", "overall_rating", "participation_rating", "planned_datetime", "start_datetime", "status", "teacher_id", "tenant_id", "updated_at" FROM "lessons";
|
||||
DROP TABLE "lessons";
|
||||
ALTER TABLE "new_lessons" RENAME TO "lessons";
|
||||
CREATE TABLE "new_student_records" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"lesson_id" INTEGER NOT NULL,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"focus" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"interest" INTEGER,
|
||||
"understanding" INTEGER,
|
||||
"domainAchievements" TEXT,
|
||||
"notes" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "student_records_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "student_records_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_student_records" ("created_at", "domainAchievements", "focus", "id", "interest", "lesson_id", "notes", "participation", "student_id", "understanding", "updated_at") SELECT "created_at", "domainAchievements", "focus", "id", "interest", "lesson_id", "notes", "participation", "student_id", "understanding", "updated_at" FROM "student_records";
|
||||
DROP TABLE "student_records";
|
||||
ALTER TABLE "new_student_records" RENAME TO "student_records";
|
||||
CREATE UNIQUE INDEX "student_records_lesson_id_student_id_key" ON "student_records"("lesson_id", "student_id");
|
||||
CREATE TABLE "new_students" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"gender" TEXT,
|
||||
"birth_date" DATETIME,
|
||||
"parent_phone" TEXT,
|
||||
"parent_name" TEXT,
|
||||
"reading_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "students_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "students_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_students" ("birth_date", "class_id", "created_at", "gender", "id", "lesson_count", "name", "parent_name", "parent_phone", "reading_count", "tenant_id", "updated_at") SELECT "birth_date", "class_id", "created_at", "gender", "id", "lesson_count", "name", "parent_name", "parent_phone", "reading_count", "tenant_id", "updated_at" FROM "students";
|
||||
DROP TABLE "students";
|
||||
ALTER TABLE "new_students" RENAME TO "students";
|
||||
CREATE TABLE "new_tags" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"level" INTEGER NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parent_id" INTEGER,
|
||||
"description" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO "new_tags" ("code", "created_at", "description", "id", "level", "metadata", "name", "parent_id", "sort_order") SELECT "code", "created_at", "description", "id", "level", "metadata", "name", "parent_id", "sort_order" FROM "tags";
|
||||
DROP TABLE "tags";
|
||||
ALTER TABLE "new_tags" RENAME TO "tags";
|
||||
CREATE UNIQUE INDEX "tags_code_key" ON "tags"("code");
|
||||
CREATE TABLE "new_teachers" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"login_account" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"class_ids" TEXT DEFAULT '[]',
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"feedback_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"last_login_at" DATETIME,
|
||||
CONSTRAINT "teachers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_teachers" ("class_ids", "created_at", "email", "feedback_count", "id", "last_login_at", "lesson_count", "login_account", "name", "password_hash", "phone", "status", "tenant_id", "updated_at") SELECT "class_ids", "created_at", "email", "feedback_count", "id", "last_login_at", "lesson_count", "login_account", "name", "password_hash", "phone", "status", "tenant_id", "updated_at" FROM "teachers";
|
||||
DROP TABLE "teachers";
|
||||
ALTER TABLE "new_teachers" RENAME TO "teachers";
|
||||
CREATE UNIQUE INDEX "teachers_login_account_key" ON "teachers"("login_account");
|
||||
CREATE TABLE "new_tenant_courses" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"authorized" BOOLEAN NOT NULL DEFAULT true,
|
||||
"authorized_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "tenant_courses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tenant_courses_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_tenant_courses" ("authorized", "authorized_at", "course_id", "created_at", "id", "tenant_id") SELECT "authorized", "authorized_at", "course_id", "created_at", "id", "tenant_id" FROM "tenant_courses";
|
||||
DROP TABLE "tenant_courses";
|
||||
ALTER TABLE "new_tenant_courses" RENAME TO "tenant_courses";
|
||||
CREATE UNIQUE INDEX "tenant_courses_tenant_id_course_id_key" ON "tenant_courses"("tenant_id", "course_id");
|
||||
CREATE TABLE "new_tenants" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"contact_person" TEXT,
|
||||
"contact_phone" TEXT,
|
||||
"logo_url" TEXT,
|
||||
"package_type" TEXT NOT NULL DEFAULT 'STANDARD',
|
||||
"teacher_quota" INTEGER NOT NULL DEFAULT 20,
|
||||
"student_quota" INTEGER NOT NULL DEFAULT 200,
|
||||
"storage_quota" BIGINT NOT NULL DEFAULT 5368709120,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"expire_date" TEXT NOT NULL,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"storage_used" BIGINT NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_tenants" ("address", "contact_person", "contact_phone", "created_at", "expire_date", "id", "logo_url", "name", "package_type", "start_date", "status", "storage_quota", "storage_used", "student_count", "student_quota", "teacher_count", "teacher_quota", "updated_at") SELECT "address", "contact_person", "contact_phone", "created_at", "expire_date", "id", "logo_url", "name", "package_type", "start_date", "status", "storage_quota", "storage_used", "student_count", "student_quota", "teacher_count", "teacher_quota", "updated_at" FROM "tenants";
|
||||
DROP TABLE "tenants";
|
||||
ALTER TABLE "new_tenants" RENAME TO "tenants";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@ -0,0 +1,27 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_courses" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture_book_id" INTEGER,
|
||||
"picture_book_name" TEXT,
|
||||
"grade_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"domain_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"duration" INTEGER NOT NULL DEFAULT 25,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"version" TEXT NOT NULL DEFAULT '1.0',
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"avg_rating" REAL NOT NULL DEFAULT 0,
|
||||
"created_by" INTEGER,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"published_at" DATETIME
|
||||
);
|
||||
INSERT INTO "new_courses" ("avg_rating", "created_at", "created_by", "description", "domain_tags", "duration", "grade_tags", "id", "name", "picture_book_id", "picture_book_name", "published_at", "status", "teacher_count", "updated_at", "usage_count", "version") SELECT "avg_rating", "created_at", "created_by", "description", "domain_tags", "duration", "grade_tags", "id", "name", "picture_book_id", "picture_book_name", "published_at", "status", "teacher_count", "updated_at", "usage_count", "version" FROM "courses";
|
||||
DROP TABLE "courses";
|
||||
ALTER TABLE "new_courses" RENAME TO "courses";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@ -0,0 +1,634 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenants" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"login_account" TEXT,
|
||||
"password_hash" TEXT,
|
||||
"address" TEXT,
|
||||
"contact_person" TEXT,
|
||||
"contact_phone" TEXT,
|
||||
"logo_url" TEXT,
|
||||
"package_type" TEXT NOT NULL DEFAULT 'STANDARD',
|
||||
"teacher_quota" INTEGER NOT NULL DEFAULT 20,
|
||||
"student_quota" INTEGER NOT NULL DEFAULT 200,
|
||||
"storage_quota" BIGINT NOT NULL DEFAULT 5368709120,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"expire_date" TEXT NOT NULL,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"storage_used" BIGINT NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "teachers" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"login_account" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"class_ids" TEXT DEFAULT '[]',
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"feedback_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"last_login_at" DATETIME,
|
||||
CONSTRAINT "teachers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "classes" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"teacher_id" INTEGER,
|
||||
"student_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "classes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "classes_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "students" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"gender" TEXT,
|
||||
"birth_date" DATETIME,
|
||||
"parent_phone" TEXT,
|
||||
"parent_name" TEXT,
|
||||
"reading_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"lesson_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "students_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "students_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "courses" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"picture_book_id" INTEGER,
|
||||
"picture_book_name" TEXT,
|
||||
"cover_image_path" TEXT,
|
||||
"ebook_paths" TEXT,
|
||||
"audio_paths" TEXT,
|
||||
"video_paths" TEXT,
|
||||
"other_resources" TEXT,
|
||||
"ppt_path" TEXT,
|
||||
"ppt_name" TEXT,
|
||||
"poster_paths" TEXT,
|
||||
"tools" TEXT,
|
||||
"student_materials" TEXT,
|
||||
"lesson_plan_data" TEXT,
|
||||
"activities_data" TEXT,
|
||||
"assessment_data" TEXT,
|
||||
"grade_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"domain_tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"duration" INTEGER NOT NULL DEFAULT 25,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"version" TEXT NOT NULL DEFAULT '1.0',
|
||||
"submitted_at" DATETIME,
|
||||
"submitted_by" INTEGER,
|
||||
"reviewed_at" DATETIME,
|
||||
"reviewed_by" INTEGER,
|
||||
"review_comment" TEXT,
|
||||
"review_checklist" TEXT,
|
||||
"parent_id" INTEGER,
|
||||
"isLatest" BOOLEAN NOT NULL DEFAULT true,
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"teacher_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"avg_rating" REAL NOT NULL DEFAULT 0,
|
||||
"created_by" INTEGER,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"published_at" DATETIME
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_versions" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"snapshotData" TEXT NOT NULL,
|
||||
"changeLog" TEXT,
|
||||
"published_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"published_by" INTEGER NOT NULL,
|
||||
CONSTRAINT "course_versions_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_resources" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_name" TEXT NOT NULL,
|
||||
"file_url" TEXT NOT NULL,
|
||||
"file_size" INTEGER,
|
||||
"mime_type" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_resources_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_scripts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"step_index" INTEGER NOT NULL,
|
||||
"step_name" TEXT NOT NULL,
|
||||
"step_type" TEXT NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"objective" TEXT,
|
||||
"teacher_script" TEXT,
|
||||
"interaction_points" TEXT,
|
||||
"resource_ids" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_scripts_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_script_pages" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"script_id" INTEGER NOT NULL,
|
||||
"page_number" INTEGER NOT NULL,
|
||||
"questions" TEXT,
|
||||
"interaction_component" TEXT,
|
||||
"teacher_notes" TEXT,
|
||||
"resource_ids" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "course_script_pages_script_id_fkey" FOREIGN KEY ("script_id") REFERENCES "course_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "course_activities" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT,
|
||||
"domain_tag_id" INTEGER,
|
||||
"activity_type" TEXT NOT NULL,
|
||||
"duration" INTEGER,
|
||||
"online_materials" TEXT,
|
||||
"offlineMaterials" TEXT,
|
||||
"activityGuide" TEXT,
|
||||
"objectives" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_activities_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lessons" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"schedule_plan_id" INTEGER,
|
||||
"planned_datetime" DATETIME,
|
||||
"start_datetime" DATETIME,
|
||||
"end_datetime" DATETIME,
|
||||
"actual_duration" INTEGER,
|
||||
"status" TEXT NOT NULL DEFAULT 'PLANNED',
|
||||
"overall_rating" TEXT,
|
||||
"participation_rating" TEXT,
|
||||
"completion_note" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lessons_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "lessons_schedule_plan_id_fkey" FOREIGN KEY ("schedule_plan_id") REFERENCES "schedule_plans" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lesson_feedbacks" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"lesson_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER NOT NULL,
|
||||
"design_quality" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"goal_achievement" INTEGER,
|
||||
"step_feedbacks" TEXT,
|
||||
"pros" TEXT,
|
||||
"suggestions" TEXT,
|
||||
"activities_done" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "lesson_feedbacks_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "lesson_feedbacks_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "student_records" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"lesson_id" INTEGER NOT NULL,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"focus" INTEGER,
|
||||
"participation" INTEGER,
|
||||
"interest" INTEGER,
|
||||
"understanding" INTEGER,
|
||||
"domainAchievements" TEXT,
|
||||
"notes" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "student_records_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "lessons" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "student_records_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"level" INTEGER NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parent_id" INTEGER,
|
||||
"description" TEXT,
|
||||
"metadata" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_courses" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"authorized" BOOLEAN NOT NULL DEFAULT true,
|
||||
"authorized_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "tenant_courses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tenant_courses_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "growth_records" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER,
|
||||
"record_type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT,
|
||||
"images" TEXT,
|
||||
"record_date" DATETIME NOT NULL,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "growth_records_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "growth_records_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "growth_records_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tasks" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"task_type" TEXT NOT NULL,
|
||||
"target_type" TEXT NOT NULL,
|
||||
"related_course_id" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"start_date" DATETIME NOT NULL,
|
||||
"end_date" DATETIME NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PUBLISHED',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "tasks_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tasks_related_course_id_fkey" FOREIGN KEY ("related_course_id") REFERENCES "courses" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "task_targets" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"task_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER,
|
||||
"student_id" INTEGER,
|
||||
CONSTRAINT "task_targets_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "task_completions" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"task_id" INTEGER NOT NULL,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"completed_at" DATETIME,
|
||||
"feedback" TEXT,
|
||||
"parent_feedback" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "task_completions_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_completions_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "task_templates" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"task_type" TEXT NOT NULL,
|
||||
"related_course_id" INTEGER,
|
||||
"default_duration" INTEGER NOT NULL DEFAULT 7,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "task_templates_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_templates_related_course_id_fkey" FOREIGN KEY ("related_course_id") REFERENCES "courses" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "resource_libraries" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"library_type" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"cover_image" TEXT,
|
||||
"tenant_id" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PUBLISHED',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "resource_items" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"library_id" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"file_type" TEXT NOT NULL,
|
||||
"file_path" TEXT NOT NULL,
|
||||
"file_size" INTEGER,
|
||||
"tags" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "resource_items_library_id_fkey" FOREIGN KEY ("library_id") REFERENCES "resource_libraries" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "system_settings" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"school_name" TEXT,
|
||||
"school_logo" TEXT,
|
||||
"address" TEXT,
|
||||
"notify_on_lesson" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notify_on_task" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notify_on_growth" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "system_settings_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parents" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"login_account" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
"last_login_at" DATETIME,
|
||||
CONSTRAINT "parents_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "parent_students" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"parent_id" INTEGER NOT NULL,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"relationship" TEXT NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "parent_students_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "parents" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "parent_students_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notifications" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"recipient_type" TEXT NOT NULL,
|
||||
"recipient_id" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"notification_type" TEXT NOT NULL,
|
||||
"related_type" TEXT,
|
||||
"related_id" INTEGER,
|
||||
"is_read" BOOLEAN NOT NULL DEFAULT false,
|
||||
"read_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "notifications_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "class_teachers" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'MAIN',
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "class_teachers_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "class_teachers_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "student_class_history" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"student_id" INTEGER NOT NULL,
|
||||
"from_class_id" INTEGER,
|
||||
"to_class_id" INTEGER NOT NULL,
|
||||
"reason" TEXT,
|
||||
"operated_by" INTEGER,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "student_class_history_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "students" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "student_class_history_from_class_id_fkey" FOREIGN KEY ("from_class_id") REFERENCES "classes" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "student_class_history_to_class_id_fkey" FOREIGN KEY ("to_class_id") REFERENCES "classes" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "schedule_plans" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"teacher_id" INTEGER,
|
||||
"scheduled_date" DATETIME,
|
||||
"scheduled_time" TEXT,
|
||||
"week_day" INTEGER,
|
||||
"repeat_type" TEXT NOT NULL DEFAULT 'NONE',
|
||||
"repeat_end_date" DATETIME,
|
||||
"source" TEXT NOT NULL DEFAULT 'SCHOOL',
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"note" TEXT,
|
||||
"reminder_sent" BOOLEAN NOT NULL DEFAULT false,
|
||||
"reminder_sent_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "schedule_plans_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_plans_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_plans_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_plans_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "schedule_templates" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"course_id" INTEGER NOT NULL,
|
||||
"class_id" INTEGER,
|
||||
"teacher_id" INTEGER,
|
||||
"scheduled_time" TEXT,
|
||||
"week_day" INTEGER,
|
||||
"duration" INTEGER NOT NULL DEFAULT 25,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "schedule_templates_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_templates_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_templates_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classes" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "schedule_templates_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "teachers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "operation_logs" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" INTEGER,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"user_type" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"module" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"target_id" INTEGER,
|
||||
"old_value" TEXT,
|
||||
"new_value" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "operation_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenants_login_account_key" ON "tenants"("login_account");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "teachers_login_account_key" ON "teachers"("login_account");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "courses_status_idx" ON "courses"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "course_versions_course_id_idx" ON "course_versions"("course_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "course_scripts_course_id_step_index_key" ON "course_scripts"("course_id", "step_index");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "course_script_pages_script_id_page_number_key" ON "course_script_pages"("script_id", "page_number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "lesson_feedbacks_lesson_id_teacher_id_key" ON "lesson_feedbacks"("lesson_id", "teacher_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "student_records_lesson_id_student_id_key" ON "student_records"("lesson_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_code_key" ON "tags"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_courses_tenant_id_course_id_key" ON "tenant_courses"("tenant_id", "course_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "growth_records_tenant_id_student_id_idx" ON "growth_records"("tenant_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "growth_records_tenant_id_class_id_idx" ON "growth_records"("tenant_id", "class_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_tenant_id_status_idx" ON "tasks"("tenant_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "task_targets_task_id_class_id_idx" ON "task_targets"("task_id", "class_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "task_targets_task_id_student_id_idx" ON "task_targets"("task_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "task_completions_task_id_student_id_key" ON "task_completions"("task_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "task_templates_tenant_id_status_idx" ON "task_templates"("tenant_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "resource_libraries_library_type_status_idx" ON "resource_libraries"("library_type", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "resource_items_library_id_idx" ON "resource_items"("library_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "system_settings_tenant_id_key" ON "system_settings"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parents_login_account_key" ON "parents"("login_account");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "parent_students_parent_id_student_id_key" ON "parent_students"("parent_id", "student_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_tenant_id_recipient_type_recipient_id_idx" ON "notifications"("tenant_id", "recipient_type", "recipient_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "class_teachers_class_id_teacher_id_key" ON "class_teachers"("class_id", "teacher_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "schedule_plans_tenant_id_class_id_idx" ON "schedule_plans"("tenant_id", "class_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "schedule_plans_tenant_id_teacher_id_idx" ON "schedule_plans"("tenant_id", "teacher_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "schedule_plans_tenant_id_scheduled_date_idx" ON "schedule_plans"("tenant_id", "scheduled_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "schedule_templates_tenant_id_idx" ON "schedule_templates"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "schedule_templates_tenant_id_course_id_idx" ON "schedule_templates"("tenant_id", "course_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "operation_logs_tenant_id_user_id_idx" ON "operation_logs"("tenant_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "operation_logs_tenant_id_created_at_idx" ON "operation_logs"("tenant_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "operation_logs_action_module_idx" ON "operation_logs"("action", "module");
|
||||
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
1129
reading-platform-backend/prisma/schema-v2.prisma
Normal file
1129
reading-platform-backend/prisma/schema-v2.prisma
Normal file
File diff suppressed because it is too large
Load Diff
1073
reading-platform-backend/prisma/schema.prisma
Normal file
1073
reading-platform-backend/prisma/schema.prisma
Normal file
File diff suppressed because it is too large
Load Diff
431
reading-platform-backend/prisma/seed.ts
Normal file
431
reading-platform-backend/prisma/seed.ts
Normal file
@ -0,0 +1,431 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始种子数据...');
|
||||
|
||||
// 1. 创建测试租户
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
name: '阳光幼儿园',
|
||||
address: '北京市朝阳区xxx街道',
|
||||
contactPerson: '张园长',
|
||||
contactPhone: '13800138000',
|
||||
packageType: 'STANDARD',
|
||||
teacherQuota: 20,
|
||||
studentQuota: 200,
|
||||
storageQuota: BigInt(5368709120), // 5GB
|
||||
startDate: '2024-01-01',
|
||||
expireDate: '2025-12-31',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
console.log('创建租户:', tenant.name);
|
||||
|
||||
// 2. 创建教师账号
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
const teacher = await prisma.teacher.upsert({
|
||||
where: { loginAccount: 'teacher1' },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
name: '李老师',
|
||||
phone: '13900139000',
|
||||
email: 'teacher1@example.com',
|
||||
loginAccount: 'teacher1',
|
||||
passwordHash: passwordHash,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
console.log('创建教师:', teacher.name);
|
||||
|
||||
// 3. 创建班级
|
||||
const class1 = await prisma.class.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
name: '中一班',
|
||||
grade: 'MIDDLE',
|
||||
teacherId: teacher.id,
|
||||
studentCount: 25,
|
||||
},
|
||||
});
|
||||
console.log('创建班级:', class1.name);
|
||||
|
||||
const class2 = await prisma.class.upsert({
|
||||
where: { id: 2 },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
name: '大一班',
|
||||
grade: 'BIG',
|
||||
teacherId: teacher.id,
|
||||
studentCount: 30,
|
||||
},
|
||||
});
|
||||
console.log('创建班级:', class2.name);
|
||||
|
||||
// 4. 更新教师的班级关联
|
||||
await prisma.teacher.update({
|
||||
where: { id: teacher.id },
|
||||
data: {
|
||||
classIds: JSON.stringify([class1.id, class2.id]),
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 创建示例学生
|
||||
const students = [
|
||||
{ name: '小明', gender: 'MALE', classId: class1.id },
|
||||
{ name: '小红', gender: 'FEMALE', classId: class1.id },
|
||||
{ name: '小华', gender: 'MALE', classId: class1.id },
|
||||
{ name: '小丽', gender: 'FEMALE', classId: class2.id },
|
||||
{ name: '小强', gender: 'MALE', classId: class2.id },
|
||||
];
|
||||
|
||||
for (const studentData of students) {
|
||||
await prisma.student.upsert({
|
||||
where: {
|
||||
id: students.indexOf(studentData) + 1,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
classId: studentData.classId,
|
||||
name: studentData.name,
|
||||
gender: studentData.gender,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log('创建学生:', students.length, '名');
|
||||
|
||||
// 6. 创建示例课程包
|
||||
const course = await prisma.course.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
name: '好饿的毛毛虫',
|
||||
description: '这是一本经典的绘本,讲述了一只毛毛虫从孵化到变成蝴蝶的故事。通过这个故事,孩子们可以学习到星期的概念、数字的认知,以及毛毛虫变蝴蝶的科学知识。',
|
||||
pictureBookName: '好饿的毛毛虫',
|
||||
gradeTags: JSON.stringify(['SMALL', 'MIDDLE']),
|
||||
domainTags: JSON.stringify(['LANGUAGE', 'SCIENCE', 'MATH']),
|
||||
duration: 30,
|
||||
status: 'PUBLISHED',
|
||||
version: '1.0',
|
||||
coverImagePath: '/uploads/covers/caterpillar.jpg',
|
||||
},
|
||||
});
|
||||
console.log('创建课程:', course.name);
|
||||
|
||||
// 7. 创建课程脚本(6步教学流程)
|
||||
const scripts = [
|
||||
{
|
||||
stepIndex: 1,
|
||||
stepName: '阅读导入',
|
||||
stepType: 'INTRODUCTION',
|
||||
duration: 5,
|
||||
objective: '激发幼儿阅读兴趣,建立阅读期待',
|
||||
teacherScript: '小朋友们,今天我们要认识一位新朋友——一只小小的毛毛虫。你们见过毛毛虫吗?它长什么样子呢?让我们一起来看看这只特别的毛毛虫的故事吧!',
|
||||
interactionPoints: JSON.stringify([
|
||||
'展示毛毛虫图片或玩偶',
|
||||
'引导幼儿分享见过的毛毛虫',
|
||||
'预测故事内容',
|
||||
]),
|
||||
},
|
||||
{
|
||||
stepIndex: 2,
|
||||
stepName: '绘本共读',
|
||||
stepType: 'READING',
|
||||
duration: 10,
|
||||
objective: '理解故事内容,发展语言能力',
|
||||
teacherScript: '(逐页讲述)从前,有一颗小小的蛋躺在叶子上...月光下,一条又小又饿的毛毛虫从蛋里爬了出来...',
|
||||
interactionPoints: JSON.stringify([
|
||||
'提问预测',
|
||||
'模仿毛毛虫吃东西的动作',
|
||||
'一起数食物的数量',
|
||||
]),
|
||||
},
|
||||
{
|
||||
stepIndex: 3,
|
||||
stepName: '理解讨论',
|
||||
stepType: 'DISCUSSION',
|
||||
duration: 5,
|
||||
objective: '加深对故事的理解,发展思维能力',
|
||||
teacherScript: '小朋友们,毛毛虫吃了哪些东西呢?为什么最后它肚子痛了?它最后变成了什么?',
|
||||
interactionPoints: JSON.stringify([
|
||||
'回顾毛毛虫吃的食物',
|
||||
'讨论健康饮食的重要性',
|
||||
'讨论毛毛虫的成长变化',
|
||||
]),
|
||||
},
|
||||
{
|
||||
stepIndex: 4,
|
||||
stepName: '互动游戏',
|
||||
stepType: 'ACTIVITY',
|
||||
duration: 5,
|
||||
objective: '通过游戏巩固学习内容',
|
||||
teacherScript: '现在我们来玩一个游戏,老师说出星期几,小朋友们来模仿毛毛虫吃了什么!',
|
||||
interactionPoints: JSON.stringify([
|
||||
'星期与食物配对游戏',
|
||||
'毛毛虫动作模仿',
|
||||
'食物分类活动',
|
||||
]),
|
||||
},
|
||||
{
|
||||
stepIndex: 5,
|
||||
stepName: '创意表达',
|
||||
stepType: 'CREATIVE',
|
||||
duration: 3,
|
||||
objective: '发展创造力和表达能力',
|
||||
teacherScript: '如果你是毛毛虫,你想吃什么?画一画你心目中的毛毛虫吧!',
|
||||
interactionPoints: JSON.stringify([
|
||||
'自由绘画',
|
||||
'分享作品',
|
||||
'创意表达',
|
||||
]),
|
||||
},
|
||||
{
|
||||
stepIndex: 6,
|
||||
stepName: '总结延伸',
|
||||
stepType: 'SUMMARY',
|
||||
duration: 2,
|
||||
objective: '总结学习内容,激发延伸探索兴趣',
|
||||
teacherScript: '今天我们认识了一只可爱的毛毛虫,它从一颗小蛋,变成毛毛虫,最后变成了漂亮的蝴蝶!回家后可以和爸爸妈妈一起找找看,还有哪些动物会变形呢?',
|
||||
interactionPoints: JSON.stringify([
|
||||
'总结毛毛虫的成长过程',
|
||||
'布置家庭延伸任务',
|
||||
'预告下次活动',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
for (const script of scripts) {
|
||||
await prisma.courseScript.upsert({
|
||||
where: {
|
||||
courseId_stepIndex: {
|
||||
courseId: course.id,
|
||||
stepIndex: script.stepIndex,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: course.id,
|
||||
...script,
|
||||
sortOrder: script.stepIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log('创建课程脚本:', scripts.length, '个步骤');
|
||||
|
||||
// 8. 创建逐页配置(为绘本共读步骤添加)
|
||||
const pages = [
|
||||
{ pageNumber: 1, questions: '你们看到了什么?这是什么颜色的?', teacherNotes: '引导观察封面' },
|
||||
{ pageNumber: 2, questions: '蛋在哪里?是谁的蛋呢?', teacherNotes: '引入故事悬念' },
|
||||
{ pageNumber: 3, questions: '毛毛虫从蛋里出来了!它说了什么?', teacherNotes: '模仿毛毛虫的声音' },
|
||||
{ pageNumber: 4, questions: '星期一,毛毛虫吃了什么?吃了几个?', teacherNotes: '学习星期和数字' },
|
||||
{ pageNumber: 5, questions: '星期二,它又吃了什么?', teacherNotes: '继续学习星期' },
|
||||
];
|
||||
|
||||
const readingScript = await prisma.courseScript.findFirst({
|
||||
where: { courseId: course.id, stepType: 'READING' },
|
||||
});
|
||||
|
||||
if (readingScript) {
|
||||
for (const page of pages) {
|
||||
await prisma.courseScriptPage.upsert({
|
||||
where: {
|
||||
scriptId_pageNumber: {
|
||||
scriptId: readingScript.id,
|
||||
pageNumber: page.pageNumber,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
scriptId: readingScript.id,
|
||||
...page,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log('创建逐页配置:', pages.length, '页');
|
||||
}
|
||||
|
||||
// 9. 创建延伸活动
|
||||
const activities = [
|
||||
{
|
||||
name: '毛毛虫手偶制作',
|
||||
domain: 'ART',
|
||||
activityType: 'HANDICRAFT',
|
||||
duration: 20,
|
||||
onlineMaterials: JSON.stringify(['毛毛虫模板PDF', '制作视频']),
|
||||
offlineMaterials: '彩纸、剪刀、胶水、眼睛贴纸',
|
||||
activityGuide: '1. 准备材料\n2. 按照模板剪裁\n3. 粘贴组装\n4. 添加装饰',
|
||||
objectives: JSON.stringify(['锻炼手部精细动作', '培养创造力', '巩固毛毛虫认知']),
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
name: '健康饮食分类',
|
||||
domain: 'SCIENCE',
|
||||
activityType: 'GAME',
|
||||
duration: 15,
|
||||
onlineMaterials: JSON.stringify(['食物卡片PPT']),
|
||||
offlineMaterials: '食物图片卡片、分类筐',
|
||||
activityGuide: '1. 展示各种食物图片\n2. 讨论健康与不健康食物\n3. 进行分类游戏',
|
||||
objectives: JSON.stringify(['认识健康饮食', '学习分类', '培养健康饮食习惯']),
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
name: '蝴蝶的生命周期',
|
||||
domain: 'SCIENCE',
|
||||
activityType: 'EXPLORATION',
|
||||
duration: 25,
|
||||
onlineMaterials: JSON.stringify(['蝴蝶生长视频', '生命周期图']),
|
||||
offlineMaterials: '绘本、放大镜、观察记录本',
|
||||
activityGuide: '1. 观看蝴蝶生长视频\n2. 讨论四个阶段\n3. 绘制生命周期图',
|
||||
objectives: JSON.stringify(['了解变态发育', '培养科学探究精神', '学习观察记录']),
|
||||
sortOrder: 3,
|
||||
},
|
||||
];
|
||||
|
||||
for (const activity of activities) {
|
||||
await prisma.courseActivity.upsert({
|
||||
where: { id: activities.indexOf(activity) + 1 },
|
||||
update: {},
|
||||
create: {
|
||||
courseId: course.id,
|
||||
...activity,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log('创建延伸活动:', activities.length, '个');
|
||||
|
||||
// 10. 为租户授权课程
|
||||
const tenantCourse = await prisma.tenantCourse.upsert({
|
||||
where: {
|
||||
tenantId_courseId: {
|
||||
tenantId: tenant.id,
|
||||
courseId: course.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
courseId: course.id,
|
||||
authorized: true,
|
||||
authorizedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log('授权课程给租户:', tenant.id, '->', course.id);
|
||||
|
||||
// 11. 创建第二个示例课程
|
||||
const course2 = await prisma.course.upsert({
|
||||
where: { id: 2 },
|
||||
update: {},
|
||||
create: {
|
||||
name: '猜猜我有多爱你',
|
||||
description: '这是一本关于爱的温暖绘本,小兔子和大兔子用各种方式表达彼此的爱。通过这个故事,孩子们可以学习到表达爱的方式,感受亲情的温暖。',
|
||||
pictureBookName: '猜猜我有多爱你',
|
||||
gradeTags: JSON.stringify(['MIDDLE', 'BIG']),
|
||||
domainTags: JSON.stringify(['LANGUAGE', 'SOCIAL']),
|
||||
duration: 25,
|
||||
status: 'PUBLISHED',
|
||||
version: '1.0',
|
||||
coverImagePath: '/uploads/covers/love.jpg',
|
||||
},
|
||||
});
|
||||
console.log('创建课程:', course2.name);
|
||||
|
||||
// 为第二个课程创建简化脚本
|
||||
const scripts2 = [
|
||||
{
|
||||
stepIndex: 1,
|
||||
stepName: '导入环节',
|
||||
stepType: 'INTRODUCTION',
|
||||
duration: 3,
|
||||
objective: '引入爱的主题',
|
||||
teacherScript: '小朋友们,你们爱爸爸妈妈吗?你们是怎么表达爱的呢?',
|
||||
interactionPoints: JSON.stringify(['分享表达爱的方式']),
|
||||
},
|
||||
{
|
||||
stepIndex: 2,
|
||||
stepName: '绘本共读',
|
||||
stepType: 'READING',
|
||||
duration: 10,
|
||||
objective: '理解故事,感受爱的表达',
|
||||
teacherScript: '小栗色兔子该上床睡觉了,可是他紧紧地抓住大栗色兔子的长耳朵不放...',
|
||||
interactionPoints: JSON.stringify(['模仿动作', '感受爱的比较']),
|
||||
},
|
||||
{
|
||||
stepIndex: 3,
|
||||
stepName: '情感讨论',
|
||||
stepType: 'DISCUSSION',
|
||||
duration: 5,
|
||||
objective: '表达自己的感受',
|
||||
teacherScript: '小兔子和大兔子谁的爱更多呢?你们觉得呢?',
|
||||
interactionPoints: JSON.stringify(['讨论爱的深度', '分享感受']),
|
||||
},
|
||||
{
|
||||
stepIndex: 4,
|
||||
stepName: '爱的表达',
|
||||
stepType: 'ACTIVITY',
|
||||
duration: 5,
|
||||
objective: '学会表达爱',
|
||||
teacherScript: '让我们也来学学小兔子,用手臂来量量我们有多爱爸爸妈妈!',
|
||||
interactionPoints: JSON.stringify(['肢体表达', '语言表达']),
|
||||
},
|
||||
];
|
||||
|
||||
for (const script of scripts2) {
|
||||
await prisma.courseScript.upsert({
|
||||
where: {
|
||||
courseId_stepIndex: {
|
||||
courseId: course2.id,
|
||||
stepIndex: script.stepIndex,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: course2.id,
|
||||
...script,
|
||||
sortOrder: script.stepIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 授权第二个课程
|
||||
await prisma.tenantCourse.upsert({
|
||||
where: {
|
||||
tenantId_courseId: {
|
||||
tenantId: tenant.id,
|
||||
courseId: course2.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
courseId: course2.id,
|
||||
authorized: true,
|
||||
authorizedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log('授权课程给租户:', tenant.id, '->', course2.id);
|
||||
|
||||
console.log('\n种子数据创建完成!');
|
||||
console.log('====================');
|
||||
console.log('测试账号信息:');
|
||||
console.log('超管: admin / 123456');
|
||||
console.log('教师: teacher1 / 123456');
|
||||
console.log('====================');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
99
reading-platform-backend/scripts/create-test-parent.ts
Normal file
99
reading-platform-backend/scripts/create-test-parent.ts
Normal file
@ -0,0 +1,99 @@
|
||||
// 创建测试家长账号脚本
|
||||
// 运行方式: npx ts-node scripts/create-test-parent.ts
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始创建测试家长账号...');
|
||||
|
||||
// 获取第一个租户
|
||||
const tenant = await prisma.tenant.findFirst();
|
||||
|
||||
if (!tenant) {
|
||||
console.log('未找到租户,请先创建学校');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`使用租户: ${tenant.name} (ID: ${tenant.id})`);
|
||||
|
||||
// 获取一些学生用于关联
|
||||
const students = await prisma.student.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
console.log(`找到 ${students.length} 个学生`);
|
||||
|
||||
// 创建测试家长
|
||||
const hashedPassword = await bcrypt.hash('123456', 10);
|
||||
|
||||
const parentData = [
|
||||
{
|
||||
name: '张爸爸',
|
||||
phone: '13800138001',
|
||||
email: 'zhang@test.com',
|
||||
loginAccount: 'parent1',
|
||||
passwordHash: hashedPassword,
|
||||
},
|
||||
{
|
||||
name: '李妈妈',
|
||||
phone: '13800138002',
|
||||
email: 'li@test.com',
|
||||
loginAccount: 'parent2',
|
||||
passwordHash: hashedPassword,
|
||||
},
|
||||
];
|
||||
|
||||
for (const data of parentData) {
|
||||
// 检查是否已存在
|
||||
const existing = await prisma.parent.findUnique({
|
||||
where: { loginAccount: data.loginAccount },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log(`家长 ${data.loginAccount} 已存在,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
...data,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`创建家长: ${parent.name} (${parent.loginAccount})`);
|
||||
|
||||
// 关联学生
|
||||
if (students.length > 0) {
|
||||
const studentToLink = students[parentData.indexOf(data) % students.length];
|
||||
|
||||
await prisma.parentStudent.create({
|
||||
data: {
|
||||
parentId: parent.id,
|
||||
studentId: studentToLink.id,
|
||||
relationship: data.name.includes('爸爸') ? 'FATHER' : 'MOTHER',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` -> 关联学生: ${studentToLink.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n测试家长账号创建完成!');
|
||||
console.log('登录账号: parent1 / parent2');
|
||||
console.log('密码: 123456');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
92
reading-platform-backend/src/app.module.js
Normal file
92
reading-platform-backend/src/app.module.js
Normal file
@ -0,0 +1,92 @@
|
||||
"use strict";
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppModule = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var config_1 = require("@nestjs/config");
|
||||
var throttler_1 = require("@nestjs/throttler");
|
||||
var prisma_module_1 = require("./database/prisma.module");
|
||||
var auth_module_1 = require("./modules/auth/auth.module");
|
||||
var course_module_1 = require("./modules/course/course.module");
|
||||
var tenant_module_1 = require("./modules/tenant/tenant.module");
|
||||
var common_module_1 = require("./modules/common/common.module");
|
||||
var AppModule = function () {
|
||||
var _classDecorators = [(0, common_1.Module)({
|
||||
imports: [
|
||||
// 配置模块
|
||||
config_1.ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ".env.".concat(process.env.NODE_ENV || 'development'),
|
||||
}),
|
||||
// 限流模块
|
||||
throttler_1.ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 60秒
|
||||
limit: 100, // 最多100个请求
|
||||
},
|
||||
]),
|
||||
// Prisma数据库模块
|
||||
prisma_module_1.PrismaModule,
|
||||
// 业务模块
|
||||
auth_module_1.AuthModule,
|
||||
course_module_1.CourseModule,
|
||||
tenant_module_1.TenantModule,
|
||||
common_module_1.CommonModule,
|
||||
],
|
||||
})];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var AppModule = _classThis = /** @class */ (function () {
|
||||
function AppModule_1() {
|
||||
}
|
||||
return AppModule_1;
|
||||
}());
|
||||
__setFunctionName(_classThis, "AppModule");
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
AppModule = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return AppModule = _classThis;
|
||||
}();
|
||||
exports.AppModule = AppModule;
|
||||
68
reading-platform-backend/src/app.module.ts
Normal file
68
reading-platform-backend/src/app.module.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PrismaModule } from './database/prisma.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CourseModule } from './modules/course/course.module';
|
||||
import { TenantModule } from './modules/tenant/tenant.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { FileUploadModule } from './modules/file-upload/file-upload.module';
|
||||
import { TeacherCourseModule } from './modules/teacher-course/teacher-course.module';
|
||||
import { LessonModule } from './modules/lesson/lesson.module';
|
||||
import { SchoolModule } from './modules/school/school.module';
|
||||
import { ResourceModule } from './modules/resource/resource.module';
|
||||
import { GrowthModule } from './modules/growth/growth.module';
|
||||
import { TaskModule } from './modules/task/task.module';
|
||||
import { ParentModule } from './modules/parent/parent.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { ExportModule } from './modules/export/export.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
// V2 新增模块
|
||||
import { ThemeModule } from './modules/theme/theme.module';
|
||||
import { CoursePackageModule } from './modules/course-package/course-package.module';
|
||||
import { CourseLessonModule } from './modules/course-lesson/course-lesson.module';
|
||||
import { SchoolCourseModule } from './modules/school-course/school-course.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
}),
|
||||
|
||||
// 限流模块
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 60秒
|
||||
limit: 100, // 最多100个请求
|
||||
},
|
||||
]),
|
||||
|
||||
// Prisma数据库模块
|
||||
PrismaModule,
|
||||
|
||||
// 业务模块
|
||||
AuthModule,
|
||||
CourseModule,
|
||||
TenantModule,
|
||||
CommonModule,
|
||||
FileUploadModule,
|
||||
TeacherCourseModule,
|
||||
LessonModule,
|
||||
SchoolModule,
|
||||
ResourceModule,
|
||||
GrowthModule,
|
||||
TaskModule,
|
||||
ParentModule,
|
||||
NotificationModule,
|
||||
ExportModule,
|
||||
AdminModule,
|
||||
// V2 新增模块
|
||||
ThemeModule,
|
||||
CoursePackageModule,
|
||||
CourseLessonModule,
|
||||
SchoolCourseModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@ -0,0 +1,44 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
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()
|
||||
: { message: 'Internal server error', statusCode: 500 };
|
||||
|
||||
const errorLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
body: request.body,
|
||||
status,
|
||||
exception: exception instanceof Error ? exception.message : String(exception),
|
||||
stack: exception instanceof Error ? exception.stack : undefined,
|
||||
};
|
||||
|
||||
// Log to file
|
||||
const logPath = path.join(process.cwd(), 'error.log');
|
||||
fs.appendFileSync(logPath, JSON.stringify(errorLog, null, 2) + '\n');
|
||||
|
||||
// Also log to console
|
||||
this.logger.error('Exception caught', errorLog);
|
||||
|
||||
response.status(status).json(message);
|
||||
}
|
||||
}
|
||||
67
reading-platform-backend/src/database/prisma.module.js
Normal file
67
reading-platform-backend/src/database/prisma.module.js
Normal file
@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PrismaModule = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var prisma_service_1 = require("./prisma.service");
|
||||
var PrismaModule = function () {
|
||||
var _classDecorators = [(0, common_1.Global)(), (0, common_1.Module)({
|
||||
providers: [prisma_service_1.PrismaService],
|
||||
exports: [prisma_service_1.PrismaService],
|
||||
})];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var PrismaModule = _classThis = /** @class */ (function () {
|
||||
function PrismaModule_1() {
|
||||
}
|
||||
return PrismaModule_1;
|
||||
}());
|
||||
__setFunctionName(_classThis, "PrismaModule");
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
PrismaModule = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return PrismaModule = _classThis;
|
||||
}();
|
||||
exports.PrismaModule = PrismaModule;
|
||||
9
reading-platform-backend/src/database/prisma.module.ts
Normal file
9
reading-platform-backend/src/database/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
203
reading-platform-backend/src/database/prisma.service.js
Normal file
203
reading-platform-backend/src/database/prisma.service.js
Normal file
@ -0,0 +1,203 @@
|
||||
"use strict";
|
||||
var __extends = (this && this.__extends) || (function () {
|
||||
var extendStatics = function (d, b) {
|
||||
extendStatics = Object.setPrototypeOf ||
|
||||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
||||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
|
||||
return extendStatics(d, b);
|
||||
};
|
||||
return function (d, b) {
|
||||
if (typeof b !== "function" && b !== null)
|
||||
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
|
||||
extendStatics(d, b);
|
||||
function __() { this.constructor = d; }
|
||||
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
||||
};
|
||||
})();
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PrismaService = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var client_1 = require("@prisma/client");
|
||||
var PrismaService = function () {
|
||||
var _classDecorators = [(0, common_1.Injectable)()];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var _classSuper = client_1.PrismaClient;
|
||||
var PrismaService = _classThis = /** @class */ (function (_super) {
|
||||
__extends(PrismaService_1, _super);
|
||||
function PrismaService_1() {
|
||||
return _super !== null && _super.apply(this, arguments) || this;
|
||||
}
|
||||
PrismaService_1.prototype.onModuleInit = function () {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, this.$connect()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
console.log('✅ Database connected successfully');
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
PrismaService_1.prototype.onModuleDestroy = function () {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, this.$disconnect()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
console.log('👋 Database disconnected');
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
// 清理测试数据的辅助方法
|
||||
PrismaService_1.prototype.cleanDatabase = function () {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Cannot clean database in production');
|
||||
}
|
||||
// 按照外键依赖顺序删除
|
||||
return [4 /*yield*/, this.studentRecord.deleteMany()];
|
||||
case 1:
|
||||
// 按照外键依赖顺序删除
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.lessonFeedback.deleteMany()];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.lesson.deleteMany()];
|
||||
case 3:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.tenantCourse.deleteMany()];
|
||||
case 4:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.courseScriptPage.deleteMany()];
|
||||
case 5:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.courseScript.deleteMany()];
|
||||
case 6:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.courseActivity.deleteMany()];
|
||||
case 7:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.courseResource.deleteMany()];
|
||||
case 8:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.course.deleteMany()];
|
||||
case 9:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.student.deleteMany()];
|
||||
case 10:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.class.deleteMany()];
|
||||
case 11:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.teacher.deleteMany()];
|
||||
case 12:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.tenant.deleteMany()];
|
||||
case 13:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, this.tag.deleteMany()];
|
||||
case 14:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return PrismaService_1;
|
||||
}(_classSuper));
|
||||
__setFunctionName(_classThis, "PrismaService");
|
||||
(function () {
|
||||
var _a;
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create((_a = _classSuper[Symbol.metadata]) !== null && _a !== void 0 ? _a : null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
PrismaService = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return PrismaService = _classThis;
|
||||
}();
|
||||
exports.PrismaService = PrismaService;
|
||||
38
reading-platform-backend/src/database/prisma.service.ts
Normal file
38
reading-platform-backend/src/database/prisma.service.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
console.log('✅ Database connected successfully');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
console.log('👋 Database disconnected');
|
||||
}
|
||||
|
||||
// 清理测试数据的辅助方法
|
||||
async cleanDatabase() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Cannot clean database in production');
|
||||
}
|
||||
|
||||
// 按照外键依赖顺序删除
|
||||
await this.studentRecord.deleteMany();
|
||||
await this.lessonFeedback.deleteMany();
|
||||
await this.lesson.deleteMany();
|
||||
await this.tenantCourse.deleteMany();
|
||||
await this.courseScriptPage.deleteMany();
|
||||
await this.courseScript.deleteMany();
|
||||
await this.courseActivity.deleteMany();
|
||||
await this.courseResource.deleteMany();
|
||||
await this.course.deleteMany();
|
||||
await this.student.deleteMany();
|
||||
await this.class.deleteMany();
|
||||
await this.teacher.deleteMany();
|
||||
await this.tenant.deleteMany();
|
||||
await this.tag.deleteMany();
|
||||
}
|
||||
}
|
||||
84
reading-platform-backend/src/main.js
Normal file
84
reading-platform-backend/src/main.js
Normal file
@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var core_1 = require("@nestjs/core");
|
||||
var common_1 = require("@nestjs/common");
|
||||
var config_1 = require("@nestjs/config");
|
||||
var app_module_1 = require("./app.module");
|
||||
function bootstrap() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var app, configService, port;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, core_1.NestFactory.create(app_module_1.AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
})];
|
||||
case 1:
|
||||
app = _a.sent();
|
||||
configService = app.get(config_1.ConfigService);
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(new common_1.ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}));
|
||||
// 启用压缩
|
||||
// app.use(compression());
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||
allowedHeaders: 'Content-Type, Accept, Authorization',
|
||||
});
|
||||
// API前缀
|
||||
app.setGlobalPrefix('api/v1');
|
||||
port = configService.get('PORT') || 3000;
|
||||
return [4 /*yield*/, app.listen(port)];
|
||||
case 2:
|
||||
_a.sent();
|
||||
console.log("\n \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n \u2551 \u2551\n \u2551 \uD83D\uDE80 \u5E7C\u513F\u9605\u8BFB\u6559\u5B66\u670D\u52A1\u5E73\u53F0\u540E\u7AEF\u542F\u52A8\u6210\u529F \u2551\n \u2551 \u2551\n \u2551 \uD83D\uDCCD Local: http://localhost:".concat(port, " \u2551\n \u2551 \uD83D\uDCCD API: http://localhost:").concat(port, "/api/v1 \u2551\n \u2551 \uD83D\uDCCD Prisma: npx prisma studio \u2551\n \u2551 \u2551\n \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D\n "));
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
bootstrap();
|
||||
74
reading-platform-backend/src/main.ts
Normal file
74
reading-platform-backend/src/main.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import * as compression from 'compression';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
// 增加请求体大小限制(支持上传大文件 base64)
|
||||
// 1GB 文件编码后约 1.33GB,加上其他字段,设置为 1500mb
|
||||
app.useBodyParser('json', { limit: '1500mb' });
|
||||
app.useBodyParser('urlencoded', { limit: '1500mb', extended: true });
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 全局异常过滤器
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
// 启用压缩
|
||||
// app.use(compression());
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||
allowedHeaders: 'Content-Type, Accept, Authorization',
|
||||
});
|
||||
|
||||
// 配置静态文件服务(用于访问上传的文件)
|
||||
// 使用绝对路径确保在编译后也能正确找到 uploads 目录
|
||||
const uploadsPath = join(__dirname, '..', '..', 'uploads');
|
||||
app.useStaticAssets(uploadsPath, {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
|
||||
// API前缀
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = configService.get<number>('PORT') || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`
|
||||
╔═════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🚀 幼儿阅读教学服务平台后端启动成功 ║
|
||||
║ ║
|
||||
║ 📍 Local: http://localhost:${port} ║
|
||||
║ 📍 API: http://localhost:${port}/api/v1 ║
|
||||
║ 📍 Prisma: npx prisma studio ║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
|
||||
import { AdminSettingsService } from './admin-settings.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('admin/settings')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class AdminSettingsController {
|
||||
constructor(private readonly settingsService: AdminSettingsService) {}
|
||||
|
||||
@Get()
|
||||
async getAllSettings() {
|
||||
return this.settingsService.getSettings();
|
||||
}
|
||||
|
||||
@Put()
|
||||
async updateSettings(@Body() data: Record<string, any>) {
|
||||
return this.settingsService.updateSettings(data);
|
||||
}
|
||||
|
||||
@Get('basic')
|
||||
async getBasicSettings() {
|
||||
return this.settingsService.getBasicSettings();
|
||||
}
|
||||
|
||||
@Put('basic')
|
||||
async updateBasicSettings(@Body() data: Record<string, any>) {
|
||||
return this.settingsService.updateSettings(data);
|
||||
}
|
||||
|
||||
@Get('security')
|
||||
async getSecuritySettings() {
|
||||
return this.settingsService.getSecuritySettings();
|
||||
}
|
||||
|
||||
@Put('security')
|
||||
async updateSecuritySettings(@Body() data: Record<string, any>) {
|
||||
return this.settingsService.updateSettings(data);
|
||||
}
|
||||
|
||||
@Get('notification')
|
||||
async getNotificationSettings() {
|
||||
return this.settingsService.getNotificationSettings();
|
||||
}
|
||||
|
||||
@Put('notification')
|
||||
async updateNotificationSettings(@Body() data: Record<string, any>) {
|
||||
return this.settingsService.updateSettings(data);
|
||||
}
|
||||
|
||||
@Get('storage')
|
||||
async getStorageSettings() {
|
||||
return this.settingsService.getStorageSettings();
|
||||
}
|
||||
|
||||
@Put('storage')
|
||||
async updateStorageSettings(@Body() data: Record<string, any>) {
|
||||
return this.settingsService.updateSettings(data);
|
||||
}
|
||||
|
||||
@Get('tenant-defaults')
|
||||
async getTenantDefaults() {
|
||||
return this.settingsService.getTenantDefaults();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
// Admin settings stored in memory (could be moved to database later)
|
||||
@Injectable()
|
||||
export class AdminSettingsService {
|
||||
private settings: Record<string, any> = {
|
||||
// Basic settings
|
||||
systemName: '幼儿阅读教学服务平台',
|
||||
systemDesc: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
systemLogo: '',
|
||||
|
||||
// Security settings
|
||||
passwordStrength: 'medium',
|
||||
maxLoginAttempts: 5,
|
||||
tokenExpire: '7d',
|
||||
forceHttps: false,
|
||||
|
||||
// Notification settings
|
||||
emailEnabled: true,
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
fromEmail: '',
|
||||
smsEnabled: false,
|
||||
|
||||
// Storage settings
|
||||
storageType: 'local',
|
||||
maxFileSize: 100,
|
||||
allowedTypes: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.ppt,.pptx',
|
||||
|
||||
// Tenant defaults
|
||||
defaultTeacherQuota: 20,
|
||||
defaultStudentQuota: 200,
|
||||
enableAutoExpire: true,
|
||||
notifyBeforeDays: 30,
|
||||
};
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getSettings() {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
async getSetting(key: string) {
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
async updateSettings(data: Record<string, any>) {
|
||||
// Update only provided keys
|
||||
for (const key of Object.keys(data)) {
|
||||
if (key in this.settings) {
|
||||
this.settings[key] = data[key];
|
||||
}
|
||||
}
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
async getBasicSettings() {
|
||||
return {
|
||||
systemName: this.settings.systemName,
|
||||
systemDesc: this.settings.systemDesc,
|
||||
contactPhone: this.settings.contactPhone,
|
||||
contactEmail: this.settings.contactEmail,
|
||||
systemLogo: this.settings.systemLogo,
|
||||
};
|
||||
}
|
||||
|
||||
async getSecuritySettings() {
|
||||
return {
|
||||
passwordStrength: this.settings.passwordStrength,
|
||||
maxLoginAttempts: this.settings.maxLoginAttempts,
|
||||
tokenExpire: this.settings.tokenExpire,
|
||||
forceHttps: this.settings.forceHttps,
|
||||
};
|
||||
}
|
||||
|
||||
async getNotificationSettings() {
|
||||
return {
|
||||
emailEnabled: this.settings.emailEnabled,
|
||||
smtpHost: this.settings.smtpHost,
|
||||
smtpPort: this.settings.smtpPort,
|
||||
fromEmail: this.settings.fromEmail,
|
||||
smsEnabled: this.settings.smsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getStorageSettings() {
|
||||
return {
|
||||
type: this.settings.storageType,
|
||||
maxFileSize: this.settings.maxFileSize,
|
||||
allowedTypes: this.settings.allowedTypes,
|
||||
};
|
||||
}
|
||||
|
||||
async getTenantDefaults() {
|
||||
return {
|
||||
defaultTeacherQuota: this.settings.defaultTeacherQuota,
|
||||
defaultStudentQuota: this.settings.defaultStudentQuota,
|
||||
enableAutoExpire: this.settings.enableAutoExpire,
|
||||
notifyBeforeDays: this.settings.notifyBeforeDays,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { AdminStatsService } from './admin-stats.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('admin/stats')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class AdminStatsController {
|
||||
constructor(private readonly statsService: AdminStatsService) {}
|
||||
|
||||
@Get()
|
||||
async getStats() {
|
||||
return this.statsService.getStats();
|
||||
}
|
||||
|
||||
@Get('trend')
|
||||
async getTrendData() {
|
||||
return this.statsService.getTrendData();
|
||||
}
|
||||
|
||||
@Get('tenants/active')
|
||||
async getActiveTenants(@Query('limit') limit?: string) {
|
||||
const limitNum = limit ? parseInt(limit, 10) : 5;
|
||||
return this.statsService.getActiveTenants(limitNum);
|
||||
}
|
||||
|
||||
@Get('courses/popular')
|
||||
async getPopularCourses(@Query('limit') limit?: string) {
|
||||
const limitNum = limit ? parseInt(limit, 10) : 5;
|
||||
return this.statsService.getPopularCourses(limitNum);
|
||||
}
|
||||
|
||||
@Get('activities')
|
||||
async getRecentActivities(@Query('limit') limit?: string) {
|
||||
const limitNum = limit ? parseInt(limit, 10) : 10;
|
||||
return this.statsService.getRecentActivities(limitNum);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminStatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getStats() {
|
||||
const [
|
||||
tenantCount,
|
||||
activeTenantCount,
|
||||
courseCount,
|
||||
publishedCourseCount,
|
||||
studentCount,
|
||||
teacherCount,
|
||||
lessonCount,
|
||||
monthlyLessons,
|
||||
] = await Promise.all([
|
||||
this.prisma.tenant.count(),
|
||||
this.prisma.tenant.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.course.count(),
|
||||
this.prisma.course.count({ where: { status: 'PUBLISHED' } }),
|
||||
this.prisma.student.count(),
|
||||
this.prisma.teacher.count(),
|
||||
this.prisma.lesson.count(),
|
||||
this.getThisMonthLessonCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
tenantCount,
|
||||
activeTenantCount,
|
||||
courseCount,
|
||||
publishedCourseCount,
|
||||
studentCount,
|
||||
teacherCount,
|
||||
lessonCount,
|
||||
monthlyLessons,
|
||||
};
|
||||
}
|
||||
|
||||
private async getThisMonthLessonCount() {
|
||||
const now = new Date();
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
return this.prisma.lesson.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: firstDayOfMonth,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTrendData() {
|
||||
// Get last 6 months data
|
||||
const months: Array<{ month: string; tenantCount: number; lessonCount: number; studentCount: number }> = [];
|
||||
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - i);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
|
||||
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
const lastDay = new Date(year, month, 0, 23, 59, 59);
|
||||
|
||||
const [tenantCount, lessonCount, studentCount] = await Promise.all([
|
||||
this.prisma.tenant.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
lte: lastDay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.lesson.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: firstDay,
|
||||
lte: lastDay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.student.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
lte: lastDay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
months.push({
|
||||
month: monthStr,
|
||||
tenantCount,
|
||||
lessonCount,
|
||||
studentCount,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
async getActiveTenants(limit: number = 5) {
|
||||
// Get tenants with most lessons in the last 30 days
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const tenants = await this.prisma.tenant.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teacherCount: true,
|
||||
studentCount: true,
|
||||
_count: {
|
||||
select: {
|
||||
lessons: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lessons: {
|
||||
_count: 'desc',
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return tenants.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
lessonCount: t._count.lessons,
|
||||
teacherCount: t.teacherCount,
|
||||
studentCount: t.studentCount,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPopularCourses(limit: number = 5) {
|
||||
// Get courses with most usage
|
||||
const courses = await this.prisma.course.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
usageCount: true,
|
||||
teacherCount: true,
|
||||
},
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
orderBy: {
|
||||
usageCount: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return courses;
|
||||
}
|
||||
|
||||
async getRecentActivities(limit: number = 10) {
|
||||
const activities: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
time: Date;
|
||||
}> = [];
|
||||
|
||||
// Get recent lessons
|
||||
const recentLessons = await this.prisma.lesson.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
tenant: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
course: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const lesson of recentLessons) {
|
||||
activities.push({
|
||||
id: lesson.id,
|
||||
type: 'lesson',
|
||||
title: `${lesson.tenant.name} 完成了课程《${lesson.course.name}》`,
|
||||
time: lesson.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Get recent tenants
|
||||
const recentTenants = await this.prisma.tenant.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const tenant of recentTenants) {
|
||||
activities.push({
|
||||
id: tenant.id + 10000,
|
||||
type: 'tenant',
|
||||
title: `新租户注册: ${tenant.name}`,
|
||||
time: tenant.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by time and limit
|
||||
return activities
|
||||
.sort((a, b) => b.time.getTime() - a.time.getTime())
|
||||
.slice(0, limit)
|
||||
.map((a) => ({
|
||||
...a,
|
||||
time: a.time.toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
14
reading-platform-backend/src/modules/admin/admin.module.ts
Normal file
14
reading-platform-backend/src/modules/admin/admin.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminSettingsController } from './admin-settings.controller';
|
||||
import { AdminSettingsService } from './admin-settings.service';
|
||||
import { AdminStatsController } from './admin-stats.controller';
|
||||
import { AdminStatsService } from './admin-stats.service';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminSettingsController, AdminStatsController],
|
||||
providers: [AdminSettingsService, AdminStatsService],
|
||||
exports: [AdminSettingsService, AdminStatsService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
133
reading-platform-backend/src/modules/auth/auth.controller.js
Normal file
133
reading-platform-backend/src/modules/auth/auth.controller.js
Normal file
@ -0,0 +1,133 @@
|
||||
"use strict";
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthController = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var jwt_auth_guard_1 = require("../common/guards/jwt-auth.guard");
|
||||
var AuthController = function () {
|
||||
var _classDecorators = [(0, common_1.Controller)('auth')];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var _instanceExtraInitializers = [];
|
||||
var _login_decorators;
|
||||
var _logout_decorators;
|
||||
var _getProfile_decorators;
|
||||
var AuthController = _classThis = /** @class */ (function () {
|
||||
function AuthController_1(authService) {
|
||||
this.authService = (__runInitializers(this, _instanceExtraInitializers), authService);
|
||||
}
|
||||
AuthController_1.prototype.login = function (loginDto) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
return [2 /*return*/, this.authService.login(loginDto)];
|
||||
});
|
||||
});
|
||||
};
|
||||
AuthController_1.prototype.logout = function () {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
// JWT是无状态的,logout主要在前端删除token
|
||||
return [2 /*return*/, { message: 'Logged out successfully' }];
|
||||
});
|
||||
});
|
||||
};
|
||||
AuthController_1.prototype.getProfile = function (req) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
return [2 /*return*/, this.authService.getProfile(req.user.userId, req.user.role)];
|
||||
});
|
||||
});
|
||||
};
|
||||
return AuthController_1;
|
||||
}());
|
||||
__setFunctionName(_classThis, "AuthController");
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
_login_decorators = [(0, common_1.Post)('login')];
|
||||
_logout_decorators = [(0, common_1.Post)('logout'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard)];
|
||||
_getProfile_decorators = [(0, common_1.Get)('profile'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard)];
|
||||
__esDecorate(_classThis, null, _login_decorators, { kind: "method", name: "login", static: false, private: false, access: { has: function (obj) { return "login" in obj; }, get: function (obj) { return obj.login; } }, metadata: _metadata }, null, _instanceExtraInitializers);
|
||||
__esDecorate(_classThis, null, _logout_decorators, { kind: "method", name: "logout", static: false, private: false, access: { has: function (obj) { return "logout" in obj; }, get: function (obj) { return obj.logout; } }, metadata: _metadata }, null, _instanceExtraInitializers);
|
||||
__esDecorate(_classThis, null, _getProfile_decorators, { kind: "method", name: "getProfile", static: false, private: false, access: { has: function (obj) { return "getProfile" in obj; }, get: function (obj) { return obj.getProfile; } }, metadata: _metadata }, null, _instanceExtraInitializers);
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
AuthController = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return AuthController = _classThis;
|
||||
}();
|
||||
exports.AuthController = AuthController;
|
||||
27
reading-platform-backend/src/modules/auth/auth.controller.ts
Normal file
27
reading-platform-backend/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout() {
|
||||
// JWT是无状态的,logout主要在前端删除token
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req) {
|
||||
return this.authService.getProfile(req.user.userId, req.user.role);
|
||||
}
|
||||
}
|
||||
128
reading-platform-backend/src/modules/auth/auth.module.js
Normal file
128
reading-platform-backend/src/modules/auth/auth.module.js
Normal file
@ -0,0 +1,128 @@
|
||||
"use strict";
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthModule = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var jwt_1 = require("@nestjs/jwt");
|
||||
var passport_1 = require("@nestjs/passport");
|
||||
var config_1 = require("@nestjs/config");
|
||||
var auth_service_1 = require("./auth.service");
|
||||
var auth_controller_1 = require("./auth.controller");
|
||||
var jwt_strategy_1 = require("./strategies/jwt.strategy");
|
||||
var prisma_module_1 = require("../../database/prisma.module");
|
||||
var AuthModule = function () {
|
||||
var _classDecorators = [(0, common_1.Module)({
|
||||
imports: [
|
||||
passport_1.PassportModule,
|
||||
jwt_1.JwtModule.registerAsync({
|
||||
imports: [config_1.ConfigModule],
|
||||
useFactory: function (configService) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
return [2 /*return*/, ({
|
||||
secret: configService.get('JWT_SECRET') || 'your-secret-key',
|
||||
signOptions: {
|
||||
expiresIn: configService.get('JWT_EXPIRES_IN') || '7d',
|
||||
},
|
||||
})];
|
||||
});
|
||||
}); },
|
||||
inject: [config_1.ConfigService],
|
||||
}),
|
||||
prisma_module_1.PrismaModule,
|
||||
],
|
||||
controllers: [auth_controller_1.AuthController],
|
||||
providers: [auth_service_1.AuthService, jwt_strategy_1.JwtStrategy],
|
||||
exports: [auth_service_1.AuthService],
|
||||
})];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var AuthModule = _classThis = /** @class */ (function () {
|
||||
function AuthModule_1() {
|
||||
}
|
||||
return AuthModule_1;
|
||||
}());
|
||||
__setFunctionName(_classThis, "AuthModule");
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
AuthModule = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return AuthModule = _classThis;
|
||||
}();
|
||||
exports.AuthModule = AuthModule;
|
||||
29
reading-platform-backend/src/modules/auth/auth.module.ts
Normal file
29
reading-platform-backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '7d',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
PrismaModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
288
reading-platform-backend/src/modules/auth/auth.service.js
Normal file
288
reading-platform-backend/src/modules/auth/auth.service.js
Normal file
@ -0,0 +1,288 @@
|
||||
"use strict";
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthService = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var bcrypt = require("bcrypt");
|
||||
var AuthService = function () {
|
||||
var _classDecorators = [(0, common_1.Injectable)()];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var AuthService = _classThis = /** @class */ (function () {
|
||||
function AuthService_1(prisma, jwtService) {
|
||||
this.prisma = prisma;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
AuthService_1.prototype.validateUser = function (account, password) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var user, isPasswordValid;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, this.prisma.teacher.findUnique({
|
||||
where: { loginAccount: account },
|
||||
})];
|
||||
case 1:
|
||||
user = _a.sent();
|
||||
if (!user) {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
return [4 /*yield*/, bcrypt.compare(password, user.passwordHash)];
|
||||
case 2:
|
||||
isPasswordValid = _a.sent();
|
||||
if (!isPasswordValid) {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
if (user.status !== 'ACTIVE') {
|
||||
throw new common_1.UnauthorizedException('账号已被停用');
|
||||
}
|
||||
return [2 /*return*/, user];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
AuthService_1.prototype.login = function (dto) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var user, tenant, teacher, isPasswordValid, payload, token;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
if (!(dto.role === 'admin')) return [3 /*break*/, 1];
|
||||
// 超管账号(硬编码或从配置读取)
|
||||
if (dto.account === 'admin' && dto.password === '123456') {
|
||||
user = {
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
role: 'admin',
|
||||
};
|
||||
}
|
||||
else {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
return [3 /*break*/, 7];
|
||||
case 1:
|
||||
if (!(dto.role === 'school')) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, this.prisma.tenant.findFirst({
|
||||
where: { name: dto.account },
|
||||
})];
|
||||
case 2:
|
||||
tenant = _a.sent();
|
||||
if (!tenant) {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
// 验证密码(这里简化处理)
|
||||
if (dto.password !== '123456') {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
user = {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
role: 'school',
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
};
|
||||
return [3 /*break*/, 7];
|
||||
case 3:
|
||||
if (!(dto.role === 'teacher')) return [3 /*break*/, 6];
|
||||
return [4 /*yield*/, this.prisma.teacher.findUnique({
|
||||
where: { loginAccount: dto.account },
|
||||
})];
|
||||
case 4:
|
||||
teacher = _a.sent();
|
||||
if (!teacher) {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
return [4 /*yield*/, bcrypt.compare(dto.password, teacher.passwordHash)];
|
||||
case 5:
|
||||
isPasswordValid = _a.sent();
|
||||
if (!isPasswordValid) {
|
||||
throw new common_1.UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
if (teacher.status !== 'ACTIVE') {
|
||||
throw new common_1.UnauthorizedException('账号已被停用');
|
||||
}
|
||||
user = {
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
role: 'teacher',
|
||||
tenantId: teacher.tenantId,
|
||||
};
|
||||
return [3 /*break*/, 7];
|
||||
case 6: throw new common_1.UnauthorizedException('无效的角色');
|
||||
case 7:
|
||||
payload = {
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
token = this.jwtService.sign(payload);
|
||||
if (!(dto.role === 'teacher')) return [3 /*break*/, 9];
|
||||
return [4 /*yield*/, this.prisma.teacher.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})];
|
||||
case 8:
|
||||
_a.sent();
|
||||
_a.label = 9;
|
||||
case 9: return [2 /*return*/, {
|
||||
token: token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenantName,
|
||||
},
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
AuthService_1.prototype.getProfile = function (userId, role) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var tenant, teacher;
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
if (!(role === 'admin')) return [3 /*break*/, 1];
|
||||
return [2 /*return*/, {
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
role: 'admin',
|
||||
}];
|
||||
case 1:
|
||||
if (!(role === 'school')) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, this.prisma.tenant.findUnique({
|
||||
where: { id: userId },
|
||||
})];
|
||||
case 2:
|
||||
tenant = _b.sent();
|
||||
if (!tenant) {
|
||||
throw new common_1.UnauthorizedException('用户不存在');
|
||||
}
|
||||
return [2 /*return*/, {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
role: 'school',
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
}];
|
||||
case 3:
|
||||
if (!(role === 'teacher')) return [3 /*break*/, 5];
|
||||
return [4 /*yield*/, this.prisma.teacher.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})];
|
||||
case 4:
|
||||
teacher = _b.sent();
|
||||
if (!teacher) {
|
||||
throw new common_1.UnauthorizedException('用户不存在');
|
||||
}
|
||||
return [2 /*return*/, {
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
role: 'teacher',
|
||||
tenantId: teacher.tenantId,
|
||||
tenantName: (_a = teacher.tenant) === null || _a === void 0 ? void 0 : _a.name,
|
||||
email: teacher.email,
|
||||
phone: teacher.phone,
|
||||
}];
|
||||
case 5: throw new common_1.UnauthorizedException('无效的角色');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return AuthService_1;
|
||||
}());
|
||||
__setFunctionName(_classThis, "AuthService");
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
AuthService = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return AuthService = _classThis;
|
||||
}();
|
||||
exports.AuthService = AuthService;
|
||||
298
reading-platform-backend/src/modules/auth/auth.service.ts
Normal file
298
reading-platform-backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
export interface LoginDto {
|
||||
account: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
role: string;
|
||||
tenantId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(account: string, password: string) {
|
||||
// 根据账号查找用户(这里简化处理,实际应根据role查找不同表)
|
||||
const user = await this.prisma.teacher.findUnique({
|
||||
where: { loginAccount: account },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
if (user.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('账号已被停用');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
// 根据角色查找不同的用户表
|
||||
let user: any;
|
||||
|
||||
if (dto.role === 'admin') {
|
||||
// 超管账号(硬编码或从配置读取)
|
||||
if (dto.account === 'admin' && dto.password === 'admin123') {
|
||||
user = {
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
role: 'admin',
|
||||
};
|
||||
} else {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
} else if (dto.role === 'school') {
|
||||
// 学校管理员(从租户表查找)
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { loginAccount: dto.account },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!tenant.passwordHash) {
|
||||
throw new UnauthorizedException('账号未设置密码');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(dto.password, tenant.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
if (tenant.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('账号已被停用');
|
||||
}
|
||||
|
||||
user = {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
role: 'school',
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
};
|
||||
} else if (dto.role === 'teacher') {
|
||||
// 教师
|
||||
const teacher = await this.prisma.teacher.findUnique({
|
||||
where: { loginAccount: dto.account },
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teacher) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(dto.password, teacher.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
if (teacher.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('账号已被停用');
|
||||
}
|
||||
|
||||
user = {
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
role: 'teacher',
|
||||
tenantId: teacher.tenantId,
|
||||
tenantName: teacher.tenant?.name,
|
||||
};
|
||||
} else if (dto.role === 'parent') {
|
||||
// 家长
|
||||
const parent = await this.prisma.parent.findUnique({
|
||||
where: { loginAccount: dto.account },
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(dto.password, parent.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
|
||||
if (parent.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('账号已被停用');
|
||||
}
|
||||
|
||||
user = {
|
||||
id: parent.id,
|
||||
name: parent.name,
|
||||
role: 'parent',
|
||||
tenantId: parent.tenantId,
|
||||
tenantName: parent.tenant?.name,
|
||||
};
|
||||
|
||||
// 更新最后登录时间
|
||||
await this.prisma.parent.update({
|
||||
where: { id: parent.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
throw new UnauthorizedException('无效的角色');
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
|
||||
const token = this.jwtService.sign(payload);
|
||||
|
||||
// 更新最后登录时间
|
||||
if (dto.role === 'teacher') {
|
||||
await this.prisma.teacher.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenantName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(userId: number, role: string) {
|
||||
if (role === 'admin') {
|
||||
return {
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
role: 'admin',
|
||||
};
|
||||
} else if (role === 'school') {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
role: 'school',
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
};
|
||||
} else if (role === 'teacher') {
|
||||
const teacher = await this.prisma.teacher.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teacher) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
role: 'teacher',
|
||||
tenantId: teacher.tenantId,
|
||||
tenantName: teacher.tenant?.name,
|
||||
email: teacher.email,
|
||||
phone: teacher.phone,
|
||||
};
|
||||
} else if (role === 'parent') {
|
||||
const parent = await this.prisma.parent.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
id: parent.id,
|
||||
name: parent.name,
|
||||
role: 'parent',
|
||||
tenantId: parent.tenantId,
|
||||
tenantName: parent.tenant?.name,
|
||||
email: parent.email,
|
||||
phone: parent.phone,
|
||||
children: parent.children.map((c) => ({
|
||||
id: c.student.id,
|
||||
name: c.student.name,
|
||||
relationship: c.relationship,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('无效的角色');
|
||||
}
|
||||
}
|
||||
71
reading-platform-backend/src/modules/auth/dto/login.dto.js
Normal file
71
reading-platform-backend/src/modules/auth/dto/login.dto.js
Normal file
@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LoginDto = void 0;
|
||||
var class_validator_1 = require("class-validator");
|
||||
var LoginDto = function () {
|
||||
var _a;
|
||||
var _account_decorators;
|
||||
var _account_initializers = [];
|
||||
var _account_extraInitializers = [];
|
||||
var _password_decorators;
|
||||
var _password_initializers = [];
|
||||
var _password_extraInitializers = [];
|
||||
var _role_decorators;
|
||||
var _role_initializers = [];
|
||||
var _role_extraInitializers = [];
|
||||
return _a = /** @class */ (function () {
|
||||
function LoginDto() {
|
||||
this.account = __runInitializers(this, _account_initializers, void 0);
|
||||
this.password = (__runInitializers(this, _account_extraInitializers), __runInitializers(this, _password_initializers, void 0));
|
||||
this.role = (__runInitializers(this, _password_extraInitializers), __runInitializers(this, _role_initializers, void 0));
|
||||
__runInitializers(this, _role_extraInitializers);
|
||||
}
|
||||
return LoginDto;
|
||||
}()),
|
||||
(function () {
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
||||
_account_decorators = [(0, class_validator_1.IsString)(), (0, class_validator_1.IsNotEmpty)()];
|
||||
_password_decorators = [(0, class_validator_1.IsString)(), (0, class_validator_1.IsNotEmpty)()];
|
||||
_role_decorators = [(0, class_validator_1.IsString)(), (0, class_validator_1.IsIn)(['admin', 'school', 'teacher']), (0, class_validator_1.IsNotEmpty)()];
|
||||
__esDecorate(null, null, _account_decorators, { kind: "field", name: "account", static: false, private: false, access: { has: function (obj) { return "account" in obj; }, get: function (obj) { return obj.account; }, set: function (obj, value) { obj.account = value; } }, metadata: _metadata }, _account_initializers, _account_extraInitializers);
|
||||
__esDecorate(null, null, _password_decorators, { kind: "field", name: "password", static: false, private: false, access: { has: function (obj) { return "password" in obj; }, get: function (obj) { return obj.password; }, set: function (obj, value) { obj.password = value; } }, metadata: _metadata }, _password_initializers, _password_extraInitializers);
|
||||
__esDecorate(null, null, _role_decorators, { kind: "field", name: "role", static: false, private: false, access: { has: function (obj) { return "role" in obj; }, get: function (obj) { return obj.role; }, set: function (obj, value) { obj.role = value; } }, metadata: _metadata }, _role_initializers, _role_extraInitializers);
|
||||
if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
})(),
|
||||
_a;
|
||||
}();
|
||||
exports.LoginDto = LoginDto;
|
||||
16
reading-platform-backend/src/modules/auth/dto/login.dto.ts
Normal file
16
reading-platform-backend/src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { IsString, IsIn, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
account: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['admin', 'school', 'teacher', 'parent'])
|
||||
@IsNotEmpty()
|
||||
role: string;
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
"use strict";
|
||||
var __extends = (this && this.__extends) || (function () {
|
||||
var extendStatics = function (d, b) {
|
||||
extendStatics = Object.setPrototypeOf ||
|
||||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
||||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
|
||||
return extendStatics(d, b);
|
||||
};
|
||||
return function (d, b) {
|
||||
if (typeof b !== "function" && b !== null)
|
||||
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
|
||||
extendStatics(d, b);
|
||||
function __() { this.constructor = d; }
|
||||
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
||||
};
|
||||
})();
|
||||
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
||||
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
||||
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
||||
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
||||
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
||||
var _, done = false;
|
||||
for (var i = decorators.length - 1; i >= 0; i--) {
|
||||
var context = {};
|
||||
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
||||
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
||||
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
||||
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
||||
if (kind === "accessor") {
|
||||
if (result === void 0) continue;
|
||||
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
||||
if (_ = accept(result.get)) descriptor.get = _;
|
||||
if (_ = accept(result.set)) descriptor.set = _;
|
||||
if (_ = accept(result.init)) initializers.unshift(_);
|
||||
}
|
||||
else if (_ = accept(result)) {
|
||||
if (kind === "field") initializers.unshift(_);
|
||||
else descriptor[key] = _;
|
||||
}
|
||||
}
|
||||
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
||||
done = true;
|
||||
};
|
||||
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
||||
var useValue = arguments.length > 2;
|
||||
for (var i = 0; i < initializers.length; i++) {
|
||||
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
||||
}
|
||||
return useValue ? value : void 0;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __setFunctionName = (this && this.__setFunctionName) || function (f, name, prefix) {
|
||||
if (typeof name === "symbol") name = name.description ? "[".concat(name.description, "]") : "";
|
||||
return Object.defineProperty(f, "name", { configurable: true, value: prefix ? "".concat(prefix, " ", name) : name });
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.JwtStrategy = void 0;
|
||||
var common_1 = require("@nestjs/common");
|
||||
var passport_1 = require("@nestjs/passport");
|
||||
var passport_jwt_1 = require("passport-jwt");
|
||||
var JwtStrategy = function () {
|
||||
var _classDecorators = [(0, common_1.Injectable)()];
|
||||
var _classDescriptor;
|
||||
var _classExtraInitializers = [];
|
||||
var _classThis;
|
||||
var _classSuper = (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy);
|
||||
var JwtStrategy = _classThis = /** @class */ (function (_super) {
|
||||
__extends(JwtStrategy_1, _super);
|
||||
function JwtStrategy_1(configService) {
|
||||
var _this = _super.call(this, {
|
||||
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET') || 'your-secret-key',
|
||||
}) || this;
|
||||
_this.configService = configService;
|
||||
return _this;
|
||||
}
|
||||
JwtStrategy_1.prototype.validate = function (payload) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
if (!payload.sub || !payload.role) {
|
||||
throw new common_1.UnauthorizedException();
|
||||
}
|
||||
return [2 /*return*/, {
|
||||
userId: payload.sub,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
}];
|
||||
});
|
||||
});
|
||||
};
|
||||
return JwtStrategy_1;
|
||||
}(_classSuper));
|
||||
__setFunctionName(_classThis, "JwtStrategy");
|
||||
(function () {
|
||||
var _a;
|
||||
var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create((_a = _classSuper[Symbol.metadata]) !== null && _a !== void 0 ? _a : null) : void 0;
|
||||
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
||||
JwtStrategy = _classThis = _classDescriptor.value;
|
||||
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
||||
__runInitializers(_classThis, _classExtraInitializers);
|
||||
})();
|
||||
return JwtStrategy = _classThis;
|
||||
}();
|
||||
exports.JwtStrategy = JwtStrategy;
|
||||
@ -0,0 +1,33 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
role: string;
|
||||
tenantId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
if (!payload.sub || !payload.role) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
reading-platform-backend/src/modules/common/common.module.ts
Normal file
13
reading-platform-backend/src/modules/common/common.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import { LogInterceptor } from './interceptors/log.interceptor';
|
||||
import { OperationLogService } from './operation-log.service';
|
||||
import { SchoolOperationLogController, AdminOperationLogController } from './operation-log.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SchoolOperationLogController, AdminOperationLogController],
|
||||
providers: [JwtAuthGuard, RolesGuard, LogInterceptor, OperationLogService],
|
||||
exports: [JwtAuthGuard, RolesGuard, LogInterceptor, OperationLogService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@ -0,0 +1,17 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const LOG_OPERATION_KEY = 'log_operation';
|
||||
|
||||
export interface LogOperationOptions {
|
||||
action: string; // 操作类型: CREATE, UPDATE, DELETE, LOGIN, etc.
|
||||
module: string; // 模块名称: 教师管理, 学生管理, etc.
|
||||
description: string; // 操作描述
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作日志装饰器
|
||||
* 用于标记需要记录操作日志的方法
|
||||
*/
|
||||
export const LogOperation = (options: LogOperationOptions) => {
|
||||
return SetMetadata(LOG_OPERATION_KEY, options);
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@ -0,0 +1,24 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// 可以通过装饰器设置是否需要认证
|
||||
const requireAuth = this.reflector.getAllAndOverride<boolean>('requireAuth', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (requireAuth === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// 如果没有设置角色要求,则允许访问
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
// 检查用户角色是否在允许的角色列表中
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { LOG_OPERATION_KEY, LogOperationOptions } from '../decorators/log-operation.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class LogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(LogInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const logOptions = this.reflector.getAllAndOverride<LogOperationOptions>(
|
||||
LOG_OPERATION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!logOptions) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
const startTime = Date.now();
|
||||
|
||||
// 获取请求体(用于记录变更前后数据)
|
||||
const body = request.body;
|
||||
const params = request.params;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: (response) => {
|
||||
this.saveLog({
|
||||
user,
|
||||
logOptions,
|
||||
body,
|
||||
params,
|
||||
response,
|
||||
ipAddress: this.getIpAddress(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
// 错误情况下也记录日志
|
||||
this.saveLog({
|
||||
user,
|
||||
logOptions,
|
||||
body,
|
||||
params,
|
||||
response: { error: error.message },
|
||||
ipAddress: this.getIpAddress(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
duration: Date.now() - startTime,
|
||||
isError: true,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async saveLog(data: {
|
||||
user: any;
|
||||
logOptions: LogOperationOptions;
|
||||
body: any;
|
||||
params: any;
|
||||
response: any;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
duration: number;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const { user, logOptions, body, params, response, ipAddress, userAgent } = data;
|
||||
|
||||
// 获取目标ID
|
||||
let targetId: number | undefined;
|
||||
if (params?.id) {
|
||||
targetId = parseInt(params.id, 10);
|
||||
} else if (response?.id) {
|
||||
targetId = response.id;
|
||||
} else if (body?.id) {
|
||||
targetId = body.id;
|
||||
}
|
||||
|
||||
// 构建描述
|
||||
let description = logOptions.description;
|
||||
if (data.isError) {
|
||||
description = `[失败] ${description}`;
|
||||
}
|
||||
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: user?.tenantId || null,
|
||||
userId: user?.userId || 0,
|
||||
userType: user?.role || 'UNKNOWN',
|
||||
action: logOptions.action,
|
||||
module: logOptions.module,
|
||||
description,
|
||||
targetId,
|
||||
oldValue: body ? JSON.stringify(body) : null,
|
||||
newValue: response ? JSON.stringify(response) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Operation logged: ${logOptions.module} - ${logOptions.action} (${data.duration}ms)`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save operation log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getIpAddress(request: any): string {
|
||||
return (
|
||||
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
request.headers['x-real-ip'] ||
|
||||
request.connection?.remoteAddress ||
|
||||
request.socket?.remoteAddress ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { OperationLogService } from './operation-log.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import { Roles } from './decorators/roles.decorator';
|
||||
|
||||
@Controller('school')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SchoolOperationLogController {
|
||||
constructor(private readonly logService: OperationLogService) {}
|
||||
|
||||
@Get('operation-logs')
|
||||
getLogs(@Request() req: any, @Query() query: any) {
|
||||
return this.logService.getLogs(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('operation-logs/stats')
|
||||
getStats(@Request() req: any, @Query() query: any) {
|
||||
return this.logService.getModuleStats(req.user.tenantId, query.startDate, query.endDate);
|
||||
}
|
||||
|
||||
@Get('operation-logs/:id')
|
||||
getLogById(@Request() req: any, @Param('id') id: string) {
|
||||
return this.logService.getLogById(req.user.tenantId, +id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class AdminOperationLogController {
|
||||
constructor(private readonly logService: OperationLogService) {}
|
||||
|
||||
@Get('operation-logs')
|
||||
getLogs(@Query() query: any) {
|
||||
return this.logService.getLogs(null, query);
|
||||
}
|
||||
|
||||
@Get('operation-logs/stats')
|
||||
getStats(@Query() query: any) {
|
||||
return this.logService.getModuleStats(null, query.startDate, query.endDate);
|
||||
}
|
||||
|
||||
@Get('operation-logs/:id')
|
||||
getLogById(@Param('id') id: string) {
|
||||
return this.logService.getLogById(null, +id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OperationLogService {
|
||||
private readonly logger = new Logger(OperationLogService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 获取操作日志列表
|
||||
*/
|
||||
async getLogs(tenantId: number | null, query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: number;
|
||||
userType?: string;
|
||||
action?: string;
|
||||
module?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
userId,
|
||||
userType,
|
||||
action,
|
||||
module,
|
||||
startDate,
|
||||
endDate,
|
||||
} = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (tenantId !== null) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
|
||||
if (userType) {
|
||||
where.userType = userType;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
where.action = action;
|
||||
}
|
||||
|
||||
if (module) {
|
||||
where.module = module;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt.gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.createdAt.lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志详情
|
||||
*/
|
||||
async getLogById(tenantId: number | null, id: number) {
|
||||
const where: any = { id };
|
||||
|
||||
if (tenantId !== null) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const log = await this.prisma.operationLog.findFirst({
|
||||
where,
|
||||
});
|
||||
|
||||
if (!log) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析 JSON 字段
|
||||
return {
|
||||
...log,
|
||||
oldValue: log.oldValue ? this.safeParseJson(log.oldValue) : null,
|
||||
newValue: log.newValue ? this.safeParseJson(log.newValue) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块统计
|
||||
*/
|
||||
async getModuleStats(tenantId: number | null, startDate?: string, endDate?: string) {
|
||||
const where: any = {};
|
||||
|
||||
if (tenantId !== null) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt.gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.createdAt.lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await this.prisma.operationLog.findMany({
|
||||
where,
|
||||
select: {
|
||||
module: true,
|
||||
action: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 统计每个模块的操作数
|
||||
const moduleStats = new Map<string, number>();
|
||||
const actionStats = new Map<string, number>();
|
||||
|
||||
logs.forEach((log) => {
|
||||
moduleStats.set(log.module, (moduleStats.get(log.module) || 0) + 1);
|
||||
actionStats.set(log.action, (actionStats.get(log.action) || 0) + 1);
|
||||
});
|
||||
|
||||
return {
|
||||
modules: Array.from(moduleStats.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
actions: Array.from(actionStats.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
total: logs.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析 JSON
|
||||
*/
|
||||
private safeParseJson(str: string): any {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { CourseLessonService } from './course-lesson.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
// 超管端课程控制器
|
||||
@Controller('admin/courses/:courseId/lessons')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class CourseLessonController {
|
||||
constructor(private readonly lessonService: CourseLessonService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Param('courseId', ParseIntPipe) courseId: number) {
|
||||
return this.lessonService.findAll(courseId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.lessonService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('type/:lessonType')
|
||||
async findByType(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Param('lessonType') lessonType: string,
|
||||
) {
|
||||
return this.lessonService.findByType(courseId, lessonType);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Body()
|
||||
body: {
|
||||
lessonType: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
videoPath?: string;
|
||||
videoName?: string;
|
||||
pptPath?: string;
|
||||
pptName?: string;
|
||||
pdfPath?: string;
|
||||
pdfName?: string;
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
assessmentData?: string;
|
||||
useTemplate?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.lessonService.create(courseId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
videoPath?: string;
|
||||
videoName?: string;
|
||||
pptPath?: string;
|
||||
pptName?: string;
|
||||
pdfPath?: string;
|
||||
pdfName?: string;
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
assessmentData?: string;
|
||||
useTemplate?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.lessonService.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.lessonService.delete(id);
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
async reorder(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Body() body: { lessonIds: number[] },
|
||||
) {
|
||||
return this.lessonService.reorder(courseId, body.lessonIds);
|
||||
}
|
||||
|
||||
// ==================== 教学环节管理 ====================
|
||||
|
||||
@Get(':lessonId/steps')
|
||||
async findSteps(@Param('lessonId', ParseIntPipe) lessonId: number) {
|
||||
return this.lessonService.findSteps(lessonId);
|
||||
}
|
||||
|
||||
@Post(':lessonId/steps')
|
||||
async createStep(
|
||||
@Param('lessonId', ParseIntPipe) lessonId: number,
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
content?: string;
|
||||
duration?: number;
|
||||
objective?: string;
|
||||
resourceIds?: number[];
|
||||
},
|
||||
) {
|
||||
return this.lessonService.createStep(lessonId, body);
|
||||
}
|
||||
|
||||
@Put('steps/:stepId')
|
||||
async updateStep(
|
||||
@Param('stepId', ParseIntPipe) stepId: number,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
duration?: number;
|
||||
objective?: string;
|
||||
resourceIds?: number[];
|
||||
},
|
||||
) {
|
||||
return this.lessonService.updateStep(stepId, body);
|
||||
}
|
||||
|
||||
@Delete('steps/:stepId')
|
||||
async removeStep(@Param('stepId', ParseIntPipe) stepId: number) {
|
||||
return this.lessonService.deleteStep(stepId);
|
||||
}
|
||||
|
||||
@Put(':lessonId/steps/reorder')
|
||||
async reorderSteps(
|
||||
@Param('lessonId', ParseIntPipe) lessonId: number,
|
||||
@Body() body: { stepIds: number[] },
|
||||
) {
|
||||
return this.lessonService.reorderSteps(lessonId, body.stepIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 教师端课程控制器
|
||||
@Controller('teacher/courses/:courseId/lessons')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class TeacherCourseLessonController {
|
||||
constructor(private readonly lessonService: CourseLessonService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.lessonService.findCourseLessonsForTeacher(courseId, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
// 先验证权限
|
||||
await this.lessonService.findCourseLessonsForTeacher(courseId, req.user.tenantId);
|
||||
return this.lessonService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('type/:lessonType')
|
||||
async findByType(
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
@Param('lessonType') lessonType: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
// 先验证权限
|
||||
await this.lessonService.findCourseLessonsForTeacher(courseId, req.user.tenantId);
|
||||
return this.lessonService.findByType(courseId, lessonType);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CourseLessonController, TeacherCourseLessonController } from './course-lesson.controller';
|
||||
import { CourseLessonService } from './course-lesson.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CourseLessonController, TeacherCourseLessonController],
|
||||
providers: [CourseLessonService],
|
||||
exports: [CourseLessonService],
|
||||
})
|
||||
export class CourseLessonModule {}
|
||||
@ -0,0 +1,311 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CourseLessonService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 课程管理 ====================
|
||||
|
||||
async findAll(courseId: number) {
|
||||
return this.prisma.courseLesson.findMany({
|
||||
where: { courseId },
|
||||
include: {
|
||||
steps: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
_count: {
|
||||
select: { steps: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
return this.prisma.courseLesson.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
steps: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByType(courseId: number, lessonType: string) {
|
||||
return this.prisma.courseLesson.findUnique({
|
||||
where: {
|
||||
courseId_lessonType: { courseId, lessonType },
|
||||
},
|
||||
include: {
|
||||
steps: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
courseId: number,
|
||||
data: {
|
||||
lessonType: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
videoPath?: string;
|
||||
videoName?: string;
|
||||
pptPath?: string;
|
||||
pptName?: string;
|
||||
pdfPath?: string;
|
||||
pdfName?: string;
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
assessmentData?: string;
|
||||
useTemplate?: boolean;
|
||||
},
|
||||
) {
|
||||
// 检查是否已存在相同类型的课程
|
||||
const existing = await this.prisma.courseLesson.findUnique({
|
||||
where: {
|
||||
courseId_lessonType: { courseId, lessonType: data.lessonType },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`该课程包已存在 ${data.lessonType} 类型的课程`);
|
||||
}
|
||||
|
||||
// 获取最大排序号
|
||||
const maxSortOrder = await this.prisma.courseLesson.aggregate({
|
||||
where: { courseId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
|
||||
const lesson = await this.prisma.courseLesson.create({
|
||||
data: {
|
||||
courseId,
|
||||
...data,
|
||||
sortOrder: (maxSortOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
return lesson;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
videoPath?: string;
|
||||
videoName?: string;
|
||||
pptPath?: string;
|
||||
pptName?: string;
|
||||
pdfPath?: string;
|
||||
pdfName?: string;
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
assessmentData?: string;
|
||||
useTemplate?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.courseLesson.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
const lesson = await this.prisma.courseLesson.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new Error('课程不存在');
|
||||
}
|
||||
|
||||
await this.prisma.courseLesson.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async reorder(courseId: number, lessonIds: number[]) {
|
||||
const updates = lessonIds.map((id, index) =>
|
||||
this.prisma.courseLesson.update({
|
||||
where: { id },
|
||||
data: { sortOrder: index + 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(updates);
|
||||
}
|
||||
|
||||
// ==================== 教学环节管理 ====================
|
||||
|
||||
async findSteps(lessonId: number) {
|
||||
return this.prisma.lessonStep.findMany({
|
||||
where: { lessonId },
|
||||
include: {
|
||||
stepResources: {
|
||||
include: {
|
||||
resource: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createStep(
|
||||
lessonId: number,
|
||||
data: {
|
||||
name: string;
|
||||
content?: string;
|
||||
duration?: number;
|
||||
objective?: string;
|
||||
resourceIds?: number[];
|
||||
},
|
||||
) {
|
||||
// 获取最大排序号
|
||||
const maxSortOrder = await this.prisma.lessonStep.aggregate({
|
||||
where: { lessonId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
|
||||
const step = await this.prisma.lessonStep.create({
|
||||
data: {
|
||||
lessonId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
duration: data.duration || 5,
|
||||
objective: data.objective,
|
||||
resourceIds: data.resourceIds ? JSON.stringify(data.resourceIds) : null,
|
||||
sortOrder: (maxSortOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 创建环节资源关联
|
||||
if (data.resourceIds && data.resourceIds.length > 0) {
|
||||
await this.prisma.lessonStepResource.createMany({
|
||||
data: data.resourceIds.map((resourceId, index) => ({
|
||||
stepId: step.id,
|
||||
resourceId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.lessonStep.findUnique({
|
||||
where: { id: step.id },
|
||||
include: {
|
||||
stepResources: {
|
||||
include: { resource: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStep(
|
||||
stepId: number,
|
||||
data: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
duration?: number;
|
||||
objective?: string;
|
||||
resourceIds?: number[];
|
||||
},
|
||||
) {
|
||||
const updateData: any = { ...data };
|
||||
if (data.resourceIds !== undefined) {
|
||||
updateData.resourceIds = JSON.stringify(data.resourceIds);
|
||||
|
||||
// 删除旧的资源关联
|
||||
await this.prisma.lessonStepResource.deleteMany({
|
||||
where: { stepId },
|
||||
});
|
||||
|
||||
// 创建新的资源关联
|
||||
if (data.resourceIds.length > 0) {
|
||||
await this.prisma.lessonStepResource.createMany({
|
||||
data: data.resourceIds.map((resourceId, index) => ({
|
||||
stepId,
|
||||
resourceId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.lessonStep.update({
|
||||
where: { id: stepId },
|
||||
data: updateData,
|
||||
include: {
|
||||
stepResources: {
|
||||
include: { resource: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStep(stepId: number) {
|
||||
return this.prisma.lessonStep.delete({
|
||||
where: { id: stepId },
|
||||
});
|
||||
}
|
||||
|
||||
async reorderSteps(lessonId: number, stepIds: number[]) {
|
||||
const updates = stepIds.map((id, index) =>
|
||||
this.prisma.lessonStep.update({
|
||||
where: { id },
|
||||
data: { sortOrder: index + 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(updates);
|
||||
}
|
||||
|
||||
// ==================== 教师端查询 ====================
|
||||
|
||||
async findCourseLessonsForTeacher(courseId: number, tenantId: number) {
|
||||
// 检查租户是否有权限访问该课程
|
||||
const tenantCourse = await this.prisma.tenantCourse.findFirst({
|
||||
where: { tenantId, courseId, authorized: true },
|
||||
});
|
||||
|
||||
if (!tenantCourse) {
|
||||
// 检查是否通过套餐授权
|
||||
const tenantPackage = await this.prisma.tenantPackage.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'ACTIVE',
|
||||
package: {
|
||||
courses: {
|
||||
some: { courseId },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenantPackage) {
|
||||
throw new Error('无权访问该课程');
|
||||
}
|
||||
}
|
||||
|
||||
return this.findAll(courseId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { CoursePackageService } from './course-package.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('admin/packages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class CoursePackageController {
|
||||
constructor(private readonly packageService: CoursePackageService) {}
|
||||
|
||||
// ==================== 套餐管理 ====================
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.packageService.findAllPackages({
|
||||
status,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.packageService.findOnePackage(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
discountPrice?: number;
|
||||
discountType?: string;
|
||||
gradeLevels: string[];
|
||||
},
|
||||
) {
|
||||
return this.packageService.createPackage(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
discountPrice?: number;
|
||||
discountType?: string;
|
||||
gradeLevels?: string[];
|
||||
},
|
||||
) {
|
||||
return this.packageService.updatePackage(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.packageService.deletePackage(id);
|
||||
}
|
||||
|
||||
// ==================== 套餐课程管理 ====================
|
||||
|
||||
@Put(':id/courses')
|
||||
async setCourses(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { courses: { courseId: number; gradeLevel: string; sortOrder?: number }[] },
|
||||
) {
|
||||
return this.packageService.setPackageCourses(id, body.courses);
|
||||
}
|
||||
|
||||
@Post(':id/courses')
|
||||
async addCourse(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { courseId: number; gradeLevel: string; sortOrder?: number },
|
||||
) {
|
||||
return this.packageService.addCourseToPackage(
|
||||
id,
|
||||
body.courseId,
|
||||
body.gradeLevel,
|
||||
body.sortOrder,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/courses/:courseId')
|
||||
async removeCourse(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('courseId', ParseIntPipe) courseId: number,
|
||||
) {
|
||||
return this.packageService.removeCourseFromPackage(id, courseId);
|
||||
}
|
||||
|
||||
// ==================== 套餐状态管理 ====================
|
||||
|
||||
@Post(':id/submit')
|
||||
async submit(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
return this.packageService.submitPackage(id, req.user.id);
|
||||
}
|
||||
|
||||
@Post(':id/review')
|
||||
async review(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { approved: boolean; comment?: string },
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.packageService.reviewPackage(id, req.user.id, body.approved, body.comment);
|
||||
}
|
||||
|
||||
@Post(':id/publish')
|
||||
async publish(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.packageService.publishPackage(id);
|
||||
}
|
||||
|
||||
@Post(':id/offline')
|
||||
async offline(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.packageService.offlinePackage(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 学校端套餐控制器
|
||||
@Controller('school/packages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SchoolPackageController {
|
||||
constructor(private readonly packageService: CoursePackageService) {}
|
||||
|
||||
@Get()
|
||||
async findTenantPackages(@Request() req: any) {
|
||||
return this.packageService.findTenantPackages(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post(':id/renew')
|
||||
async renewPackage(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { endDate: string; pricePaid?: number },
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.packageService.renewTenantPackage(
|
||||
req.user.tenantId,
|
||||
id,
|
||||
body.endDate,
|
||||
body.pricePaid,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoursePackageController, SchoolPackageController } from './course-package.controller';
|
||||
import { CoursePackageService } from './course-package.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CoursePackageController, SchoolPackageController],
|
||||
providers: [CoursePackageService],
|
||||
exports: [CoursePackageService],
|
||||
})
|
||||
export class CoursePackageModule {}
|
||||
@ -0,0 +1,372 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CoursePackageService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 套餐管理 ====================
|
||||
|
||||
async findAllPackages(params: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { status, page = 1, pageSize = 20 } = params;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.coursePackage.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: {
|
||||
select: { courses: true, tenantPackages: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.coursePackage.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((pkg) => ({
|
||||
...pkg,
|
||||
courseCount: pkg._count.courses,
|
||||
tenantCount: pkg._count.tenantPackages,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOnePackage(id: number) {
|
||||
const pkg = await this.prisma.coursePackage.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
duration: true,
|
||||
gradeTags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new Error('套餐不存在');
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async createPackage(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
discountPrice?: number;
|
||||
discountType?: string;
|
||||
gradeLevels: string[];
|
||||
}) {
|
||||
return this.prisma.coursePackage.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
discountPrice: data.discountPrice,
|
||||
discountType: data.discountType,
|
||||
gradeLevels: JSON.stringify(data.gradeLevels),
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updatePackage(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
discountPrice?: number;
|
||||
discountType?: string;
|
||||
gradeLevels?: string[];
|
||||
},
|
||||
) {
|
||||
const updateData: any = { ...data };
|
||||
if (data.gradeLevels) {
|
||||
updateData.gradeLevels = JSON.stringify(data.gradeLevels);
|
||||
}
|
||||
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePackage(id: number) {
|
||||
// 检查是否有租户正在使用
|
||||
const tenantCount = await this.prisma.tenantPackage.count({
|
||||
where: { packageId: id, status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
if (tenantCount > 0) {
|
||||
throw new Error(`有 ${tenantCount} 个租户正在使用该套餐,无法删除`);
|
||||
}
|
||||
|
||||
return this.prisma.coursePackage.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 套餐课程管理 ====================
|
||||
|
||||
async setPackageCourses(
|
||||
packageId: number,
|
||||
courses: { courseId: number; gradeLevel: string; sortOrder?: number }[],
|
||||
) {
|
||||
// 删除现有关联
|
||||
await this.prisma.coursePackageCourse.deleteMany({
|
||||
where: { packageId },
|
||||
});
|
||||
|
||||
// 创建新关联
|
||||
if (courses.length > 0) {
|
||||
await this.prisma.coursePackageCourse.createMany({
|
||||
data: courses.map((c, index) => ({
|
||||
packageId,
|
||||
courseId: c.courseId,
|
||||
gradeLevel: c.gradeLevel,
|
||||
sortOrder: c.sortOrder ?? index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新套餐课程数
|
||||
await this.prisma.coursePackage.update({
|
||||
where: { id: packageId },
|
||||
data: { courseCount: courses.length },
|
||||
});
|
||||
|
||||
return this.findOnePackage(packageId);
|
||||
}
|
||||
|
||||
async addCourseToPackage(
|
||||
packageId: number,
|
||||
courseId: number,
|
||||
gradeLevel: string,
|
||||
sortOrder?: number,
|
||||
) {
|
||||
// 检查是否已存在
|
||||
const existing = await this.prisma.coursePackageCourse.findUnique({
|
||||
where: {
|
||||
packageId_courseId: { packageId, courseId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('该课程已在套餐中');
|
||||
}
|
||||
|
||||
await this.prisma.coursePackageCourse.create({
|
||||
data: {
|
||||
packageId,
|
||||
courseId,
|
||||
gradeLevel,
|
||||
sortOrder: sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 更新套餐课程数
|
||||
const count = await this.prisma.coursePackageCourse.count({
|
||||
where: { packageId },
|
||||
});
|
||||
await this.prisma.coursePackage.update({
|
||||
where: { id: packageId },
|
||||
data: { courseCount: count },
|
||||
});
|
||||
|
||||
return this.findOnePackage(packageId);
|
||||
}
|
||||
|
||||
async removeCourseFromPackage(packageId: number, courseId: number) {
|
||||
await this.prisma.coursePackageCourse.delete({
|
||||
where: {
|
||||
packageId_courseId: { packageId, courseId },
|
||||
},
|
||||
});
|
||||
|
||||
// 更新套餐课程数
|
||||
const count = await this.prisma.coursePackageCourse.count({
|
||||
where: { packageId },
|
||||
});
|
||||
await this.prisma.coursePackage.update({
|
||||
where: { id: packageId },
|
||||
data: { courseCount: count },
|
||||
});
|
||||
|
||||
return this.findOnePackage(packageId);
|
||||
}
|
||||
|
||||
// ==================== 套餐状态管理 ====================
|
||||
|
||||
async submitPackage(id: number, userId: number) {
|
||||
const pkg = await this.prisma.coursePackage.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { courses: true } } },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new Error('套餐不存在');
|
||||
}
|
||||
|
||||
if (pkg._count.courses === 0) {
|
||||
throw new Error('套餐必须包含至少一个课程包');
|
||||
}
|
||||
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PENDING_REVIEW',
|
||||
submittedAt: new Date(),
|
||||
submittedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reviewPackage(
|
||||
id: number,
|
||||
userId: number,
|
||||
approved: boolean,
|
||||
comment?: string,
|
||||
) {
|
||||
const pkg = await this.prisma.coursePackage.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new Error('套餐不存在');
|
||||
}
|
||||
|
||||
if (pkg.status !== 'PENDING_REVIEW') {
|
||||
throw new Error('只有待审核状态的套餐可以审核');
|
||||
}
|
||||
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: approved ? 'APPROVED' : 'REJECTED',
|
||||
reviewedAt: new Date(),
|
||||
reviewedBy: userId,
|
||||
reviewComment: comment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async publishPackage(id: number) {
|
||||
const pkg = await this.prisma.coursePackage.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new Error('套餐不存在');
|
||||
}
|
||||
|
||||
if (pkg.status !== 'APPROVED') {
|
||||
throw new Error('只有已审核通过的套餐可以发布');
|
||||
}
|
||||
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async offlinePackage(id: number) {
|
||||
return this.prisma.coursePackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'OFFLINE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 学校端查询 ====================
|
||||
|
||||
async findTenantPackages(tenantId: number) {
|
||||
return this.prisma.tenantPackage.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
package: {
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async renewTenantPackage(
|
||||
tenantId: number,
|
||||
packageId: number,
|
||||
endDate: string,
|
||||
pricePaid?: number,
|
||||
) {
|
||||
const existing = await this.prisma.tenantPackage.findFirst({
|
||||
where: { tenantId, packageId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.tenantPackage.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
endDate,
|
||||
status: 'ACTIVE',
|
||||
pricePaid: pricePaid ?? existing.pricePaid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.tenantPackage.create({
|
||||
data: {
|
||||
tenantId,
|
||||
packageId,
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
endDate,
|
||||
status: 'ACTIVE',
|
||||
pricePaid: pricePaid ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CourseValidationData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
coverImagePath?: string;
|
||||
gradeTags?: string;
|
||||
domainTags?: string;
|
||||
duration?: number;
|
||||
ebookPaths?: string;
|
||||
audioPaths?: string;
|
||||
videoPaths?: string;
|
||||
otherResources?: string;
|
||||
scripts?: any[];
|
||||
lessonPlanData?: string; // JSON字符串,包含 phases 和 scriptPages
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程发布前验证服务
|
||||
* 验证课程内容的完整性和合规性
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseValidationService {
|
||||
private readonly logger = new Logger(CourseValidationService.name);
|
||||
|
||||
/**
|
||||
* 验证课程是否可以提交审核
|
||||
*/
|
||||
async validateForSubmit(course: CourseValidationData): Promise<ValidationResult> {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
// 1. 验证基本信息
|
||||
this.validateBasicInfo(course, errors);
|
||||
|
||||
// 2. 验证封面图片
|
||||
this.validateCover(course, errors);
|
||||
|
||||
// 3. 验证年级标签
|
||||
this.validateGradeTags(course, errors);
|
||||
|
||||
// 4. 验证课程时长
|
||||
this.validateDuration(course, errors);
|
||||
|
||||
// 5. 验证数字资源
|
||||
this.validateResources(course, warnings);
|
||||
|
||||
// 6. 验证教学流程
|
||||
this.validateScripts(course, errors);
|
||||
|
||||
const result: ValidationResult = {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
|
||||
this.logger.log(`Validation result: valid=${result.valid}, errors=${errors.length}, warnings=${warnings.length}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证基本信息
|
||||
*/
|
||||
private validateBasicInfo(course: CourseValidationData, errors: ValidationError[]): void {
|
||||
// 课程名称
|
||||
if (!course.name || course.name.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: '请输入课程名称',
|
||||
code: 'NAME_REQUIRED',
|
||||
});
|
||||
} else if (course.name.length < 2) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: '课程名称至少需要2个字符',
|
||||
code: 'NAME_TOO_SHORT',
|
||||
});
|
||||
} else if (course.name.length > 50) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: '课程名称不能超过50个字符',
|
||||
code: 'NAME_TOO_LONG',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证封面图片
|
||||
*/
|
||||
private validateCover(course: CourseValidationData, errors: ValidationError[]): void {
|
||||
if (!course.coverImagePath) {
|
||||
errors.push({
|
||||
field: 'coverImagePath',
|
||||
message: '请上传课程封面',
|
||||
code: 'COVER_REQUIRED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证年级标签
|
||||
*/
|
||||
private validateGradeTags(course: CourseValidationData, errors: ValidationError[]): void {
|
||||
if (!course.gradeTags) {
|
||||
errors.push({
|
||||
field: 'gradeTags',
|
||||
message: '请选择适用年级',
|
||||
code: 'GRADE_REQUIRED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const grades = JSON.parse(course.gradeTags);
|
||||
if (!Array.isArray(grades) || grades.length === 0) {
|
||||
errors.push({
|
||||
field: 'gradeTags',
|
||||
message: '请至少选择一个适用年级',
|
||||
code: 'GRADE_EMPTY',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
errors.push({
|
||||
field: 'gradeTags',
|
||||
message: '年级标签格式错误',
|
||||
code: 'GRADE_FORMAT_ERROR',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证课程时长
|
||||
*/
|
||||
private validateDuration(course: CourseValidationData, errors: ValidationError[]): void {
|
||||
if (course.duration === undefined || course.duration === null) {
|
||||
errors.push({
|
||||
field: 'duration',
|
||||
message: '请设置课程时长',
|
||||
code: 'DURATION_REQUIRED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (course.duration < 5) {
|
||||
errors.push({
|
||||
field: 'duration',
|
||||
message: '课程时长不能少于5分钟',
|
||||
code: 'DURATION_TOO_SHORT',
|
||||
});
|
||||
} else if (course.duration > 60) {
|
||||
errors.push({
|
||||
field: 'duration',
|
||||
message: '课程时长不能超过60分钟',
|
||||
code: 'DURATION_TOO_LONG',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字资源
|
||||
*/
|
||||
private validateResources(course: CourseValidationData, warnings: ValidationWarning[]): void {
|
||||
const hasEbook = this.hasValidJsonArray(course.ebookPaths);
|
||||
const hasAudio = this.hasValidJsonArray(course.audioPaths);
|
||||
const hasVideo = this.hasValidJsonArray(course.videoPaths);
|
||||
const hasOther = this.hasValidJsonArray(course.otherResources);
|
||||
|
||||
if (!hasEbook && !hasAudio && !hasVideo && !hasOther) {
|
||||
warnings.push({
|
||||
field: 'resources',
|
||||
message: '建议上传至少1个数字资源(电子绘本、音频或视频)',
|
||||
code: 'RESOURCES_SUGGESTED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证教学流程
|
||||
*/
|
||||
private validateScripts(course: CourseValidationData, errors: ValidationError[]): void {
|
||||
// 优先检查 lessonPlanData 中的 phases(新数据结构)
|
||||
if (course.lessonPlanData) {
|
||||
try {
|
||||
const lessonPlan = JSON.parse(course.lessonPlanData);
|
||||
if (!lessonPlan.phases || !Array.isArray(lessonPlan.phases) || lessonPlan.phases.length === 0) {
|
||||
errors.push({
|
||||
field: 'lessonPlanData',
|
||||
message: '请至少配置一个教学环节',
|
||||
code: 'SCRIPTS_REQUIRED',
|
||||
});
|
||||
}
|
||||
return; // 使用新数据结构验证完成
|
||||
} catch {
|
||||
errors.push({
|
||||
field: 'lessonPlanData',
|
||||
message: '教学计划数据格式错误',
|
||||
code: 'LESSON_PLAN_FORMAT_ERROR',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的 scripts 字段
|
||||
if (course.scripts !== undefined) {
|
||||
if (!Array.isArray(course.scripts) || course.scripts.length === 0) {
|
||||
errors.push({
|
||||
field: 'scripts',
|
||||
message: '请至少配置一个教学环节',
|
||||
code: 'SCRIPTS_REQUIRED',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是有效的JSON数组
|
||||
*/
|
||||
private hasValidJsonArray(jsonStr: string | undefined | null): boolean {
|
||||
if (!jsonStr) return false;
|
||||
|
||||
try {
|
||||
const arr = JSON.parse(jsonStr);
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速检查课程是否可以提交(只返回布尔值)
|
||||
*/
|
||||
async canSubmit(course: CourseValidationData): Promise<boolean> {
|
||||
const result = await this.validateForSubmit(course);
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证摘要(用于显示)
|
||||
*/
|
||||
getValidationSummary(result: ValidationResult): string {
|
||||
if (result.valid && result.warnings.length === 0) {
|
||||
return '课程内容完整,可以提交审核';
|
||||
}
|
||||
|
||||
if (result.valid && result.warnings.length > 0) {
|
||||
return `课程可以提交,但有 ${result.warnings.length} 条建议`;
|
||||
}
|
||||
|
||||
return `课程有 ${result.errors.length} 个问题需要修复`;
|
||||
}
|
||||
}
|
||||
161
reading-platform-backend/src/modules/course/course.controller.ts
Normal file
161
reading-platform-backend/src/modules/course/course.controller.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { CourseService } from './course.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('courses')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class CourseController {
|
||||
constructor(private readonly courseService: CourseService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: any) {
|
||||
return this.courseService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('review')
|
||||
getReviewList(@Query() query: any) {
|
||||
return this.courseService.getReviewList(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.courseService.findOne(+id);
|
||||
}
|
||||
|
||||
@Get(':id/stats')
|
||||
getStats(@Param('id') id: string) {
|
||||
return this.courseService.getStats(+id);
|
||||
}
|
||||
|
||||
@Get(':id/validate')
|
||||
validate(@Param('id') id: string) {
|
||||
return this.courseService.validate(+id);
|
||||
}
|
||||
|
||||
@Get(':id/versions')
|
||||
getVersionHistory(@Param('id') id: string) {
|
||||
return this.courseService.getVersionHistory(+id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createCourseDto: any, @Request() req: any) {
|
||||
return this.courseService.create({
|
||||
...createCourseDto,
|
||||
createdBy: req.user?.userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() updateCourseDto: any) {
|
||||
return this.courseService.update(+id, updateCourseDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.courseService.remove(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审核
|
||||
* POST /api/v1/courses/:id/submit
|
||||
*/
|
||||
@Post(':id/submit')
|
||||
submit(@Param('id') id: string, @Body() body: { copyrightConfirmed: boolean }, @Request() req: any) {
|
||||
const userId = req.user?.userId || 0;
|
||||
return this.courseService.submit(+id, userId, body.copyrightConfirmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销审核申请
|
||||
* POST /api/v1/courses/:id/withdraw
|
||||
*/
|
||||
@Post(':id/withdraw')
|
||||
withdraw(@Param('id') id: string, @Request() req: any) {
|
||||
const userId = req.user?.userId || 0;
|
||||
return this.courseService.withdraw(+id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过
|
||||
* POST /api/v1/courses/:id/approve
|
||||
*/
|
||||
@Post(':id/approve')
|
||||
approve(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { checklist?: any; comment?: string },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reviewerId = req.user?.userId || 0;
|
||||
return this.courseService.approve(+id, reviewerId, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回
|
||||
* POST /api/v1/courses/:id/reject
|
||||
*/
|
||||
@Post(':id/reject')
|
||||
reject(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { checklist?: any; comment: string },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reviewerId = req.user?.userId || 0;
|
||||
return this.courseService.reject(+id, reviewerId, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接发布(超级管理员专用)
|
||||
* POST /api/v1/courses/:id/direct-publish
|
||||
*/
|
||||
@Post(':id/direct-publish')
|
||||
@Roles('admin')
|
||||
directPublish(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { skipValidation?: boolean },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const userId = req.user?.userId || 0;
|
||||
return this.courseService.directPublish(+id, userId, body.skipValidation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布课程(兼容旧API)
|
||||
* POST /api/v1/courses/:id/publish
|
||||
*/
|
||||
@Post(':id/publish')
|
||||
publish(@Param('id') id: string) {
|
||||
return this.courseService.publish(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下架课程
|
||||
* POST /api/v1/courses/:id/unpublish
|
||||
*/
|
||||
@Post(':id/unpublish')
|
||||
unpublish(@Param('id') id: string) {
|
||||
return this.courseService.unpublish(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发布已下架的课程
|
||||
* POST /api/v1/courses/:id/republish
|
||||
*/
|
||||
@Post(':id/republish')
|
||||
republish(@Param('id') id: string) {
|
||||
return this.courseService.republish(+id);
|
||||
}
|
||||
}
|
||||
13
reading-platform-backend/src/modules/course/course.module.ts
Normal file
13
reading-platform-backend/src/modules/course/course.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CourseController } from './course.controller';
|
||||
import { CourseService } from './course.service';
|
||||
import { CourseValidationService } from './course-validation.service';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [CourseController],
|
||||
providers: [CourseService, CourseValidationService],
|
||||
exports: [CourseService, CourseValidationService],
|
||||
})
|
||||
export class CourseModule {}
|
||||
1033
reading-platform-backend/src/modules/course/course.service.ts
Normal file
1033
reading-platform-backend/src/modules/course/course.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,88 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ExportService } from './export.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('school/export')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class ExportController {
|
||||
constructor(private readonly exportService: ExportService) {}
|
||||
|
||||
@Get('teachers')
|
||||
async exportTeachers(@Request() req: any, @Res() res: Response) {
|
||||
const buffer = await this.exportService.exportTeachers(req.user.tenantId);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=teachers_${Date.now()}.xlsx`,
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('students')
|
||||
async exportStudents(@Request() req: any, @Res() res: Response) {
|
||||
const buffer = await this.exportService.exportStudents(req.user.tenantId);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=students_${Date.now()}.xlsx`,
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('lessons')
|
||||
async exportLessons(@Request() req: any, @Res() res: Response) {
|
||||
const buffer = await this.exportService.exportLessons(req.user.tenantId);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=lessons_${Date.now()}.xlsx`,
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('growth-records')
|
||||
async exportGrowthRecords(
|
||||
@Request() req: any,
|
||||
@Query('studentId') studentId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const buffer = await this.exportService.exportGrowthRecords(
|
||||
req.user.tenantId,
|
||||
studentId ? +studentId : undefined,
|
||||
);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=growth_records_${Date.now()}.xlsx`,
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
}
|
||||
10
reading-platform-backend/src/modules/export/export.module.ts
Normal file
10
reading-platform-backend/src/modules/export/export.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExportController],
|
||||
providers: [ExportService],
|
||||
exports: [ExportService],
|
||||
})
|
||||
export class ExportModule {}
|
||||
266
reading-platform-backend/src/modules/export/export.service.ts
Normal file
266
reading-platform-backend/src/modules/export/export.service.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 导出教师列表 ====================
|
||||
|
||||
async exportTeachers(tenantId: number): Promise<Buffer> {
|
||||
const teachers = await this.prisma.teacher.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
classes: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('教师列表');
|
||||
|
||||
// 设置表头
|
||||
worksheet.columns = [
|
||||
{ header: 'ID', key: 'id', width: 8 },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '手机号', key: 'phone', width: 15 },
|
||||
{ header: '邮箱', key: 'email', width: 25 },
|
||||
{ header: '登录账号', key: 'loginAccount', width: 15 },
|
||||
{ header: '负责班级', key: 'classes', width: 20 },
|
||||
{ header: '授课次数', key: 'lessonCount', width: 10 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '创建时间', key: 'createdAt', width: 20 },
|
||||
];
|
||||
|
||||
// 设置表头样式
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
// 添加数据
|
||||
teachers.forEach((teacher) => {
|
||||
worksheet.addRow({
|
||||
id: teacher.id,
|
||||
name: teacher.name,
|
||||
phone: teacher.phone,
|
||||
email: teacher.email || '-',
|
||||
loginAccount: teacher.loginAccount,
|
||||
classes: teacher.classes.map((c) => c.name).join('、') || '-',
|
||||
lessonCount: teacher.lessonCount,
|
||||
status: teacher.status === 'ACTIVE' ? '正常' : '停用',
|
||||
createdAt: teacher.createdAt.toLocaleDateString('zh-CN'),
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
// ==================== 导出学生列表 ====================
|
||||
|
||||
async exportStudents(tenantId: number): Promise<Buffer> {
|
||||
const students = await this.prisma.student.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
class: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ classId: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('学生列表');
|
||||
|
||||
// 设置表头
|
||||
worksheet.columns = [
|
||||
{ header: 'ID', key: 'id', width: 8 },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '班级', key: 'className', width: 15 },
|
||||
{ header: '生日', key: 'birthDate', width: 15 },
|
||||
{ header: '家长姓名', key: 'parentName', width: 15 },
|
||||
{ header: '家长电话', key: 'parentPhone', width: 15 },
|
||||
{ header: '阅读次数', key: 'readingCount', width: 10 },
|
||||
{ header: '创建时间', key: 'createdAt', width: 20 },
|
||||
];
|
||||
|
||||
// 设置表头样式
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
// 添加数据
|
||||
students.forEach((student) => {
|
||||
worksheet.addRow({
|
||||
id: student.id,
|
||||
name: student.name,
|
||||
gender: student.gender || '-',
|
||||
className: student.class?.name || '-',
|
||||
birthDate: student.birthDate
|
||||
? new Date(student.birthDate).toLocaleDateString('zh-CN')
|
||||
: '-',
|
||||
parentName: student.parentName || '-',
|
||||
parentPhone: student.parentPhone || '-',
|
||||
readingCount: student.readingCount,
|
||||
createdAt: student.createdAt.toLocaleDateString('zh-CN'),
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
// ==================== 导出授课记录 ====================
|
||||
|
||||
async exportLessons(tenantId: number): Promise<Buffer> {
|
||||
const lessons = await this.prisma.lesson.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
course: {
|
||||
select: { name: true, pictureBookName: true },
|
||||
},
|
||||
class: {
|
||||
select: { name: true },
|
||||
},
|
||||
teacher: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('授课记录');
|
||||
|
||||
// 设置表头
|
||||
worksheet.columns = [
|
||||
{ header: 'ID', key: 'id', width: 8 },
|
||||
{ header: '课程名称', key: 'courseName', width: 25 },
|
||||
{ header: '绘本名称', key: 'pictureBookName', width: 20 },
|
||||
{ header: '授课班级', key: 'className', width: 15 },
|
||||
{ header: '授课教师', key: 'teacherName', width: 12 },
|
||||
{ header: '计划时间', key: 'plannedDatetime', width: 18 },
|
||||
{ header: '开始时间', key: 'startDatetime', width: 18 },
|
||||
{ header: '结束时间', key: 'endDatetime', width: 18 },
|
||||
{ header: '实际时长(分钟)', key: 'actualDuration', width: 12 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
];
|
||||
|
||||
// 设置表头样式
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
// 状态映射
|
||||
const statusMap: Record<string, string> = {
|
||||
PLANNED: '已计划',
|
||||
IN_PROGRESS: '进行中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
|
||||
// 添加数据
|
||||
lessons.forEach((lesson) => {
|
||||
worksheet.addRow({
|
||||
id: lesson.id,
|
||||
courseName: lesson.course?.name || '-',
|
||||
pictureBookName: lesson.course?.pictureBookName || '-',
|
||||
className: lesson.class?.name || '-',
|
||||
teacherName: lesson.teacher?.name || '-',
|
||||
plannedDatetime: lesson.plannedDatetime
|
||||
? new Date(lesson.plannedDatetime).toLocaleString('zh-CN')
|
||||
: '-',
|
||||
startDatetime: lesson.startDatetime
|
||||
? new Date(lesson.startDatetime).toLocaleString('zh-CN')
|
||||
: '-',
|
||||
endDatetime: lesson.endDatetime
|
||||
? new Date(lesson.endDatetime).toLocaleString('zh-CN')
|
||||
: '-',
|
||||
actualDuration: lesson.actualDuration || '-',
|
||||
status: statusMap[lesson.status] || lesson.status,
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
// ==================== 导出成长档案(简单文本格式) ====================
|
||||
|
||||
async exportGrowthRecords(tenantId: number, studentId?: number): Promise<Buffer> {
|
||||
const where: any = { tenantId };
|
||||
if (studentId) {
|
||||
where.studentId = studentId;
|
||||
}
|
||||
|
||||
const records = await this.prisma.growthRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
student: {
|
||||
select: { name: true },
|
||||
},
|
||||
class: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: { recordDate: 'desc' },
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('成长档案');
|
||||
|
||||
// 设置表头
|
||||
worksheet.columns = [
|
||||
{ header: 'ID', key: 'id', width: 8 },
|
||||
{ header: '学生姓名', key: 'studentName', width: 15 },
|
||||
{ header: '班级', key: 'className', width: 15 },
|
||||
{ header: '标题', key: 'title', width: 25 },
|
||||
{ header: '内容', key: 'content', width: 50 },
|
||||
{ header: '记录类型', key: 'recordType', width: 12 },
|
||||
{ header: '记录日期', key: 'recordDate', width: 15 },
|
||||
{ header: '创建时间', key: 'createdAt', width: 20 },
|
||||
];
|
||||
|
||||
// 设置表头样式
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
// 添加数据
|
||||
records.forEach((record) => {
|
||||
worksheet.addRow({
|
||||
id: record.id,
|
||||
studentName: record.student?.name || '-',
|
||||
className: record.class?.name || '-',
|
||||
title: record.title,
|
||||
content: record.content || '-',
|
||||
recordType: record.recordType,
|
||||
recordDate: record.recordDate
|
||||
? new Date(record.recordDate).toLocaleDateString('zh-CN')
|
||||
: '-',
|
||||
createdAt: record.createdAt.toLocaleDateString('zh-CN'),
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Body,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { FileUploadService } from './file-upload.service';
|
||||
|
||||
@Controller('files')
|
||||
export class FileUploadController {
|
||||
private readonly logger = new Logger(FileUploadController.name);
|
||||
|
||||
constructor(private readonly fileUploadService: FileUploadService) {}
|
||||
|
||||
/**
|
||||
* 上传单个文件
|
||||
* POST /api/v1/files/upload
|
||||
*/
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 300 * 1024 * 1024, // 300MB
|
||||
},
|
||||
}),
|
||||
)
|
||||
async uploadFile(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() body: { type?: string; courseId?: string },
|
||||
) {
|
||||
this.logger.log(`Uploading file: ${file.originalname}, type: ${body.type || 'unknown'}`);
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('没有上传文件');
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
const fileType = body.type || 'other';
|
||||
const validationResult = this.fileUploadService.validateFile(file, fileType);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
throw new BadRequestException(validationResult.error);
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const savedFile = await this.fileUploadService.saveFile(file, fileType, body.courseId);
|
||||
|
||||
this.logger.log(`File uploaded successfully: ${savedFile.filePath}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: savedFile.filePath,
|
||||
fileName: savedFile.fileName,
|
||||
originalName: file.originalname,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* DELETE /api/v1/files/delete
|
||||
*/
|
||||
@Delete('delete')
|
||||
async deleteFile(@Body() body: { filePath: string }) {
|
||||
this.logger.log(`Deleting file: ${body.filePath}`);
|
||||
|
||||
if (!body.filePath) {
|
||||
throw new BadRequestException('缺少文件路径');
|
||||
}
|
||||
|
||||
const result = await this.fileUploadService.deleteFile(body.filePath);
|
||||
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error);
|
||||
}
|
||||
|
||||
this.logger.log(`File deleted successfully: ${body.filePath}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '文件删除成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FileUploadController } from './file-upload.controller';
|
||||
import { FileUploadService } from './file-upload.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FileUploadController],
|
||||
providers: [FileUploadService],
|
||||
exports: [FileUploadService],
|
||||
})
|
||||
export class FileUploadModule {}
|
||||
@ -0,0 +1,203 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { extname, join, basename } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
// 文件类型配置
|
||||
const FILE_TYPE_CONFIG = {
|
||||
cover: {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
folder: 'covers',
|
||||
},
|
||||
ebook: {
|
||||
allowedMimeTypes: [
|
||||
'application/pdf',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: 'ebooks',
|
||||
},
|
||||
audio: {
|
||||
allowedMimeTypes: ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/ogg'],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: 'audio',
|
||||
},
|
||||
video: {
|
||||
allowedMimeTypes: ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: 'videos',
|
||||
},
|
||||
ppt: {
|
||||
allowedMimeTypes: [
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/pdf', // 也允许PDF格式
|
||||
],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: join('materials', 'ppt'),
|
||||
},
|
||||
poster: {
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
folder: join('materials', 'posters'),
|
||||
},
|
||||
document: {
|
||||
allowedMimeTypes: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: 'documents',
|
||||
},
|
||||
other: {
|
||||
allowedMimeTypes: [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
],
|
||||
maxSize: 300 * 1024 * 1024, // 300MB
|
||||
folder: 'other',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadService {
|
||||
private readonly logger = new Logger(FileUploadService.name);
|
||||
private readonly uploadBasePath = join(process.cwd(), 'uploads', 'courses');
|
||||
|
||||
constructor() {
|
||||
this.ensureDirectoriesExist();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保所有必要的目录存在
|
||||
*/
|
||||
private async ensureDirectoriesExist() {
|
||||
const directories = Object.values(FILE_TYPE_CONFIG).map((config) =>
|
||||
join(this.uploadBasePath, config.folder),
|
||||
);
|
||||
|
||||
for (const dir of directories) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
this.logger.log(`Ensured directory exists: ${dir}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件
|
||||
*/
|
||||
validateFile(
|
||||
file: Express.Multer.File,
|
||||
type: string,
|
||||
): { valid: boolean; error?: string } {
|
||||
const config = FILE_TYPE_CONFIG[type as keyof typeof FILE_TYPE_CONFIG] || FILE_TYPE_CONFIG.other;
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > config.maxSize) {
|
||||
const maxSizeMB = (config.maxSize / (1024 * 1024)).toFixed(0);
|
||||
return {
|
||||
valid: false,
|
||||
error: `文件大小超过限制,最大允许 ${maxSizeMB}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查 MIME 类型
|
||||
if (!config.allowedMimeTypes.includes(file.mimetype)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `不支持的文件类型: ${file.mimetype}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件
|
||||
*/
|
||||
async saveFile(
|
||||
file: Express.Multer.File,
|
||||
type: string,
|
||||
courseId?: string,
|
||||
): Promise<{ filePath: string; fileName: string }> {
|
||||
const config = FILE_TYPE_CONFIG[type as keyof typeof FILE_TYPE_CONFIG] || FILE_TYPE_CONFIG.other;
|
||||
|
||||
// 生成安全的文件名(避免中文和特殊字符编码问题)
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
const originalExt = extname(file.originalname) || '';
|
||||
const courseIdPrefix = courseId ? `${courseId}_` : '';
|
||||
const newFileName = `${courseIdPrefix}${timestamp}_${randomStr}${originalExt}`;
|
||||
|
||||
// 目标路径
|
||||
const targetDir = join(this.uploadBasePath, config.folder);
|
||||
const targetPath = join(targetDir, newFileName);
|
||||
|
||||
try {
|
||||
// 写入文件
|
||||
await fs.writeFile(targetPath, file.buffer);
|
||||
|
||||
// 返回相对路径(用于 API 响应和数据库存储)
|
||||
const relativePath = `/uploads/courses/${config.folder}/${newFileName}`;
|
||||
|
||||
this.logger.log(`File saved: ${targetPath}`);
|
||||
|
||||
return {
|
||||
filePath: relativePath,
|
||||
fileName: newFileName,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save file:', error);
|
||||
throw new BadRequestException('文件保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async deleteFile(filePath: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 安全检查:确保路径在 uploads 目录内,防止目录遍历攻击
|
||||
if (!filePath.startsWith('/uploads/')) {
|
||||
return { success: false, error: '非法的文件路径' };
|
||||
}
|
||||
|
||||
// 防止路径遍历攻击
|
||||
const normalizedPath = filePath.replace(/\.\./g, '');
|
||||
const fullPath = join(process.cwd(), normalizedPath);
|
||||
|
||||
// 确保最终路径仍在 uploads 目录内
|
||||
if (!fullPath.startsWith(join(process.cwd(), 'uploads'))) {
|
||||
return { success: false, error: '非法的文件路径' };
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch {
|
||||
this.logger.warn(`File not found: ${fullPath}`);
|
||||
return { success: true, error: '文件不存在' }; // 文件不存在也返回成功
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
await fs.unlink(fullPath);
|
||||
this.logger.log(`File deleted: ${fullPath}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to delete file:', error);
|
||||
return { success: false, error: '文件删除失败' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, IsEnum, IsDateString, IsArray } from 'class-validator';
|
||||
|
||||
export enum RecordType {
|
||||
STUDENT = 'STUDENT',
|
||||
CLASS = 'CLASS',
|
||||
}
|
||||
|
||||
export class CreateGrowthRecordDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty({ message: '学生ID不能为空' })
|
||||
studentId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsEnum(RecordType)
|
||||
recordType: RecordType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
images?: string[];
|
||||
|
||||
@IsDateString()
|
||||
recordDate: string;
|
||||
}
|
||||
|
||||
export class UpdateGrowthRecordDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
images?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
recordDate?: string;
|
||||
}
|
||||
|
||||
export class QueryGrowthRecordDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
studentId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recordType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
117
reading-platform-backend/src/modules/growth/growth.controller.ts
Normal file
117
reading-platform-backend/src/modules/growth/growth.controller.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { GrowthService } from './growth.service';
|
||||
import { CreateGrowthRecordDto, UpdateGrowthRecordDto } from './dto/create-growth.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('school')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class GrowthController {
|
||||
constructor(private readonly growthService: GrowthService) {}
|
||||
|
||||
@Get('growth-records')
|
||||
findAll(@Request() req: any, @Query() query: any) {
|
||||
return this.growthService.findAll(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('growth-records/:id')
|
||||
findOne(@Request() req: any, @Param('id') id: string) {
|
||||
return this.growthService.findOne(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('growth-records')
|
||||
create(@Request() req: any, @Body() dto: CreateGrowthRecordDto) {
|
||||
return this.growthService.create(req.user.tenantId, req.user.userId, dto);
|
||||
}
|
||||
|
||||
@Put('growth-records/:id')
|
||||
update(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGrowthRecordDto,
|
||||
) {
|
||||
return this.growthService.update(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('growth-records/:id')
|
||||
delete(@Request() req: any, @Param('id') id: string) {
|
||||
return this.growthService.delete(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Get('students/:studentId/growth-records')
|
||||
findByStudent(
|
||||
@Request() req: any,
|
||||
@Param('studentId') studentId: string,
|
||||
@Query() query: any,
|
||||
) {
|
||||
return this.growthService.findByStudent(req.user.tenantId, +studentId, query);
|
||||
}
|
||||
|
||||
@Get('classes/:classId/growth-records')
|
||||
findByClass(
|
||||
@Request() req: any,
|
||||
@Param('classId') classId: string,
|
||||
@Query() query: any,
|
||||
) {
|
||||
return this.growthService.findByClass(req.user.tenantId, +classId, query);
|
||||
}
|
||||
}
|
||||
|
||||
// 教师端的成长档案控制器
|
||||
@Controller('teacher')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class TeacherGrowthController {
|
||||
constructor(private readonly growthService: GrowthService) {}
|
||||
|
||||
@Get('growth-records')
|
||||
findAll(@Request() req: any, @Query() query: any) {
|
||||
return this.growthService.findAllForTeacher(req.user.tenantId, req.user.userId, query);
|
||||
}
|
||||
|
||||
@Get('growth-records/:id')
|
||||
findOne(@Request() req: any, @Param('id') id: string) {
|
||||
return this.growthService.findOneForTeacher(req.user.tenantId, req.user.userId, +id);
|
||||
}
|
||||
|
||||
@Post('growth-records')
|
||||
create(@Request() req: any, @Body() dto: CreateGrowthRecordDto) {
|
||||
return this.growthService.createForTeacher(req.user.tenantId, req.user.userId, dto);
|
||||
}
|
||||
|
||||
@Put('growth-records/:id')
|
||||
update(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGrowthRecordDto,
|
||||
) {
|
||||
return this.growthService.updateForTeacher(req.user.tenantId, req.user.userId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('growth-records/:id')
|
||||
delete(@Request() req: any, @Param('id') id: string) {
|
||||
return this.growthService.deleteForTeacher(req.user.tenantId, req.user.userId, +id);
|
||||
}
|
||||
|
||||
@Get('classes/:classId/growth-records')
|
||||
findByClass(
|
||||
@Request() req: any,
|
||||
@Param('classId') classId: string,
|
||||
@Query() query: any,
|
||||
) {
|
||||
return this.growthService.findByClass(req.user.tenantId, +classId, query);
|
||||
}
|
||||
}
|
||||
10
reading-platform-backend/src/modules/growth/growth.module.ts
Normal file
10
reading-platform-backend/src/modules/growth/growth.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GrowthController, TeacherGrowthController } from './growth.controller';
|
||||
import { GrowthService } from './growth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GrowthController, TeacherGrowthController],
|
||||
providers: [GrowthService],
|
||||
exports: [GrowthService],
|
||||
})
|
||||
export class GrowthModule {}
|
||||
637
reading-platform-backend/src/modules/growth/growth.service.ts
Normal file
637
reading-platform-backend/src/modules/growth/growth.service.ts
Normal file
@ -0,0 +1,637 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { CreateGrowthRecordDto, UpdateGrowthRecordDto } from './dto/create-growth.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GrowthService {
|
||||
private readonly logger = new Logger(GrowthService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private parseJsonArray(value: any): any[] {
|
||||
if (!value) return [];
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
// ==================== 成长档案管理 ====================
|
||||
|
||||
async findAll(tenantId: number, query: any) {
|
||||
const { page = 1, pageSize = 10, studentId, classId, recordType, keyword } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {
|
||||
tenantId: tenantId,
|
||||
};
|
||||
|
||||
if (studentId) {
|
||||
where.studentId = +studentId;
|
||||
}
|
||||
|
||||
if (classId) {
|
||||
where.classId = +classId;
|
||||
}
|
||||
|
||||
if (recordType) {
|
||||
where.recordType = recordType;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ title: { contains: keyword } },
|
||||
{ content: { contains: keyword } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { recordDate: 'desc' },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.growthRecord.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
images: this.parseJsonArray(item.images),
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师端查询成长档案(带数据隔离)
|
||||
* 仅返回教师所教班级学生的档案
|
||||
*/
|
||||
async findAllForTeacher(tenantId: number, teacherId: number, query: any) {
|
||||
const { page = 1, pageSize = 10, studentId, classId, recordType, keyword } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
// 获取教师关联的所有班级ID
|
||||
const classTeachers = await this.prisma.classTeacher.findMany({
|
||||
where: { teacherId },
|
||||
select: { classId: true },
|
||||
});
|
||||
|
||||
const classIds = classTeachers.map((ct) => ct.classId);
|
||||
|
||||
if (classIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
const where: any = {
|
||||
tenantId: tenantId,
|
||||
classId: { in: classIds }, // 数据隔离:只查询教师所教班级的档案
|
||||
};
|
||||
|
||||
if (studentId) {
|
||||
where.studentId = +studentId;
|
||||
}
|
||||
|
||||
// 如果指定了 classId,需要验证是否在教师的班级列表中
|
||||
if (classId) {
|
||||
if (!classIds.includes(+classId)) {
|
||||
throw new ForbiddenException('您没有权限查看该班级的档案');
|
||||
}
|
||||
where.classId = +classId;
|
||||
}
|
||||
|
||||
if (recordType) {
|
||||
where.recordType = recordType;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ title: { contains: keyword } },
|
||||
{ content: { contains: keyword } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { recordDate: 'desc' },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.growthRecord.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
images: this.parseJsonArray(item.images),
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师端查询单个档案(带数据隔离)
|
||||
*/
|
||||
async findOneForTeacher(tenantId: number, teacherId: number, id: number) {
|
||||
const record = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
birthDate: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
// 验证教师是否有权限查看该班级的档案
|
||||
const classTeacher = await this.prisma.classTeacher.findFirst({
|
||||
where: { teacherId, classId: record.classId },
|
||||
});
|
||||
|
||||
if (!classTeacher) {
|
||||
throw new ForbiddenException('您没有权限查看此档案');
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师端创建档案(带数据隔离验证)
|
||||
*/
|
||||
async createForTeacher(tenantId: number, teacherId: number, dto: CreateGrowthRecordDto) {
|
||||
// 验证学生是否存在
|
||||
const student = await this.prisma.student.findFirst({
|
||||
where: {
|
||||
id: dto.studentId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
throw new NotFoundException('学生不存在');
|
||||
}
|
||||
|
||||
// 验证教师是否有权限为该班级创建档案
|
||||
const classId = dto.classId || student.classId;
|
||||
const classTeacher = await this.prisma.classTeacher.findFirst({
|
||||
where: { teacherId, classId },
|
||||
});
|
||||
|
||||
if (!classTeacher) {
|
||||
throw new ForbiddenException('您没有权限为此班级创建档案');
|
||||
}
|
||||
|
||||
const record = await this.prisma.growthRecord.create({
|
||||
data: {
|
||||
tenantId: tenantId,
|
||||
studentId: dto.studentId,
|
||||
classId: classId,
|
||||
recordType: dto.recordType,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
images: JSON.stringify(dto.images || []),
|
||||
recordDate: new Date(dto.recordDate),
|
||||
createdBy: teacherId,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record created by teacher: ${record.id}`);
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师端更新档案(带数据隔离验证)
|
||||
*/
|
||||
async updateForTeacher(tenantId: number, teacherId: number, id: number, dto: UpdateGrowthRecordDto) {
|
||||
const existing = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
// 验证教师是否有权限更新该班级的档案
|
||||
const classTeacher = await this.prisma.classTeacher.findFirst({
|
||||
where: { teacherId, classId: existing.classId },
|
||||
});
|
||||
|
||||
if (!classTeacher) {
|
||||
throw new ForbiddenException('您没有权限更新此档案');
|
||||
}
|
||||
|
||||
const record = await this.prisma.growthRecord.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
images: dto.images ? JSON.stringify(dto.images) : undefined,
|
||||
recordDate: dto.recordDate ? new Date(dto.recordDate) : undefined,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record updated by teacher: ${id}`);
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师端删除档案(带数据隔离验证)
|
||||
*/
|
||||
async deleteForTeacher(tenantId: number, teacherId: number, id: number) {
|
||||
const existing = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
// 验证教师是否有权限删除该班级的档案
|
||||
const classTeacher = await this.prisma.classTeacher.findFirst({
|
||||
where: { teacherId, classId: existing.classId },
|
||||
});
|
||||
|
||||
if (!classTeacher) {
|
||||
throw new ForbiddenException('您没有权限删除此档案');
|
||||
}
|
||||
|
||||
await this.prisma.growthRecord.delete({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record deleted by teacher: ${id}`);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async findOne(tenantId: number, id: number) {
|
||||
const record = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
birthDate: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
async create(tenantId: number, userId: number, dto: CreateGrowthRecordDto) {
|
||||
// 验证学生是否存在
|
||||
const student = await this.prisma.student.findFirst({
|
||||
where: {
|
||||
id: dto.studentId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
throw new NotFoundException('学生不存在');
|
||||
}
|
||||
|
||||
const record = await this.prisma.growthRecord.create({
|
||||
data: {
|
||||
tenantId: tenantId,
|
||||
studentId: dto.studentId,
|
||||
classId: dto.classId || student.classId,
|
||||
recordType: dto.recordType,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
images: JSON.stringify(dto.images || []),
|
||||
recordDate: new Date(dto.recordDate),
|
||||
createdBy: userId,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record created: ${record.id}`);
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
async update(tenantId: number, id: number, dto: UpdateGrowthRecordDto) {
|
||||
const existing = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
const record = await this.prisma.growthRecord.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
images: dto.images ? JSON.stringify(dto.images) : undefined,
|
||||
recordDate: dto.recordDate ? new Date(dto.recordDate) : undefined,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record updated: ${id}`);
|
||||
|
||||
return {
|
||||
...record,
|
||||
images: this.parseJsonArray(record.images),
|
||||
};
|
||||
}
|
||||
|
||||
async delete(tenantId: number, id: number) {
|
||||
const existing = await this.prisma.growthRecord.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('成长档案不存在');
|
||||
}
|
||||
|
||||
await this.prisma.growthRecord.delete({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
this.logger.log(`Growth record deleted: ${id}`);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
// ==================== 学生档案列表 ====================
|
||||
|
||||
async findByStudent(tenantId: number, studentId: number, query: any) {
|
||||
const { page = 1, pageSize = 10 } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
// 验证学生是否存在
|
||||
const student = await this.prisma.student.findFirst({
|
||||
where: {
|
||||
id: studentId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
throw new NotFoundException('学生不存在');
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthRecord.findMany({
|
||||
where: {
|
||||
studentId: studentId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
orderBy: { recordDate: 'desc' },
|
||||
}),
|
||||
this.prisma.growthRecord.count({
|
||||
where: {
|
||||
studentId: studentId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
images: this.parseJsonArray(item.images),
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 班级档案列表 ====================
|
||||
|
||||
async findByClass(tenantId: number, classId: number, query: any) {
|
||||
const { page = 1, pageSize = 10, recordDate } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
// 验证班级是否存在
|
||||
const classEntity = await this.prisma.class.findFirst({
|
||||
where: {
|
||||
id: classId,
|
||||
tenantId: tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!classEntity) {
|
||||
throw new NotFoundException('班级不存在');
|
||||
}
|
||||
|
||||
const where: any = {
|
||||
classId: classId,
|
||||
tenantId: tenantId,
|
||||
recordType: 'CLASS',
|
||||
};
|
||||
|
||||
if (recordDate) {
|
||||
where.recordDate = new Date(recordDate);
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { recordDate: 'desc' },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.growthRecord.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
images: this.parseJsonArray(item.images),
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateLessonDto {
|
||||
@IsNumber()
|
||||
courseId: number;
|
||||
|
||||
@IsNumber()
|
||||
classId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
plannedDatetime?: string;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { IsString, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class FinishLessonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
overallRating?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
participationRating?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
completionNote?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
actualDuration?: number;
|
||||
}
|
||||
133
reading-platform-backend/src/modules/lesson/lesson.controller.ts
Normal file
133
reading-platform-backend/src/modules/lesson/lesson.controller.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { LessonService } from './lesson.service';
|
||||
import { CreateLessonDto } from './dto/create-lesson.dto';
|
||||
import { FinishLessonDto } from './dto/finish-lesson.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
// 教师端授课控制器
|
||||
@Controller('teacher/lessons')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class LessonController {
|
||||
constructor(private readonly lessonService: LessonService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req: any, @Query() query: any) {
|
||||
return this.lessonService.findByTeacher(req.user.userId, query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Request() req: any, @Param('id') id: string) {
|
||||
return this.lessonService.findOne(+id, req.user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Request() req: any, @Body() dto: CreateLessonDto) {
|
||||
return this.lessonService.create(req.user.userId, req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
start(@Request() req: any, @Param('id') id: string) {
|
||||
return this.lessonService.start(+id, req.user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/finish')
|
||||
finish(@Request() req: any, @Param('id') id: string, @Body() dto: FinishLessonDto) {
|
||||
return this.lessonService.finish(+id, req.user.userId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
cancel(@Request() req: any, @Param('id') id: string) {
|
||||
return this.lessonService.cancel(+id, req.user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/students/:studentId/record')
|
||||
saveStudentRecord(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Param('studentId') studentId: string,
|
||||
@Body() data: any
|
||||
) {
|
||||
return this.lessonService.saveStudentRecord(+id, req.user.userId, +studentId, data);
|
||||
}
|
||||
|
||||
@Get(':id/student-records')
|
||||
getStudentRecords(@Request() req: any, @Param('id') id: string) {
|
||||
return this.lessonService.getStudentRecords(+id, req.user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/student-records/batch')
|
||||
batchSaveStudentRecords(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() data: { records: Array<{ studentId: number; focus?: number; participation?: number; interest?: number; understanding?: number; notes?: string }> }
|
||||
) {
|
||||
return this.lessonService.batchSaveStudentRecords(+id, req.user.userId, data.records);
|
||||
}
|
||||
|
||||
// ==================== 课程反馈 ====================
|
||||
|
||||
@Post(':id/feedback')
|
||||
submitFeedback(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() data: any
|
||||
) {
|
||||
return this.lessonService.submitFeedback(+id, req.user.userId, data);
|
||||
}
|
||||
|
||||
@Get(':id/feedback')
|
||||
getFeedback(@Request() req: any, @Param('id') id: string) {
|
||||
return this.lessonService.getFeedback(+id, req.user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// 教师端反馈控制器
|
||||
@Controller('teacher/feedbacks')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class TeacherFeedbackController {
|
||||
constructor(private readonly lessonService: LessonService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req: any, @Query() query: any) {
|
||||
return this.lessonService.getFeedbacksByTenant(req.user.tenantId, {
|
||||
...query,
|
||||
teacherId: req.user.userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats(@Request() req: any) {
|
||||
return this.lessonService.getTeacherFeedbackStats(req.user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// 学校端反馈控制器
|
||||
@Controller('school/feedbacks')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SchoolFeedbackController {
|
||||
constructor(private readonly lessonService: LessonService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req: any, @Query() query: any) {
|
||||
return this.lessonService.getFeedbacksByTenant(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats(@Request() req: any) {
|
||||
return this.lessonService.getFeedbackStats(req.user.tenantId);
|
||||
}
|
||||
}
|
||||
10
reading-platform-backend/src/modules/lesson/lesson.module.ts
Normal file
10
reading-platform-backend/src/modules/lesson/lesson.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LessonController, SchoolFeedbackController, TeacherFeedbackController } from './lesson.controller';
|
||||
import { LessonService } from './lesson.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LessonController, SchoolFeedbackController, TeacherFeedbackController],
|
||||
providers: [LessonService],
|
||||
exports: [LessonService],
|
||||
})
|
||||
export class LessonModule {}
|
||||
905
reading-platform-backend/src/modules/lesson/lesson.service.ts
Normal file
905
reading-platform-backend/src/modules/lesson/lesson.service.ts
Normal file
@ -0,0 +1,905 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { CreateLessonDto } from './dto/create-lesson.dto';
|
||||
import { FinishLessonDto } from './dto/finish-lesson.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LessonService {
|
||||
private readonly logger = new Logger(LessonService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(teacherId: number, tenantId: number, dto: CreateLessonDto) {
|
||||
// 验证课程是否已授权
|
||||
const course = await this.prisma.course.findUnique({
|
||||
where: { id: dto.courseId },
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
throw new NotFoundException('课程不存在');
|
||||
}
|
||||
|
||||
if (course.status !== 'PUBLISHED') {
|
||||
throw new ForbiddenException('该课程未发布');
|
||||
}
|
||||
|
||||
// 检查授权
|
||||
const tenantCourse = await this.prisma.tenantCourse.findUnique({
|
||||
where: {
|
||||
tenantId_courseId: {
|
||||
tenantId: tenantId,
|
||||
courseId: dto.courseId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenantCourse || !tenantCourse.authorized) {
|
||||
throw new ForbiddenException('您的学校未获得此课程的授权');
|
||||
}
|
||||
|
||||
// 验证班级是否属于该教师
|
||||
const classEntity = await this.prisma.class.findFirst({
|
||||
where: {
|
||||
id: dto.classId,
|
||||
tenantId: tenantId,
|
||||
teacherId: teacherId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!classEntity) {
|
||||
throw new ForbiddenException('无权操作此班级');
|
||||
}
|
||||
|
||||
// 创建授课记录
|
||||
const lesson = await this.prisma.lesson.create({
|
||||
data: {
|
||||
tenantId: tenantId,
|
||||
teacherId: teacherId,
|
||||
classId: dto.classId,
|
||||
courseId: dto.courseId,
|
||||
plannedDatetime: dto.plannedDatetime ? new Date(dto.plannedDatetime) : null,
|
||||
status: 'PLANNED',
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
duration: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Lesson created: ${lesson.id} by teacher ${teacherId}`);
|
||||
|
||||
return lesson;
|
||||
}
|
||||
|
||||
async start(lessonId: number, teacherId: number) {
|
||||
// 查找授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
if (lesson.status !== 'PLANNED') {
|
||||
throw new ForbiddenException('该授课记录已开始或已完成');
|
||||
}
|
||||
|
||||
// 更新状态和开始时间
|
||||
const updatedLesson = await this.prisma.lesson.update({
|
||||
where: { id: lessonId },
|
||||
data: {
|
||||
status: 'IN_PROGRESS',
|
||||
startDatetime: new Date(),
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
duration: true,
|
||||
pptPath: true,
|
||||
pptName: true,
|
||||
ebookPaths: true,
|
||||
audioPaths: true,
|
||||
videoPaths: true,
|
||||
posterPaths: true,
|
||||
scripts: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
pages: {
|
||||
orderBy: { pageNumber: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
activities: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
students: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Lesson started: ${lessonId}`);
|
||||
|
||||
// 解析 JSON 字段
|
||||
return {
|
||||
...updatedLesson,
|
||||
course: {
|
||||
...updatedLesson.course,
|
||||
ebookPaths: updatedLesson.course.ebookPaths ? JSON.parse(updatedLesson.course.ebookPaths) : [],
|
||||
audioPaths: updatedLesson.course.audioPaths ? JSON.parse(updatedLesson.course.audioPaths) : [],
|
||||
videoPaths: updatedLesson.course.videoPaths ? JSON.parse(updatedLesson.course.videoPaths) : [],
|
||||
posterPaths: updatedLesson.course.posterPaths ? JSON.parse(updatedLesson.course.posterPaths) : [],
|
||||
scripts: updatedLesson.course.scripts.map((script) => ({
|
||||
...script,
|
||||
interactionPoints: script.interactionPoints ? JSON.parse(script.interactionPoints) : null,
|
||||
resourceIds: script.resourceIds ? JSON.parse(script.resourceIds) : null,
|
||||
pages: script.pages?.map((page) => ({
|
||||
...page,
|
||||
resourceIds: page.resourceIds ? JSON.parse(page.resourceIds) : null,
|
||||
})),
|
||||
})),
|
||||
activities: updatedLesson.course.activities.map((activity) => ({
|
||||
...activity,
|
||||
onlineMaterials: activity.onlineMaterials ? JSON.parse(activity.onlineMaterials) : null,
|
||||
objectives: activity.objectives ? JSON.parse(activity.objectives) : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async finish(lessonId: number, teacherId: number, dto: FinishLessonDto) {
|
||||
// 查找授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
if (lesson.status !== 'IN_PROGRESS') {
|
||||
throw new ForbiddenException('该授课记录未开始或已完成');
|
||||
}
|
||||
|
||||
// 计算实际时长
|
||||
let actualDuration = dto.actualDuration;
|
||||
if (!actualDuration && lesson.startDatetime) {
|
||||
const endTime = new Date();
|
||||
actualDuration = Math.round((endTime.getTime() - lesson.startDatetime.getTime()) / 60000);
|
||||
}
|
||||
|
||||
// 更新状态和结束时间
|
||||
const updatedLesson = await this.prisma.lesson.update({
|
||||
where: { id: lessonId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
endDatetime: new Date(),
|
||||
actualDuration: actualDuration,
|
||||
overallRating: dto.overallRating,
|
||||
participationRating: dto.participationRating,
|
||||
completionNote: dto.completionNote,
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新课程使用统计
|
||||
await this.prisma.course.update({
|
||||
where: { id: lesson.courseId },
|
||||
data: {
|
||||
usageCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// 更新教师授课次数
|
||||
await this.prisma.teacher.update({
|
||||
where: { id: teacherId },
|
||||
data: {
|
||||
lessonCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Lesson finished: ${lessonId}, duration: ${actualDuration} minutes`);
|
||||
|
||||
return updatedLesson;
|
||||
}
|
||||
|
||||
async cancel(lessonId: number, teacherId: number) {
|
||||
// 查找授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
// 只有已计划状态的课程可以取消
|
||||
if (lesson.status !== 'PLANNED') {
|
||||
throw new ForbiddenException('只有已计划的课程可以取消');
|
||||
}
|
||||
|
||||
// 更新状态为已取消
|
||||
const updatedLesson = await this.prisma.lesson.update({
|
||||
where: { id: lessonId },
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Lesson cancelled: ${lessonId}`);
|
||||
|
||||
return updatedLesson;
|
||||
}
|
||||
|
||||
async findOne(lessonId: number, teacherId: number) {
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
duration: true,
|
||||
pptPath: true,
|
||||
pptName: true,
|
||||
ebookPaths: true,
|
||||
audioPaths: true,
|
||||
videoPaths: true,
|
||||
posterPaths: true,
|
||||
scripts: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
pages: {
|
||||
orderBy: { pageNumber: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
activities: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
students: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权查看此授课记录');
|
||||
}
|
||||
|
||||
// 解析 JSON 字段
|
||||
return {
|
||||
...lesson,
|
||||
course: {
|
||||
...lesson.course,
|
||||
ebookPaths: lesson.course.ebookPaths ? JSON.parse(lesson.course.ebookPaths) : [],
|
||||
audioPaths: lesson.course.audioPaths ? JSON.parse(lesson.course.audioPaths) : [],
|
||||
videoPaths: lesson.course.videoPaths ? JSON.parse(lesson.course.videoPaths) : [],
|
||||
posterPaths: lesson.course.posterPaths ? JSON.parse(lesson.course.posterPaths) : [],
|
||||
scripts: lesson.course.scripts.map((script) => ({
|
||||
...script,
|
||||
interactionPoints: script.interactionPoints ? JSON.parse(script.interactionPoints) : null,
|
||||
resourceIds: script.resourceIds ? JSON.parse(script.resourceIds) : null,
|
||||
pages: script.pages?.map((page) => ({
|
||||
...page,
|
||||
resourceIds: page.resourceIds ? JSON.parse(page.resourceIds) : null,
|
||||
})),
|
||||
})),
|
||||
activities: lesson.course.activities.map((activity) => ({
|
||||
...activity,
|
||||
onlineMaterials: activity.onlineMaterials ? JSON.parse(activity.onlineMaterials) : null,
|
||||
objectives: activity.objectives ? JSON.parse(activity.objectives) : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findByTeacher(teacherId: number, query: any) {
|
||||
const { page = 1, pageSize = 10, status, courseId } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {
|
||||
teacherId: teacherId,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
where.courseId = +courseId;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.lesson.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.lesson.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async saveStudentRecord(
|
||||
lessonId: number,
|
||||
teacherId: number,
|
||||
studentId: number,
|
||||
data: {
|
||||
focus?: number;
|
||||
participation?: number;
|
||||
interest?: number;
|
||||
understanding?: number;
|
||||
notes?: string;
|
||||
}
|
||||
) {
|
||||
// 验证授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
// 验证学生是否在班级中
|
||||
const student = await this.prisma.student.findFirst({
|
||||
where: {
|
||||
id: studentId,
|
||||
classId: lesson.classId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
throw new NotFoundException('学生不存在或不在此班级');
|
||||
}
|
||||
|
||||
// 创建或更新学生记录
|
||||
const record = await this.prisma.studentRecord.upsert({
|
||||
where: {
|
||||
lessonId_studentId: {
|
||||
lessonId: lessonId,
|
||||
studentId: studentId,
|
||||
},
|
||||
},
|
||||
update: data,
|
||||
create: {
|
||||
lessonId: lessonId,
|
||||
studentId: studentId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async getStudentRecords(lessonId: number, teacherId: number) {
|
||||
// 验证授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
students: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
// 获取所有学生记录
|
||||
const records = await this.prisma.studentRecord.findMany({
|
||||
where: { lessonId },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gender: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 合并学生信息和记录
|
||||
const studentRecords = lesson.class.students.map((student) => {
|
||||
const record = records.find((r) => r.studentId === student.id);
|
||||
return {
|
||||
...student,
|
||||
record: record || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
lesson: {
|
||||
id: lesson.id,
|
||||
status: lesson.status,
|
||||
className: lesson.class.name,
|
||||
},
|
||||
students: studentRecords,
|
||||
};
|
||||
}
|
||||
|
||||
async batchSaveStudentRecords(
|
||||
lessonId: number,
|
||||
teacherId: number,
|
||||
records: Array<{
|
||||
studentId: number;
|
||||
focus?: number;
|
||||
participation?: number;
|
||||
interest?: number;
|
||||
understanding?: number;
|
||||
notes?: string;
|
||||
}>
|
||||
) {
|
||||
// 验证授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
// 批量保存记录
|
||||
const results = [];
|
||||
for (const record of records) {
|
||||
const saved = await this.prisma.studentRecord.upsert({
|
||||
where: {
|
||||
lessonId_studentId: {
|
||||
lessonId: lessonId,
|
||||
studentId: record.studentId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
focus: record.focus,
|
||||
participation: record.participation,
|
||||
interest: record.interest,
|
||||
understanding: record.understanding,
|
||||
notes: record.notes,
|
||||
},
|
||||
create: {
|
||||
lessonId: lessonId,
|
||||
studentId: record.studentId,
|
||||
focus: record.focus,
|
||||
participation: record.participation,
|
||||
interest: record.interest,
|
||||
understanding: record.understanding,
|
||||
notes: record.notes,
|
||||
},
|
||||
});
|
||||
results.push(saved);
|
||||
|
||||
// 更新学生的阅读次数(仅首次记录时)
|
||||
const existingRecord = await this.prisma.studentRecord.findFirst({
|
||||
where: {
|
||||
studentId: record.studentId,
|
||||
lessonId: { not: lessonId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
// 这是该学生第一次有记录,更新阅读次数
|
||||
await this.prisma.student.update({
|
||||
where: { id: record.studentId },
|
||||
data: { readingCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Batch saved ${results.length} student records for lesson ${lessonId}`);
|
||||
|
||||
return { count: results.length, records: results };
|
||||
}
|
||||
|
||||
// ==================== 课程反馈功能 ====================
|
||||
|
||||
async submitFeedback(
|
||||
lessonId: number,
|
||||
teacherId: number,
|
||||
data: {
|
||||
designQuality?: number;
|
||||
participation?: number;
|
||||
goalAchievement?: number;
|
||||
stepFeedbacks?: any;
|
||||
pros?: string;
|
||||
suggestions?: string;
|
||||
activitiesDone?: any;
|
||||
}
|
||||
) {
|
||||
// 验证授课记录
|
||||
const lesson = await this.prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundException('授课记录不存在');
|
||||
}
|
||||
|
||||
if (lesson.teacherId !== teacherId) {
|
||||
throw new ForbiddenException('无权操作此授课记录');
|
||||
}
|
||||
|
||||
// 创建或更新反馈
|
||||
const feedback = await this.prisma.lessonFeedback.upsert({
|
||||
where: {
|
||||
lessonId_teacherId: {
|
||||
lessonId: lessonId,
|
||||
teacherId: teacherId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
designQuality: data.designQuality,
|
||||
participation: data.participation,
|
||||
goalAchievement: data.goalAchievement,
|
||||
stepFeedbacks: data.stepFeedbacks ? JSON.stringify(data.stepFeedbacks) : null,
|
||||
pros: data.pros,
|
||||
suggestions: data.suggestions,
|
||||
activitiesDone: data.activitiesDone ? JSON.stringify(data.activitiesDone) : null,
|
||||
},
|
||||
create: {
|
||||
lessonId: lessonId,
|
||||
teacherId: teacherId,
|
||||
designQuality: data.designQuality,
|
||||
participation: data.participation,
|
||||
goalAchievement: data.goalAchievement,
|
||||
stepFeedbacks: data.stepFeedbacks ? JSON.stringify(data.stepFeedbacks) : null,
|
||||
pros: data.pros,
|
||||
suggestions: data.suggestions,
|
||||
activitiesDone: data.activitiesDone ? JSON.stringify(data.activitiesDone) : null,
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
id: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新教师反馈次数
|
||||
await this.prisma.teacher.update({
|
||||
where: { id: teacherId },
|
||||
data: {
|
||||
feedbackCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Feedback submitted for lesson: ${lessonId}`);
|
||||
|
||||
return {
|
||||
...feedback,
|
||||
stepFeedbacks: feedback.stepFeedbacks ? JSON.parse(feedback.stepFeedbacks as string) : null,
|
||||
activitiesDone: feedback.activitiesDone ? JSON.parse(feedback.activitiesDone as string) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getFeedback(lessonId: number, teacherId: number) {
|
||||
const feedback = await this.prisma.lessonFeedback.findUnique({
|
||||
where: {
|
||||
lessonId_teacherId: {
|
||||
lessonId: lessonId,
|
||||
teacherId: teacherId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
id: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!feedback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...feedback,
|
||||
stepFeedbacks: feedback.stepFeedbacks ? JSON.parse(feedback.stepFeedbacks as string) : null,
|
||||
activitiesDone: feedback.activitiesDone ? JSON.parse(feedback.activitiesDone as string) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getFeedbacksByTenant(tenantId: number, query: any) {
|
||||
const { page = 1, pageSize = 10, teacherId, courseId } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {
|
||||
lesson: {
|
||||
tenantId: tenantId,
|
||||
},
|
||||
};
|
||||
|
||||
if (teacherId) {
|
||||
where.teacherId = +teacherId;
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
where.lesson = {
|
||||
...where.lesson,
|
||||
courseId: +courseId,
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.lessonFeedback.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
id: true,
|
||||
startDatetime: true,
|
||||
actualDuration: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.lessonFeedback.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
stepFeedbacks: item.stepFeedbacks ? JSON.parse(item.stepFeedbacks as string) : null,
|
||||
activitiesDone: item.activitiesDone ? JSON.parse(item.activitiesDone as string) : null,
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getFeedbackStats(tenantId: number) {
|
||||
// 获取反馈统计
|
||||
const feedbacks = await this.prisma.lessonFeedback.findMany({
|
||||
where: {
|
||||
lesson: {
|
||||
tenantId: tenantId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
designQuality: true,
|
||||
participation: true,
|
||||
goalAchievement: true,
|
||||
lesson: {
|
||||
select: {
|
||||
courseId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalFeedbacks = feedbacks.length;
|
||||
const avgDesignQuality = feedbacks.reduce((sum, f) => sum + (f.designQuality || 0), 0) / (totalFeedbacks || 1);
|
||||
const avgParticipation = feedbacks.reduce((sum, f) => sum + (f.participation || 0), 0) / (totalFeedbacks || 1);
|
||||
const avgGoalAchievement = feedbacks.reduce((sum, f) => sum + (f.goalAchievement || 0), 0) / (totalFeedbacks || 1);
|
||||
|
||||
// 按课程统计
|
||||
const courseStats: Record<number, { count: number; avgRating: number }> = {};
|
||||
feedbacks.forEach((f) => {
|
||||
const courseId = f.lesson.courseId;
|
||||
if (!courseStats[courseId]) {
|
||||
courseStats[courseId] = { count: 0, avgRating: 0 };
|
||||
}
|
||||
courseStats[courseId].count++;
|
||||
courseStats[courseId].avgRating += (f.designQuality || 0 + f.participation || 0 + f.goalAchievement || 0) / 3;
|
||||
});
|
||||
|
||||
// 计算平均值
|
||||
Object.keys(courseStats).forEach((courseId) => {
|
||||
courseStats[+courseId].avgRating /= courseStats[+courseId].count;
|
||||
});
|
||||
|
||||
return {
|
||||
totalFeedbacks,
|
||||
avgDesignQuality: Math.round(avgDesignQuality * 10) / 10,
|
||||
avgParticipation: Math.round(avgParticipation * 10) / 10,
|
||||
avgGoalAchievement: Math.round(avgGoalAchievement * 10) / 10,
|
||||
courseStats,
|
||||
};
|
||||
}
|
||||
|
||||
async getTeacherFeedbackStats(teacherId: number) {
|
||||
const feedbacks = await this.prisma.lessonFeedback.findMany({
|
||||
where: { teacherId },
|
||||
select: {
|
||||
designQuality: true,
|
||||
participation: true,
|
||||
goalAchievement: true,
|
||||
lesson: {
|
||||
select: {
|
||||
courseId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalFeedbacks = feedbacks.length;
|
||||
const avgDesignQuality = feedbacks.reduce((sum, f) => sum + (f.designQuality || 0), 0) / (totalFeedbacks || 1);
|
||||
const avgParticipation = feedbacks.reduce((sum, f) => sum + (f.participation || 0), 0) / (totalFeedbacks || 1);
|
||||
const avgGoalAchievement = feedbacks.reduce((sum, f) => sum + (f.goalAchievement || 0), 0) / (totalFeedbacks || 1);
|
||||
|
||||
return {
|
||||
totalFeedbacks,
|
||||
avgDesignQuality: Math.round(avgDesignQuality * 10) / 10,
|
||||
avgParticipation: Math.round(avgParticipation * 10) / 10,
|
||||
avgGoalAchievement: Math.round(avgGoalAchievement * 10) / 10,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
// 学校端通知控制器
|
||||
@Controller('school/notifications')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SchoolNotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
@Get()
|
||||
getNotifications(@Request() req: any, @Query() query: any) {
|
||||
return this.notificationService.getNotifications(
|
||||
req.user.tenantId,
|
||||
'SCHOOL',
|
||||
req.user.userId,
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
getUnreadCount(@Request() req: any) {
|
||||
return this.notificationService.getUnreadCount(
|
||||
req.user.tenantId,
|
||||
'SCHOOL',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id/read')
|
||||
markAsRead(@Request() req: any, @Param('id') id: string) {
|
||||
return this.notificationService.markAsRead(
|
||||
req.user.tenantId,
|
||||
+id,
|
||||
'SCHOOL',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put('read-all')
|
||||
markAllAsRead(@Request() req: any) {
|
||||
return this.notificationService.markAllAsRead(
|
||||
req.user.tenantId,
|
||||
'SCHOOL',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 教师端通知控制器
|
||||
@Controller('teacher/notifications')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class TeacherNotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
@Get()
|
||||
getNotifications(@Request() req: any, @Query() query: any) {
|
||||
return this.notificationService.getNotifications(
|
||||
req.user.tenantId,
|
||||
'TEACHER',
|
||||
req.user.userId,
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
getUnreadCount(@Request() req: any) {
|
||||
return this.notificationService.getUnreadCount(
|
||||
req.user.tenantId,
|
||||
'TEACHER',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id/read')
|
||||
markAsRead(@Request() req: any, @Param('id') id: string) {
|
||||
return this.notificationService.markAsRead(
|
||||
req.user.tenantId,
|
||||
+id,
|
||||
'TEACHER',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put('read-all')
|
||||
markAllAsRead(@Request() req: any) {
|
||||
return this.notificationService.markAllAsRead(
|
||||
req.user.tenantId,
|
||||
'TEACHER',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 家长端通知控制器
|
||||
@Controller('parent/notifications')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('parent')
|
||||
export class ParentNotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
@Get()
|
||||
getNotifications(@Request() req: any, @Query() query: any) {
|
||||
return this.notificationService.getNotifications(
|
||||
req.user.tenantId,
|
||||
'PARENT',
|
||||
req.user.userId,
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
getUnreadCount(@Request() req: any) {
|
||||
return this.notificationService.getUnreadCount(
|
||||
req.user.tenantId,
|
||||
'PARENT',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id/read')
|
||||
markAsRead(@Request() req: any, @Param('id') id: string) {
|
||||
return this.notificationService.markAsRead(
|
||||
req.user.tenantId,
|
||||
+id,
|
||||
'PARENT',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
|
||||
@Put('read-all')
|
||||
markAllAsRead(@Request() req: any) {
|
||||
return this.notificationService.markAllAsRead(
|
||||
req.user.tenantId,
|
||||
'PARENT',
|
||||
req.user.userId
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import {
|
||||
SchoolNotificationController,
|
||||
TeacherNotificationController,
|
||||
ParentNotificationController,
|
||||
} from './notification.controller';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { ScheduleNotificationService } from './schedule-notification.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [
|
||||
SchoolNotificationController,
|
||||
TeacherNotificationController,
|
||||
ParentNotificationController,
|
||||
],
|
||||
providers: [NotificationService, ScheduleNotificationService],
|
||||
exports: [NotificationService, ScheduleNotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@ -0,0 +1,169 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 创建通知 ====================
|
||||
|
||||
async createNotification(data: {
|
||||
tenantId: number;
|
||||
recipientType: 'TEACHER' | 'SCHOOL' | 'PARENT';
|
||||
recipientId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
notificationType: 'SYSTEM' | 'TASK' | 'LESSON' | 'GROWTH';
|
||||
relatedType?: string;
|
||||
relatedId?: number;
|
||||
}) {
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
recipientType: data.recipientType,
|
||||
recipientId: data.recipientId,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
notificationType: data.notificationType,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Notification created: ${notification.id} for ${data.recipientType}:${data.recipientId}`
|
||||
);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
// 批量创建通知
|
||||
async createBatchNotifications(
|
||||
notifications: Array<{
|
||||
tenantId: number;
|
||||
recipientType: 'TEACHER' | 'SCHOOL' | 'PARENT';
|
||||
recipientId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
notificationType: 'SYSTEM' | 'TASK' | 'LESSON' | 'GROWTH';
|
||||
relatedType?: string;
|
||||
relatedId?: number;
|
||||
}>
|
||||
) {
|
||||
const results = await this.prisma.notification.createMany({
|
||||
data: notifications,
|
||||
});
|
||||
|
||||
this.logger.log(`Batch notifications created: ${results.count}`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ==================== 获取通知 ====================
|
||||
|
||||
async getNotifications(
|
||||
tenantId: number,
|
||||
recipientType: string,
|
||||
recipientId: number,
|
||||
query: any
|
||||
) {
|
||||
const { page = 1, pageSize = 20, isRead, notificationType } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {
|
||||
tenantId,
|
||||
recipientType,
|
||||
recipientId,
|
||||
};
|
||||
|
||||
if (isRead !== undefined) {
|
||||
where.isRead = isRead === 'true';
|
||||
}
|
||||
|
||||
if (notificationType) {
|
||||
where.notificationType = notificationType;
|
||||
}
|
||||
|
||||
const [items, total, unreadCount] = await Promise.all([
|
||||
this.prisma.notification.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.notification.count({ where }),
|
||||
this.prisma.notification.count({
|
||||
where: { ...where, isRead: false },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
unreadCount,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getUnreadCount(tenantId: number, recipientType: string, recipientId: number) {
|
||||
return this.prisma.notification.count({
|
||||
where: {
|
||||
tenantId,
|
||||
recipientType,
|
||||
recipientId,
|
||||
isRead: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 标记已读 ====================
|
||||
|
||||
async markAsRead(tenantId: number, notificationId: number, recipientType: string, recipientId: number) {
|
||||
const notification = await this.prisma.notification.findFirst({
|
||||
where: {
|
||||
id: notificationId,
|
||||
tenantId,
|
||||
recipientType,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.prisma.notification.update({
|
||||
where: { id: notificationId },
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markAllAsRead(tenantId: number, recipientType: string, recipientId: number) {
|
||||
const result = await this.prisma.notification.updateMany({
|
||||
where: {
|
||||
tenantId,
|
||||
recipientType,
|
||||
recipientId,
|
||||
isRead: false,
|
||||
},
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Marked ${result.count} notifications as read for ${recipientType}:${recipientId}`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleNotificationService {
|
||||
private readonly logger = new Logger(ScheduleNotificationService.name);
|
||||
private isProcessing = false;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 每30分钟检查一次即将开始的课程
|
||||
* 发送提醒给相关教师
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_30_MINUTES)
|
||||
async handleUpcomingScheduleReminders() {
|
||||
if (this.isProcessing) {
|
||||
this.logger.debug('Previous reminder task still processing, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 计算接下来30分钟的时间范围
|
||||
const thirtyMinutesLater = new Date(now.getTime() + 30 * 60 * 1000);
|
||||
|
||||
// 查找即将开始的排课(今天,未发送提醒)
|
||||
const upcomingSchedules = await this.prisma.schedulePlan.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
reminderSent: false,
|
||||
scheduledDate: {
|
||||
gte: new Date(now.toISOString().split('T')[0] + 'T00:00:00.000Z'),
|
||||
lt: new Date(now.toISOString().split('T')[0] + 'T23:59:59.999Z'),
|
||||
},
|
||||
teacherId: { not: null },
|
||||
},
|
||||
include: {
|
||||
teacher: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
course: {
|
||||
select: { name: true, pictureBookName: true },
|
||||
},
|
||||
class: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 过滤出即将开始的排课(根据时间段)
|
||||
const schedulesToRemind = upcomingSchedules.filter((schedule) => {
|
||||
if (!schedule.scheduledTime) return false;
|
||||
|
||||
// 解析时间 "09:00-09:30"
|
||||
const [startTime] = schedule.scheduledTime.split('-');
|
||||
if (!startTime) return false;
|
||||
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
const scheduleStartTime = new Date(now);
|
||||
scheduleStartTime.setHours(hours, minutes, 0, 0);
|
||||
|
||||
// 检查是否在接下来的30分钟内
|
||||
return scheduleStartTime >= now && scheduleStartTime <= thirtyMinutesLater;
|
||||
});
|
||||
|
||||
this.logger.log(`Found ${schedulesToRemind.length} schedules to send reminders for`);
|
||||
|
||||
// 发送提醒
|
||||
for (const schedule of schedulesToRemind) {
|
||||
try {
|
||||
await this.sendScheduleReminder(schedule);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send reminder for schedule ${schedule.id}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error in schedule reminder task:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单个排课提醒
|
||||
*/
|
||||
private async sendScheduleReminder(schedule: any) {
|
||||
if (!schedule.teacherId || !schedule.teacher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const courseName = schedule.course?.pictureBookName || schedule.course?.name || '课程';
|
||||
const className = schedule.class?.name || '班级';
|
||||
const timeStr = schedule.scheduledTime || '时间待定';
|
||||
|
||||
// 创建通知
|
||||
await this.notificationService.createNotification({
|
||||
tenantId: schedule.tenantId,
|
||||
recipientType: 'TEACHER',
|
||||
recipientId: schedule.teacherId,
|
||||
title: '课程提醒',
|
||||
content: `您即将在 ${timeStr} 为「${className}」讲授《${courseName}》,请做好准备。`,
|
||||
notificationType: 'LESSON',
|
||||
relatedType: 'SchedulePlan',
|
||||
relatedId: schedule.id,
|
||||
});
|
||||
|
||||
// 标记已发送提醒
|
||||
await this.prisma.schedulePlan.update({
|
||||
where: { id: schedule.id },
|
||||
data: {
|
||||
reminderSent: true,
|
||||
reminderSentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Reminder sent for schedule ${schedule.id} to teacher ${schedule.teacherId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发提醒检查(用于测试)
|
||||
*/
|
||||
async triggerReminderCheck() {
|
||||
this.logger.log('Manual trigger of reminder check');
|
||||
await this.handleUpcomingScheduleReminders();
|
||||
return { message: 'Reminder check triggered' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有提醒状态(用于测试或重置)
|
||||
*/
|
||||
async resetAllReminders() {
|
||||
const result = await this.prisma.schedulePlan.updateMany({
|
||||
where: {
|
||||
reminderSent: true,
|
||||
},
|
||||
data: {
|
||||
reminderSent: false,
|
||||
reminderSentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Reset ${result.count} schedule reminders`);
|
||||
|
||||
return { message: `Reset ${result.count} reminders` };
|
||||
}
|
||||
|
||||
// ==================== 任务提醒 ====================
|
||||
|
||||
/**
|
||||
* 每天早上9点检查即将到期的任务
|
||||
* 发送提醒给家长
|
||||
*/
|
||||
@Cron('0 9 * * *') // 每天早上9点
|
||||
async handleTaskDeadlineReminders() {
|
||||
this.logger.log('Starting task deadline reminder check...');
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const threeDaysLater = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
const oneDayLater = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// 查找即将到期且未完成的任务
|
||||
const tasksToRemind = await this.prisma.task.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
endDate: {
|
||||
gte: now,
|
||||
lte: threeDaysLater,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: { name: true, pictureBookName: true },
|
||||
},
|
||||
completions: {
|
||||
where: {
|
||||
status: { not: 'COMPLETED' },
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parents: {
|
||||
include: {
|
||||
parent: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Found ${tasksToRemind.length} tasks with upcoming deadlines`);
|
||||
|
||||
let reminderCount = 0;
|
||||
|
||||
for (const task of tasksToRemind) {
|
||||
const daysRemaining = Math.ceil(
|
||||
(task.endDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
// 只在截止前3天、1天、当天发送提醒
|
||||
if (![3, 1, 0].includes(daysRemaining)) continue;
|
||||
|
||||
const taskName = task.course?.pictureBookName || task.course?.name || task.title;
|
||||
|
||||
for (const completion of task.completions) {
|
||||
// 发送提醒给每个学生的家长
|
||||
for (const parentRelation of completion.student.parents) {
|
||||
try {
|
||||
await this.notificationService.createNotification({
|
||||
tenantId: task.tenantId,
|
||||
recipientType: 'PARENT',
|
||||
recipientId: parentRelation.parent.id,
|
||||
title: '任务即将截止',
|
||||
content: `「${completion.student.name}」的任务《${taskName}》将在${daysRemaining === 0 ? '今天' : daysRemaining + '天后'}截止,请尽快完成。`,
|
||||
notificationType: 'TASK',
|
||||
relatedType: 'Task',
|
||||
relatedId: task.id,
|
||||
});
|
||||
reminderCount++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send task reminder to parent ${parentRelation.parent.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Sent ${reminderCount} task reminder notifications`);
|
||||
} catch (error) {
|
||||
this.logger.error('Error in task deadline reminder task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动发送任务提醒(教师点击"发送提醒"按钮时调用)
|
||||
*/
|
||||
async sendManualTaskReminder(tenantId: number, taskId: number) {
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: { id: taskId, tenantId },
|
||||
include: {
|
||||
course: {
|
||||
select: { name: true, pictureBookName: true },
|
||||
},
|
||||
completions: {
|
||||
where: {
|
||||
status: { not: 'COMPLETED' },
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parents: {
|
||||
include: {
|
||||
parent: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return { success: false, message: '任务不存在' };
|
||||
}
|
||||
|
||||
const taskName = task.course?.pictureBookName || task.course?.name || task.title;
|
||||
let reminderCount = 0;
|
||||
const remindedStudents: { id: number; name: string }[] = [];
|
||||
|
||||
for (const completion of task.completions) {
|
||||
for (const parentRelation of completion.student.parents) {
|
||||
try {
|
||||
await this.notificationService.createNotification({
|
||||
tenantId: task.tenantId,
|
||||
recipientType: 'PARENT',
|
||||
recipientId: parentRelation.parent.id,
|
||||
title: '阅读任务提醒',
|
||||
content: `老师提醒您:「${completion.student.name}」的任务《${taskName}》尚未完成,请督促孩子尽快完成阅读任务。`,
|
||||
notificationType: 'TASK',
|
||||
relatedType: 'Task',
|
||||
relatedId: task.id,
|
||||
});
|
||||
reminderCount++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send manual task reminder to parent ${parentRelation.parent.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
remindedStudents.push({
|
||||
id: completion.student.id,
|
||||
name: completion.student.name,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Manual task reminder sent for task ${taskId} to ${reminderCount} parents`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已发送${reminderCount}条提醒`,
|
||||
remindedCount: reminderCount,
|
||||
students: remindedStudents,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ParentService } from './parent.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('parent')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('parent')
|
||||
export class ParentController {
|
||||
constructor(private readonly parentService: ParentService) {}
|
||||
|
||||
// ==================== 孩子信息 ====================
|
||||
|
||||
@Get('children')
|
||||
getChildren(@Request() req: any) {
|
||||
return this.parentService.getChildren(req.user.userId, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('children/:id')
|
||||
getChildProfile(@Request() req: any, @Param('id') id: string) {
|
||||
return this.parentService.getChildProfile(req.user.userId, +id, req.user.tenantId);
|
||||
}
|
||||
|
||||
// ==================== 阅读记录 ====================
|
||||
|
||||
@Get('children/:id/lessons')
|
||||
getChildLessons(@Request() req: any, @Param('id') id: string, @Query() query: any) {
|
||||
return this.parentService.getChildLessons(req.user.userId, +id, req.user.tenantId, query);
|
||||
}
|
||||
|
||||
// ==================== 任务 ====================
|
||||
|
||||
@Get('children/:id/tasks')
|
||||
getChildTasks(@Request() req: any, @Param('id') id: string, @Query() query: any) {
|
||||
return this.parentService.getChildTasks(req.user.userId, +id, req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Put('children/:studentId/tasks/:taskId/feedback')
|
||||
submitTaskFeedback(
|
||||
@Request() req: any,
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() body: { feedback: string },
|
||||
) {
|
||||
return this.parentService.submitTaskFeedback(
|
||||
req.user.userId,
|
||||
+studentId,
|
||||
+taskId,
|
||||
req.user.tenantId,
|
||||
body.feedback,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 成长档案 ====================
|
||||
|
||||
@Get('children/:id/growth-records')
|
||||
getChildGrowthRecords(@Request() req: any, @Param('id') id: string, @Query() query: any) {
|
||||
return this.parentService.getChildGrowthRecords(req.user.userId, +id, req.user.tenantId, query);
|
||||
}
|
||||
}
|
||||
10
reading-platform-backend/src/modules/parent/parent.module.ts
Normal file
10
reading-platform-backend/src/modules/parent/parent.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ParentController } from './parent.controller';
|
||||
import { ParentService } from './parent.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ParentController],
|
||||
providers: [ParentService],
|
||||
exports: [ParentService],
|
||||
})
|
||||
export class ParentModule {}
|
||||
309
reading-platform-backend/src/modules/parent/parent.service.ts
Normal file
309
reading-platform-backend/src/modules/parent/parent.service.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ParentService {
|
||||
private readonly logger = new Logger(ParentService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 孩子信息 ====================
|
||||
|
||||
async getChildren(parentId: number, tenantId: number) {
|
||||
const parent = await this.prisma.parent.findFirst({
|
||||
where: { id: parentId, tenantId },
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
student: {
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new NotFoundException('家长不存在');
|
||||
}
|
||||
|
||||
return parent.children.map((c) => ({
|
||||
id: c.student.id,
|
||||
name: c.student.name,
|
||||
gender: c.student.gender,
|
||||
birthDate: c.student.birthDate,
|
||||
relationship: c.relationship,
|
||||
class: c.student.class,
|
||||
readingCount: c.student.readingCount,
|
||||
lessonCount: c.student.lessonCount,
|
||||
}));
|
||||
}
|
||||
|
||||
async getChildProfile(parentId: number, studentId: number, tenantId: number) {
|
||||
// 验证亲子关系
|
||||
const relation = await this.prisma.parentStudent.findFirst({
|
||||
where: { parentId, studentId },
|
||||
});
|
||||
|
||||
if (!relation) {
|
||||
throw new ForbiddenException('您没有查看该学生信息的权限');
|
||||
}
|
||||
|
||||
const student = await this.prisma.student.findFirst({
|
||||
where: { id: studentId, tenantId },
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
throw new NotFoundException('学生不存在');
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const [lessonRecords, growthRecords, taskCompletions] = await Promise.all([
|
||||
this.prisma.studentRecord.count({
|
||||
where: { studentId },
|
||||
}),
|
||||
this.prisma.growthRecord.count({
|
||||
where: { studentId },
|
||||
}),
|
||||
this.prisma.taskCompletion.count({
|
||||
where: {
|
||||
studentId,
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...student,
|
||||
stats: {
|
||||
lessonRecords,
|
||||
growthRecords,
|
||||
taskCompletions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 阅读记录 ====================
|
||||
|
||||
async getChildLessons(parentId: number, studentId: number, tenantId: number, query: any) {
|
||||
// 验证亲子关系
|
||||
const relation = await this.prisma.parentStudent.findFirst({
|
||||
where: { parentId, studentId },
|
||||
});
|
||||
|
||||
if (!relation) {
|
||||
throw new ForbiddenException('您没有查看该学生信息的权限');
|
||||
}
|
||||
|
||||
const { page = 1, pageSize = 10 } = query;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where = { studentId };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.studentRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
id: true,
|
||||
startDatetime: true,
|
||||
endDatetime: true,
|
||||
actualDuration: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.studentRecord.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 任务列表 ====================
|
||||
|
||||
async getChildTasks(parentId: number, studentId: number, tenantId: number, query: any) {
|
||||
// 验证亲子关系
|
||||
const relation = await this.prisma.parentStudent.findFirst({
|
||||
where: { parentId, studentId },
|
||||
});
|
||||
|
||||
if (!relation) {
|
||||
throw new ForbiddenException('您没有查看该学生信息的权限');
|
||||
}
|
||||
|
||||
const { page = 1, pageSize = 10, status } = query;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {
|
||||
studentId,
|
||||
task: { tenantId, status: 'PUBLISHED' },
|
||||
};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.taskCompletion.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { task: { createdAt: 'desc' } },
|
||||
include: {
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
taskType: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.taskCompletion.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// 提交家长反馈
|
||||
async submitTaskFeedback(
|
||||
parentId: number,
|
||||
studentId: number,
|
||||
taskId: number,
|
||||
tenantId: number,
|
||||
feedback: string,
|
||||
) {
|
||||
// 验证亲子关系
|
||||
const relation = await this.prisma.parentStudent.findFirst({
|
||||
where: { parentId, studentId },
|
||||
});
|
||||
|
||||
if (!relation) {
|
||||
throw new ForbiddenException('您没有操作该学生信息的权限');
|
||||
}
|
||||
|
||||
const completion = await this.prisma.taskCompletion.findFirst({
|
||||
where: {
|
||||
taskId,
|
||||
studentId,
|
||||
task: { tenantId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!completion) {
|
||||
throw new NotFoundException('任务记录不存在');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.taskCompletion.update({
|
||||
where: {
|
||||
taskId_studentId: { taskId, studentId },
|
||||
},
|
||||
data: {
|
||||
parentFeedback: feedback,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Parent feedback submitted: task=${taskId}, student=${studentId}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ==================== 成长档案 ====================
|
||||
|
||||
async getChildGrowthRecords(parentId: number, studentId: number, tenantId: number, query: any) {
|
||||
// 验证亲子关系
|
||||
const relation = await this.prisma.parentStudent.findFirst({
|
||||
where: { parentId, studentId },
|
||||
});
|
||||
|
||||
if (!relation) {
|
||||
throw new ForbiddenException('您没有查看该学生信息的权限');
|
||||
}
|
||||
|
||||
const { page = 1, pageSize = 10 } = query;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where = {
|
||||
studentId,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.growthRecord.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { recordDate: 'desc' },
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.growthRecord.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
images: item.images ? JSON.parse(item.images) : [],
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, IsArray, IsEnum, Min } from 'class-validator';
|
||||
|
||||
export enum LibraryType {
|
||||
PICTURE_BOOK = 'PICTURE_BOOK',
|
||||
MATERIAL = 'MATERIAL',
|
||||
TEMPLATE = 'TEMPLATE',
|
||||
}
|
||||
|
||||
export enum FileType {
|
||||
IMAGE = 'IMAGE',
|
||||
PDF = 'PDF',
|
||||
VIDEO = 'VIDEO',
|
||||
AUDIO = 'AUDIO',
|
||||
PPT = 'PPT',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
export class CreateLibraryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '资源库名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@IsEnum(LibraryType)
|
||||
libraryType: LibraryType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export class UpdateLibraryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '资源库名称不能为空' })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class CreateResourceItemDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty({ message: '资源库ID不能为空' })
|
||||
libraryId: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '资源名称不能为空' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(FileType)
|
||||
fileType: FileType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '文件路径不能为空' })
|
||||
filePath: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
fileSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class UpdateResourceItemDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '资源名称不能为空' })
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class QueryLibraryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
libraryType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export class QueryResourceItemDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
libraryId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { CreateLibraryDto, UpdateLibraryDto, CreateResourceItemDto, UpdateResourceItemDto } from './dto/create-resource.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('admin/resources')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
export class ResourceController {
|
||||
constructor(private readonly resourceService: ResourceService) {}
|
||||
|
||||
// ==================== 资源库管理 ====================
|
||||
|
||||
@Get('libraries')
|
||||
findAllLibraries(@Query() query: any) {
|
||||
return this.resourceService.findAllLibraries(query);
|
||||
}
|
||||
|
||||
@Get('libraries/:id')
|
||||
findLibrary(@Param('id') id: string) {
|
||||
return this.resourceService.findLibrary(+id);
|
||||
}
|
||||
|
||||
@Post('libraries')
|
||||
createLibrary(@Body() dto: CreateLibraryDto, @Request() req: any) {
|
||||
return this.resourceService.createLibrary(dto, req.user.userId);
|
||||
}
|
||||
|
||||
@Put('libraries/:id')
|
||||
updateLibrary(@Param('id') id: string, @Body() dto: UpdateLibraryDto) {
|
||||
return this.resourceService.updateLibrary(+id, dto);
|
||||
}
|
||||
|
||||
@Delete('libraries/:id')
|
||||
deleteLibrary(@Param('id') id: string) {
|
||||
return this.resourceService.deleteLibrary(+id);
|
||||
}
|
||||
|
||||
// ==================== 资源项目管理 ====================
|
||||
|
||||
@Get('items')
|
||||
findAllItems(@Query() query: any) {
|
||||
return this.resourceService.findAllItems(query);
|
||||
}
|
||||
|
||||
@Get('items/:id')
|
||||
findItem(@Param('id') id: string) {
|
||||
return this.resourceService.findItem(+id);
|
||||
}
|
||||
|
||||
@Post('items')
|
||||
createItem(@Body() dto: CreateResourceItemDto) {
|
||||
return this.resourceService.createItem(dto);
|
||||
}
|
||||
|
||||
@Put('items/:id')
|
||||
updateItem(@Param('id') id: string, @Body() dto: UpdateResourceItemDto) {
|
||||
return this.resourceService.updateItem(+id, dto);
|
||||
}
|
||||
|
||||
@Delete('items/:id')
|
||||
deleteItem(@Param('id') id: string) {
|
||||
return this.resourceService.deleteItem(+id);
|
||||
}
|
||||
|
||||
@Post('items/batch-delete')
|
||||
batchDeleteItems(@Body() body: { ids: number[] }) {
|
||||
return this.resourceService.batchDeleteItems(body.ids);
|
||||
}
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.resourceService.getStats();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ResourceController } from './resource.controller';
|
||||
import { ResourceService } from './resource.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ResourceController],
|
||||
providers: [ResourceService],
|
||||
exports: [ResourceService],
|
||||
})
|
||||
export class ResourceModule {}
|
||||
@ -0,0 +1,357 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { CreateLibraryDto, UpdateLibraryDto, CreateResourceItemDto, UpdateResourceItemDto } from './dto/create-resource.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
private readonly logger = new Logger(ResourceService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private parseJsonArray(value: any): any[] {
|
||||
if (!value) return [];
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
// ==================== 资源库管理 ====================
|
||||
|
||||
async findAllLibraries(query: any) {
|
||||
const { page = 1, pageSize = 10, libraryType, keyword } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (libraryType) {
|
||||
where.libraryType = libraryType;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where.name = { contains: keyword };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.resourceLibrary.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.resourceLibrary.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((lib) => ({
|
||||
...lib,
|
||||
itemCount: lib._count.items,
|
||||
_count: undefined,
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findLibrary(id: number) {
|
||||
const library = await this.prisma.resourceLibrary.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
take: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!library) {
|
||||
throw new NotFoundException('资源库不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
...library,
|
||||
items: library.items.map((item) => ({
|
||||
...item,
|
||||
tags: this.parseJsonArray(item.tags),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createLibrary(dto: CreateLibraryDto, userId: number) {
|
||||
const library = await this.prisma.resourceLibrary.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
libraryType: dto.libraryType,
|
||||
description: dto.description,
|
||||
coverImage: dto.coverImage,
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Library created: ${library.id}`);
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
async updateLibrary(id: number, dto: UpdateLibraryDto) {
|
||||
const library = await this.prisma.resourceLibrary.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!library) {
|
||||
throw new NotFoundException('资源库不存在');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.resourceLibrary.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
coverImage: dto.coverImage,
|
||||
sortOrder: dto.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Library updated: ${id}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteLibrary(id: number) {
|
||||
const library = await this.prisma.resourceLibrary.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!library) {
|
||||
throw new NotFoundException('资源库不存在');
|
||||
}
|
||||
|
||||
// 删除资源库(会级联删除所有资源项目)
|
||||
await this.prisma.resourceLibrary.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
this.logger.log(`Library deleted: ${id}`);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
// ==================== 资源项目管理 ====================
|
||||
|
||||
async findAllItems(query: any) {
|
||||
const { page = 1, pageSize = 20, libraryId, fileType, keyword } = query;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = +pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (libraryId) {
|
||||
where.libraryId = +libraryId;
|
||||
}
|
||||
|
||||
if (fileType) {
|
||||
where.fileType = fileType;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ title: { contains: keyword } },
|
||||
{ description: { contains: keyword } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.resourceItem.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
library: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
libraryType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.resourceItem.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
tags: this.parseJsonArray(item.tags),
|
||||
})),
|
||||
total,
|
||||
page: +page,
|
||||
pageSize: +pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findItem(id: number) {
|
||||
const item = await this.prisma.resourceItem.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
library: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
libraryType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('资源项目不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
tags: this.parseJsonArray(item.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async createItem(dto: CreateResourceItemDto) {
|
||||
// 检查资源库是否存在
|
||||
const library = await this.prisma.resourceLibrary.findUnique({
|
||||
where: { id: dto.libraryId },
|
||||
});
|
||||
|
||||
if (!library) {
|
||||
throw new NotFoundException('资源库不存在');
|
||||
}
|
||||
|
||||
const item = await this.prisma.resourceItem.create({
|
||||
data: {
|
||||
libraryId: dto.libraryId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
fileType: dto.fileType,
|
||||
filePath: dto.filePath,
|
||||
fileSize: dto.fileSize,
|
||||
tags: JSON.stringify(dto.tags || []),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Resource item created: ${item.id}`);
|
||||
|
||||
return {
|
||||
...item,
|
||||
tags: this.parseJsonArray(item.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async updateItem(id: number, dto: UpdateResourceItemDto) {
|
||||
const item = await this.prisma.resourceItem.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('资源项目不存在');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.resourceItem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
tags: dto.tags ? JSON.stringify(dto.tags) : undefined,
|
||||
sortOrder: dto.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Resource item updated: ${id}`);
|
||||
|
||||
return {
|
||||
...updated,
|
||||
tags: this.parseJsonArray(updated.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteItem(id: number) {
|
||||
const item = await this.prisma.resourceItem.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('资源项目不存在');
|
||||
}
|
||||
|
||||
await this.prisma.resourceItem.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
this.logger.log(`Resource item deleted: ${id}`);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async batchDeleteItems(ids: number[]) {
|
||||
await this.prisma.resourceItem.deleteMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Batch deleted ${ids.length} resource items`);
|
||||
|
||||
return { message: `成功删除 ${ids.length} 个资源` };
|
||||
}
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
async getStats() {
|
||||
const [totalLibraries, totalItems, itemsByType, itemsByLibraryType] = await Promise.all([
|
||||
this.prisma.resourceLibrary.count(),
|
||||
this.prisma.resourceItem.count(),
|
||||
this.prisma.resourceItem.groupBy({
|
||||
by: ['fileType'],
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.resourceLibrary.groupBy({
|
||||
by: ['libraryType'],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalLibraries,
|
||||
totalItems,
|
||||
itemsByType: itemsByType.reduce((acc, item) => {
|
||||
acc[item.fileType] = item._count;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
itemsByLibraryType: itemsByLibraryType.reduce((acc, lib) => {
|
||||
acc[lib.libraryType] = lib._count;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { SchoolCourseService } from './school-course.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
// 学校端校本课程包控制器
|
||||
@Controller('school/school-courses')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SchoolCourseController {
|
||||
constructor(private readonly schoolCourseService: SchoolCourseService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: any) {
|
||||
return this.schoolCourseService.findAll(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('source-courses')
|
||||
async getSourceCourses(@Request() req: any) {
|
||||
return this.schoolCourseService.getSourceCourses(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
return this.schoolCourseService.findOne(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
sourceCourseId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.create(
|
||||
req.user.tenantId,
|
||||
body.sourceCourseId,
|
||||
req.user.id,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.update(id, req.user.tenantId, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
return this.schoolCourseService.delete(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
// 课程管理
|
||||
@Get(':id/lessons')
|
||||
async findLessons(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.schoolCourseService.findLessons(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Put(':id/lessons/:lessonId')
|
||||
async updateLesson(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('lessonId', ParseIntPipe) lessonId: number,
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
changeNote?: string;
|
||||
stepsData?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.updateLesson(id, lessonId, req.user.tenantId, body);
|
||||
}
|
||||
|
||||
// 预约管理
|
||||
@Get(':id/reservations')
|
||||
async findReservations(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.schoolCourseService.findReservations(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post(':id/reservations')
|
||||
async createReservation(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
teacherId: number;
|
||||
classId: number;
|
||||
scheduledDate: string;
|
||||
scheduledTime?: string;
|
||||
note?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.createReservation(id, req.user.tenantId, body);
|
||||
}
|
||||
|
||||
@Post('reservations/:reservationId/cancel')
|
||||
async cancelReservation(
|
||||
@Param('reservationId', ParseIntPipe) reservationId: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.schoolCourseService.cancelReservation(reservationId, req.user.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// 教师端校本课程包控制器
|
||||
@Controller('teacher/school-courses')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('teacher')
|
||||
export class TeacherSchoolCourseController {
|
||||
constructor(private readonly schoolCourseService: SchoolCourseService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: any) {
|
||||
return this.schoolCourseService.findAll(req.user.tenantId, req.user.id);
|
||||
}
|
||||
|
||||
@Get('source-courses')
|
||||
async getSourceCourses(@Request() req: any) {
|
||||
return this.schoolCourseService.getSourceCourses(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
return this.schoolCourseService.findOne(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
sourceCourseId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.create(
|
||||
req.user.tenantId,
|
||||
body.sourceCourseId,
|
||||
req.user.id,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.update(id, req.user.tenantId, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
return this.schoolCourseService.delete(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
// 课程管理
|
||||
@Get(':id/lessons')
|
||||
async findLessons(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.schoolCourseService.findLessons(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Put(':id/lessons/:lessonId')
|
||||
async updateLesson(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('lessonId', ParseIntPipe) lessonId: number,
|
||||
@Request() req: any,
|
||||
@Body()
|
||||
body: {
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
changeNote?: string;
|
||||
stepsData?: string;
|
||||
},
|
||||
) {
|
||||
return this.schoolCourseService.updateLesson(id, lessonId, req.user.tenantId, body);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchoolCourseController, TeacherSchoolCourseController } from './school-course.controller';
|
||||
import { SchoolCourseService } from './school-course.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SchoolCourseController, TeacherSchoolCourseController],
|
||||
providers: [SchoolCourseService],
|
||||
exports: [SchoolCourseService],
|
||||
})
|
||||
export class SchoolCourseModule {}
|
||||
@ -0,0 +1,396 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchoolCourseService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ==================== 校本课程包管理 ====================
|
||||
|
||||
async findAll(tenantId?: number, createdBy?: number) {
|
||||
const where: any = {};
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
if (createdBy) {
|
||||
where.createdBy = createdBy;
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourse.findMany({
|
||||
where,
|
||||
include: {
|
||||
sourceCourse: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { lessons: true, reservations: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number, tenantId?: number) {
|
||||
const where: any = { id };
|
||||
if (tenantId) {
|
||||
where.tenantId = tenantId;
|
||||
}
|
||||
|
||||
const schoolCourse = await this.prisma.schoolCourse.findFirst({
|
||||
where,
|
||||
include: {
|
||||
sourceCourse: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
lessons: {
|
||||
orderBy: { lessonType: 'asc' },
|
||||
},
|
||||
reservations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!schoolCourse) {
|
||||
throw new Error('校本课程包不存在');
|
||||
}
|
||||
|
||||
return schoolCourse;
|
||||
}
|
||||
|
||||
async create(
|
||||
tenantId: number,
|
||||
sourceCourseId: number,
|
||||
createdBy: number,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
},
|
||||
) {
|
||||
// 检查源课程是否存在
|
||||
const sourceCourse = await this.prisma.course.findUnique({
|
||||
where: { id: sourceCourseId },
|
||||
include: {
|
||||
courseLessons: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceCourse) {
|
||||
throw new Error('源课程包不存在');
|
||||
}
|
||||
|
||||
// 检查租户是否有权限
|
||||
const hasAccess = await this.checkTenantAccess(tenantId, sourceCourseId);
|
||||
if (!hasAccess) {
|
||||
throw new Error('无权访问该课程包');
|
||||
}
|
||||
|
||||
// 创建校本课程包
|
||||
const schoolCourse = await this.prisma.schoolCourse.create({
|
||||
data: {
|
||||
tenantId,
|
||||
sourceCourseId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdBy,
|
||||
changesSummary: data.changesSummary,
|
||||
},
|
||||
});
|
||||
|
||||
// 复制源课程的课程
|
||||
for (const lesson of sourceCourse.courseLessons) {
|
||||
await this.prisma.schoolCourseLesson.create({
|
||||
data: {
|
||||
schoolCourseId: schoolCourse.id,
|
||||
sourceLessonId: lesson.id,
|
||||
lessonType: lesson.lessonType,
|
||||
objectives: lesson.objectives,
|
||||
preparation: lesson.preparation,
|
||||
extension: lesson.extension,
|
||||
reflection: lesson.reflection,
|
||||
stepsData: JSON.stringify(lesson.steps),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOne(schoolCourse.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
tenantId: number,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
changesSummary?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
// 验证权限
|
||||
const existing = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourse.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number, tenantId: number) {
|
||||
// 验证权限
|
||||
const existing = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
// 检查是否有预约
|
||||
const reservationCount = await this.prisma.schoolCourseReservation.count({
|
||||
where: { schoolCourseId: id, status: 'PENDING' },
|
||||
});
|
||||
|
||||
if (reservationCount > 0) {
|
||||
throw new Error(`有 ${reservationCount} 个待进行的预约,无法删除`);
|
||||
}
|
||||
|
||||
await this.prisma.schoolCourse.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ==================== 校本课程管理 ====================
|
||||
|
||||
async findLessons(schoolCourseId: number, tenantId: number) {
|
||||
// 验证权限
|
||||
const schoolCourse = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id: schoolCourseId, tenantId },
|
||||
});
|
||||
|
||||
if (!schoolCourse) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourseLesson.findMany({
|
||||
where: { schoolCourseId },
|
||||
orderBy: { lessonType: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateLesson(
|
||||
schoolCourseId: number,
|
||||
lessonId: number,
|
||||
tenantId: number,
|
||||
data: {
|
||||
objectives?: string;
|
||||
preparation?: string;
|
||||
extension?: string;
|
||||
reflection?: string;
|
||||
changeNote?: string;
|
||||
stepsData?: string;
|
||||
},
|
||||
) {
|
||||
// 验证权限
|
||||
const schoolCourse = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id: schoolCourseId, tenantId },
|
||||
});
|
||||
|
||||
if (!schoolCourse) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourseLesson.update({
|
||||
where: { id: lessonId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 预约管理 ====================
|
||||
|
||||
async findReservations(schoolCourseId: number, tenantId: number) {
|
||||
// 验证权限
|
||||
const schoolCourse = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id: schoolCourseId, tenantId },
|
||||
});
|
||||
|
||||
if (!schoolCourse) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourseReservation.findMany({
|
||||
where: { schoolCourseId },
|
||||
orderBy: { scheduledDate: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createReservation(
|
||||
schoolCourseId: number,
|
||||
tenantId: number,
|
||||
data: {
|
||||
teacherId: number;
|
||||
classId: number;
|
||||
scheduledDate: string;
|
||||
scheduledTime?: string;
|
||||
note?: string;
|
||||
},
|
||||
) {
|
||||
// 验证权限
|
||||
const schoolCourse = await this.prisma.schoolCourse.findFirst({
|
||||
where: { id: schoolCourseId, tenantId },
|
||||
});
|
||||
|
||||
if (!schoolCourse) {
|
||||
throw new Error('校本课程包不存在或无权访问');
|
||||
}
|
||||
|
||||
// 验证教师和班级属于该租户
|
||||
const [teacher, class_] = await Promise.all([
|
||||
this.prisma.teacher.findFirst({
|
||||
where: { id: data.teacherId, tenantId },
|
||||
}),
|
||||
this.prisma.class.findFirst({
|
||||
where: { id: data.classId, tenantId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!teacher) {
|
||||
throw new Error('教师不存在或不属于该学校');
|
||||
}
|
||||
if (!class_) {
|
||||
throw new Error('班级不存在或不属于该学校');
|
||||
}
|
||||
|
||||
const reservation = await this.prisma.schoolCourseReservation.create({
|
||||
data: {
|
||||
schoolCourseId,
|
||||
teacherId: data.teacherId,
|
||||
classId: data.classId,
|
||||
scheduledDate: data.scheduledDate,
|
||||
scheduledTime: data.scheduledTime,
|
||||
note: data.note,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// 更新使用次数
|
||||
await this.prisma.schoolCourse.update({
|
||||
where: { id: schoolCourseId },
|
||||
data: { usageCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return reservation;
|
||||
}
|
||||
|
||||
async updateReservationStatus(
|
||||
reservationId: number,
|
||||
tenantId: number,
|
||||
status: string,
|
||||
) {
|
||||
const reservation = await this.prisma.schoolCourseReservation.findFirst({
|
||||
where: { id: reservationId },
|
||||
include: { schoolCourse: true },
|
||||
});
|
||||
|
||||
if (!reservation || reservation.schoolCourse.tenantId !== tenantId) {
|
||||
throw new Error('预约不存在或无权访问');
|
||||
}
|
||||
|
||||
return this.prisma.schoolCourseReservation.update({
|
||||
where: { id: reservationId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
async cancelReservation(reservationId: number, tenantId: number) {
|
||||
return this.updateReservationStatus(reservationId, tenantId, 'CANCELLED');
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private async checkTenantAccess(tenantId: number, courseId: number) {
|
||||
// 检查直接授权
|
||||
const tenantCourse = await this.prisma.tenantCourse.findFirst({
|
||||
where: { tenantId, courseId, authorized: true },
|
||||
});
|
||||
|
||||
if (tenantCourse) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查套餐授权
|
||||
const tenantPackage = await this.prisma.tenantPackage.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'ACTIVE',
|
||||
package: {
|
||||
courses: {
|
||||
some: { courseId },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!tenantPackage;
|
||||
}
|
||||
|
||||
// 获取可创建校本课程包的源课程列表
|
||||
async getSourceCourses(tenantId: number) {
|
||||
// 通过套餐获取课程
|
||||
const tenantPackages = await this.prisma.tenantPackage.findMany({
|
||||
where: { tenantId, status: 'ACTIVE' },
|
||||
include: {
|
||||
package: {
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
coverImagePath: true,
|
||||
description: true,
|
||||
duration: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const courses = new Map();
|
||||
for (const tp of tenantPackages) {
|
||||
for (const pc of tp.package.courses) {
|
||||
if (!courses.has(pc.course.id)) {
|
||||
courses.set(pc.course.id, pc.course);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(courses.values());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { IsInt, IsString, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
|
||||
// 教师角色类型
|
||||
export type TeacherRole = 'MAIN' | 'ASSIST' | 'CARE';
|
||||
|
||||
// 添加班级教师 DTO
|
||||
export class AddClassTeacherDto {
|
||||
@IsInt()
|
||||
teacherId: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['MAIN', 'ASSIST', 'CARE'])
|
||||
role: TeacherRole;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
// 更新班级教师 DTO
|
||||
export class UpdateClassTeacherDto {
|
||||
@IsString()
|
||||
@IsIn(['MAIN', 'ASSIST', 'CARE'])
|
||||
@IsOptional()
|
||||
role?: TeacherRole;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
// 学生调班 DTO
|
||||
export class TransferStudentDto {
|
||||
@IsInt()
|
||||
toClassId: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator';
|
||||
|
||||
export class CreateClassDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '班级名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '年级不能为空' })
|
||||
grade: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
}
|
||||
|
||||
export class UpdateClassDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '班级名称不能为空' })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '年级不能为空' })
|
||||
grade?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, Matches, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateStudentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '姓名不能为空' })
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['男', '女'], { message: '性别只能是男或女' })
|
||||
gender?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
birthDate?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty({ message: '班级不能为空' })
|
||||
classId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '请输入正确的手机号' })
|
||||
parentPhone?: string;
|
||||
}
|
||||
|
||||
export class UpdateStudentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '姓名不能为空' })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['男', '女'], { message: '性别只能是男或女' })
|
||||
gender?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
birthDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '请输入正确的手机号' })
|
||||
parentPhone?: string;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, IsArray, IsInt, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class CreateTeacherDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '姓名不能为空' })
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '请输入正确的手机号' })
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: '请输入正确的邮箱格式' })
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '登录账号不能为空' })
|
||||
loginAccount: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(6, { message: '密码至少6位' })
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
classIds?: number[];
|
||||
}
|
||||
|
||||
export class UpdateTeacherDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '姓名不能为空' })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: '请输入正确的手机号' })
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail({}, { message: '请输入正确的邮箱格式' })
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
classIds?: number[];
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsInt } from 'class-validator';
|
||||
|
||||
export class ImportStudentsDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
}
|
||||
166
reading-platform-backend/src/modules/school/dto/schedule.dto.ts
Normal file
166
reading-platform-backend/src/modules/school/dto/schedule.dto.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { IsInt, IsOptional, IsDateString, IsString, IsEnum, Min, Max, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateScheduleDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
classId: number;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
courseId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
weekDay?: number;
|
||||
|
||||
@IsEnum(['NONE', 'DAILY', 'WEEKLY'])
|
||||
repeatType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
repeatEndDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
weekDay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['NONE', 'DAILY', 'WEEKLY'])
|
||||
repeatType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
repeatEndDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class QueryScheduleDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
courseId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class TimetableQueryDto {
|
||||
@IsNotEmpty()
|
||||
@IsDateString()
|
||||
startDate: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsDateString()
|
||||
endDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
}
|
||||
|
||||
export class BatchScheduleItemDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
classId: number;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
courseId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
scheduledDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class BatchCreateScheduleDto {
|
||||
@IsNotEmpty()
|
||||
schedules: BatchScheduleItemDto[];
|
||||
}
|
||||
109
reading-platform-backend/src/modules/school/export.controller.ts
Normal file
109
reading-platform-backend/src/modules/school/export.controller.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ExportService } from './export.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('school')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class ExportController {
|
||||
constructor(private readonly exportService: ExportService) {}
|
||||
|
||||
@Get('export/lessons')
|
||||
async exportLessons(
|
||||
@Request() req: any,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Res() res?: Response,
|
||||
) {
|
||||
const buffer = await this.exportService.exportLessons(
|
||||
req.user.tenantId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
const filename = `授课记录_${this.getDateRangeFilename(startDate, endDate)}.xlsx`;
|
||||
this.sendExcelResponse(res, buffer, filename);
|
||||
}
|
||||
|
||||
@Get('export/teacher-stats')
|
||||
async exportTeacherStats(
|
||||
@Request() req: any,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Res() res?: Response,
|
||||
) {
|
||||
const buffer = await this.exportService.exportTeacherStats(
|
||||
req.user.tenantId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
const filename = `教师绩效统计_${this.getDateRangeFilename(startDate, endDate)}.xlsx`;
|
||||
this.sendExcelResponse(res, buffer, filename);
|
||||
}
|
||||
|
||||
@Get('export/student-stats')
|
||||
async exportStudentStats(
|
||||
@Request() req: any,
|
||||
@Query('classId') classId?: string,
|
||||
@Res() res?: Response,
|
||||
) {
|
||||
const buffer = await this.exportService.exportStudentStats(
|
||||
req.user.tenantId,
|
||||
classId ? parseInt(classId, 10) : undefined,
|
||||
);
|
||||
|
||||
const filename = `学生统计_${this.formatDate(new Date())}.xlsx`;
|
||||
this.sendExcelResponse(res, buffer, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Excel 响应
|
||||
*/
|
||||
private sendExcelResponse(res: Response, buffer: Buffer, filename: string) {
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日期范围文件名
|
||||
*/
|
||||
private getDateRangeFilename(startDate?: string, endDate?: string): string {
|
||||
if (startDate && endDate) {
|
||||
return `${startDate}_${endDate}`;
|
||||
} else if (startDate) {
|
||||
return `${startDate}_至今`;
|
||||
} else if (endDate) {
|
||||
return `至${endDate}`;
|
||||
}
|
||||
return this.formatDate(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
276
reading-platform-backend/src/modules/school/export.service.ts
Normal file
276
reading-platform-backend/src/modules/school/export.service.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 导出授课记录
|
||||
*/
|
||||
async exportLessons(tenantId: number, startDate?: string, endDate?: string) {
|
||||
const where: any = {
|
||||
tenantId,
|
||||
status: 'COMPLETED',
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) where.createdAt.gte = new Date(startDate);
|
||||
if (endDate) where.createdAt.lte = new Date(endDate);
|
||||
}
|
||||
|
||||
const lessons = await this.prisma.lesson.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
course: {
|
||||
select: {
|
||||
name: true,
|
||||
pictureBookName: true,
|
||||
duration: true,
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
class: {
|
||||
select: {
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 转换为 Excel 数据格式
|
||||
const data = lessons.map((lesson, index) => ({
|
||||
'序号': index + 1,
|
||||
'课程名称': lesson.course?.name || '',
|
||||
'绘本名称': lesson.course?.pictureBookName || '',
|
||||
'授课教师': lesson.teacher?.name || '',
|
||||
'班级': lesson.class?.name || '',
|
||||
'年级': lesson.class?.grade || '',
|
||||
'计划时长(分钟)': lesson.course?.duration || '',
|
||||
'实际时长(分钟)': lesson.actualDuration || '',
|
||||
'授课日期': lesson.startDatetime
|
||||
? new Date(lesson.startDatetime).toLocaleDateString('zh-CN')
|
||||
: '',
|
||||
'开始时间': lesson.startDatetime
|
||||
? new Date(lesson.startDatetime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
'结束时间': lesson.endDatetime
|
||||
? new Date(lesson.endDatetime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
'状态': this.getStatusText(lesson.status),
|
||||
'备注': lesson.completionNote || '',
|
||||
}));
|
||||
|
||||
return this.generateExcelBuffer(data, '授课记录');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出教师绩效统计
|
||||
*/
|
||||
async exportTeacherStats(tenantId: number, startDate?: string, endDate?: string) {
|
||||
// 获取所有教师
|
||||
const teachers = await this.prisma.teacher.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
lessonCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 构建查询条件
|
||||
const lessonWhere: any = {
|
||||
tenantId,
|
||||
status: 'COMPLETED',
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
lessonWhere.createdAt = {};
|
||||
if (startDate) lessonWhere.createdAt.gte = new Date(startDate);
|
||||
if (endDate) lessonWhere.createdAt.lte = new Date(endDate);
|
||||
}
|
||||
|
||||
// 获取每个教师的详细统计
|
||||
const teacherStats = await Promise.all(
|
||||
teachers.map(async (teacher) => {
|
||||
// 该时间段内的授课次数
|
||||
const periodLessons = await this.prisma.lesson.count({
|
||||
where: {
|
||||
...lessonWhere,
|
||||
teacherId: teacher.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取反馈统计
|
||||
const feedbackWhere: any = {
|
||||
teacherId: teacher.id,
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
feedbackWhere.lesson = {
|
||||
endDatetime: {},
|
||||
};
|
||||
if (startDate) feedbackWhere.lesson.endDatetime.gte = new Date(startDate);
|
||||
if (endDate) feedbackWhere.lesson.endDatetime.lte = new Date(endDate);
|
||||
}
|
||||
|
||||
const feedbacks = await this.prisma.lessonFeedback.findMany({
|
||||
where: feedbackWhere,
|
||||
select: {
|
||||
designQuality: true,
|
||||
participation: true,
|
||||
goalAchievement: true,
|
||||
},
|
||||
});
|
||||
|
||||
let avgRating = 0;
|
||||
if (feedbacks.length > 0) {
|
||||
const totalRating = feedbacks.reduce((sum, f) => {
|
||||
const ratings = [f.designQuality, f.participation, f.goalAchievement].filter((r) => r !== null);
|
||||
const avg = ratings.length > 0 ? ratings.reduce((s, r) => s + r, 0) / ratings.length : 0;
|
||||
return sum + avg;
|
||||
}, 0);
|
||||
avgRating = Math.round((totalRating / feedbacks.length) * 100) / 100;
|
||||
}
|
||||
|
||||
// 获取关联班级数
|
||||
const classCount = await this.prisma.classTeacher.count({
|
||||
where: {
|
||||
teacherId: teacher.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
'教师姓名': teacher.name,
|
||||
'联系电话': teacher.phone,
|
||||
'邮箱': teacher.email || '',
|
||||
'关联班级数': classCount,
|
||||
'累计授课次数': teacher.lessonCount,
|
||||
'本期授课次数': periodLessons,
|
||||
'平均评分': avgRating || '暂无评分',
|
||||
'入职日期': new Date(teacher.createdAt).toLocaleDateString('zh-CN'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 按本期授课次数排序
|
||||
teacherStats.sort((a, b) => (b['本期授课次数'] as number) - (a['本期授课次数'] as number));
|
||||
|
||||
// 添加排名
|
||||
const dataWithRank = teacherStats.map((item, index) => ({
|
||||
'排名': index + 1,
|
||||
...item,
|
||||
}));
|
||||
|
||||
return this.generateExcelBuffer(dataWithRank, '教师绩效');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出学生统计
|
||||
*/
|
||||
async exportStudentStats(tenantId: number, classId?: number) {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (classId) {
|
||||
where.classId = classId;
|
||||
}
|
||||
|
||||
const students = await this.prisma.student.findMany({
|
||||
where,
|
||||
include: {
|
||||
class: {
|
||||
select: {
|
||||
name: true,
|
||||
grade: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const data = students.map((student, index) => ({
|
||||
'序号': index + 1,
|
||||
'学生姓名': student.name,
|
||||
'性别': student.gender === 'MALE' ? '男' : student.gender === 'FEMALE' ? '女' : '',
|
||||
'出生日期': student.birthDate
|
||||
? new Date(student.birthDate).toLocaleDateString('zh-CN')
|
||||
: '',
|
||||
'班级': student.class?.name || '',
|
||||
'年级': student.class?.grade || '',
|
||||
'家长姓名': student.parentName || '',
|
||||
'联系电话': student.parentPhone || '',
|
||||
'参与课程数': student.lessonCount,
|
||||
'阅读记录数': student.readingCount,
|
||||
'入校日期': new Date(student.createdAt).toLocaleDateString('zh-CN'),
|
||||
}));
|
||||
|
||||
return this.generateExcelBuffer(data, '学生统计');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Excel Buffer
|
||||
*/
|
||||
private generateExcelBuffer(data: any[], sheetName: string): Buffer {
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 设置列宽
|
||||
const colWidths = this.calculateColumnWidths(data);
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算列宽
|
||||
*/
|
||||
private calculateColumnWidths(data: any[]): { wch: number }[] {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
return headers.map((header) => {
|
||||
// 计算该列的最大宽度
|
||||
let maxWidth = header.length;
|
||||
data.forEach((row) => {
|
||||
const value = String(row[header] || '');
|
||||
maxWidth = Math.max(maxWidth, value.length);
|
||||
});
|
||||
// 限制最大宽度并添加一些padding
|
||||
return { wch: Math.min(maxWidth + 2, 50) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态文本转换
|
||||
*/
|
||||
private getStatusText(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
PLANNED: '已计划',
|
||||
IN_PROGRESS: '进行中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('school')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class PackageController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get('package')
|
||||
async getPackageInfo(@Request() req: any) {
|
||||
const tenantId = req.user.tenantId;
|
||||
|
||||
const [tenant, teacherCount, studentCount] = await Promise.all([
|
||||
this.prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
packageType: true,
|
||||
teacherQuota: true,
|
||||
studentQuota: true,
|
||||
storageQuota: true,
|
||||
storageUsed: true,
|
||||
startDate: true,
|
||||
expireDate: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.teacher.count({ where: { tenantId } }),
|
||||
this.prisma.student.count({ where: { tenantId } }),
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
packageType: tenant.packageType,
|
||||
teacherQuota: tenant.teacherQuota,
|
||||
studentQuota: tenant.studentQuota,
|
||||
storageQuota: Number(tenant.storageQuota),
|
||||
teacherCount: teacherCount,
|
||||
studentCount: studentCount,
|
||||
storageUsed: Number(tenant.storageUsed),
|
||||
startDate: tenant.startDate,
|
||||
expireDate: tenant.expireDate,
|
||||
status: tenant.status,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('package/usage')
|
||||
async getPackageUsage(@Request() req: any) {
|
||||
const tenantId = req.user.tenantId;
|
||||
|
||||
const [tenant, teacherCount, studentCount] = await Promise.all([
|
||||
this.prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: {
|
||||
teacherQuota: true,
|
||||
studentQuota: true,
|
||||
storageQuota: true,
|
||||
storageUsed: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.teacher.count({ where: { tenantId } }),
|
||||
this.prisma.student.count({ where: { tenantId } }),
|
||||
]);
|
||||
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
teacher: {
|
||||
used: teacherCount,
|
||||
quota: tenant.teacherQuota,
|
||||
percentage: tenant.teacherQuota > 0 ? Math.round((teacherCount / tenant.teacherQuota) * 100) : 0,
|
||||
},
|
||||
student: {
|
||||
used: studentCount,
|
||||
quota: tenant.studentQuota,
|
||||
percentage: tenant.studentQuota > 0 ? Math.round((studentCount / tenant.studentQuota) * 100) : 0,
|
||||
},
|
||||
storage: {
|
||||
used: Number(tenant.storageUsed),
|
||||
quota: Number(tenant.storageQuota),
|
||||
percentage: Number(tenant.storageQuota) > 0 ? Math.round((Number(tenant.storageUsed) / Number(tenant.storageQuota)) * 100) : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
375
reading-platform-backend/src/modules/school/school.controller.ts
Normal file
375
reading-platform-backend/src/modules/school/school.controller.ts
Normal file
@ -0,0 +1,375 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { SchoolService } from './school.service';
|
||||
import { CreateTeacherDto, UpdateTeacherDto } from './dto/create-teacher.dto';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/create-student.dto';
|
||||
import { CreateClassDto, UpdateClassDto } from './dto/create-class.dto';
|
||||
import { AddClassTeacherDto, UpdateClassTeacherDto, TransferStudentDto } from './dto/class-teacher.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, QueryScheduleDto, TimetableQueryDto } from './dto/schedule.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { LogOperation } from '../common/decorators/log-operation.decorator';
|
||||
import { LogInterceptor } from '../common/interceptors/log.interceptor';
|
||||
|
||||
@Controller('school')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseInterceptors(LogInterceptor)
|
||||
@Roles('school')
|
||||
export class SchoolController {
|
||||
constructor(private readonly schoolService: SchoolService) {}
|
||||
|
||||
// ==================== 教师管理 ====================
|
||||
|
||||
@Get('teachers')
|
||||
findTeachers(@Request() req: any, @Query() query: any) {
|
||||
return this.schoolService.findTeachers(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('teachers/:id')
|
||||
findTeacher(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findTeacher(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('teachers')
|
||||
createTeacher(@Request() req: any, @Body() dto: CreateTeacherDto) {
|
||||
return this.schoolService.createTeacher(req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Put('teachers/:id')
|
||||
updateTeacher(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTeacherDto,
|
||||
) {
|
||||
return this.schoolService.updateTeacher(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('teachers/:id')
|
||||
deleteTeacher(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.deleteTeacher(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('teachers/:id/reset-password')
|
||||
resetTeacherPassword(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.resetTeacherPassword(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
// ==================== 学生管理 ====================
|
||||
|
||||
@Get('students')
|
||||
findStudents(@Request() req: any, @Query() query: any) {
|
||||
return this.schoolService.findStudents(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('students/:id')
|
||||
findStudent(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findStudent(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('students')
|
||||
createStudent(@Request() req: any, @Body() dto: CreateStudentDto) {
|
||||
return this.schoolService.createStudent(req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Put('students/:id')
|
||||
updateStudent(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateStudentDto,
|
||||
) {
|
||||
return this.schoolService.updateStudent(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('students/:id')
|
||||
deleteStudent(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.deleteStudent(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
// ==================== 学生调班 ====================
|
||||
|
||||
@Post('students/:id/transfer')
|
||||
transferStudent(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: TransferStudentDto,
|
||||
) {
|
||||
return this.schoolService.transferStudent(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Get('students/:id/history')
|
||||
getStudentClassHistory(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.getStudentClassHistory(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('students/import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importStudents(
|
||||
@Request() req: any,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query('defaultClassId') defaultClassId?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请上传文件');
|
||||
}
|
||||
|
||||
const studentsData = await this.schoolService.parseStudentImportFile(file);
|
||||
return this.schoolService.importStudents(
|
||||
req.user.tenantId,
|
||||
studentsData,
|
||||
defaultClassId ? +defaultClassId : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('students/import/template')
|
||||
getImportTemplate() {
|
||||
return {
|
||||
headers: ['姓名', '性别', '出生日期', '班级ID', '家长姓名', '家长电话'],
|
||||
example: ['张小明', '男', '2020-01-15', '1', '张三', '13800138000'],
|
||||
notes: [
|
||||
'姓名为必填项',
|
||||
'性别可选:男/女,默认为男',
|
||||
'出生日期格式:YYYY-MM-DD',
|
||||
'班级ID为必填项,可在班级管理中查看',
|
||||
'家长姓名和家长电话为选填项',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 班级管理 ====================
|
||||
|
||||
@Get('classes')
|
||||
findClasses(@Request() req: any) {
|
||||
return this.schoolService.findClasses(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('classes/:id')
|
||||
findClass(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findClass(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Get('classes/:id/students')
|
||||
findClassStudents(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Query() query: any,
|
||||
) {
|
||||
return this.schoolService.findClassStudents(req.user.tenantId, +id, query);
|
||||
}
|
||||
|
||||
@Post('classes')
|
||||
createClass(@Request() req: any, @Body() dto: CreateClassDto) {
|
||||
return this.schoolService.createClass(req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Put('classes/:id')
|
||||
updateClass(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClassDto,
|
||||
) {
|
||||
return this.schoolService.updateClass(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('classes/:id')
|
||||
deleteClass(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.deleteClass(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
// ==================== 班级教师管理 ====================
|
||||
|
||||
@Get('classes/:id/teachers')
|
||||
findClassTeachers(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findClassTeachers(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('classes/:id/teachers')
|
||||
addClassTeacher(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddClassTeacherDto,
|
||||
) {
|
||||
return this.schoolService.addClassTeacher(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Put('classes/:id/teachers/:teacherId')
|
||||
updateClassTeacher(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Param('teacherId') teacherId: string,
|
||||
@Body() dto: UpdateClassTeacherDto,
|
||||
) {
|
||||
return this.schoolService.updateClassTeacher(
|
||||
req.user.tenantId,
|
||||
+id,
|
||||
+teacherId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('classes/:id/teachers/:teacherId')
|
||||
removeClassTeacher(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Param('teacherId') teacherId: string,
|
||||
) {
|
||||
return this.schoolService.removeClassTeacher(req.user.tenantId, +id, +teacherId);
|
||||
}
|
||||
|
||||
// ==================== 家长管理 ====================
|
||||
|
||||
@Get('parents')
|
||||
findParents(@Request() req: any, @Query() query: any) {
|
||||
return this.schoolService.findParents(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('parents/:id')
|
||||
findParent(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findParent(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('parents')
|
||||
createParent(@Request() req: any, @Body() dto: any) {
|
||||
return this.schoolService.createParent(req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Put('parents/:id')
|
||||
updateParent(@Request() req: any, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.schoolService.updateParent(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('parents/:id')
|
||||
deleteParent(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.deleteParent(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('parents/:id/reset-password')
|
||||
resetParentPassword(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.resetParentPassword(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('parents/:parentId/children/:studentId')
|
||||
addChildToParent(
|
||||
@Request() req: any,
|
||||
@Param('parentId') parentId: string,
|
||||
@Param('studentId') studentId: string,
|
||||
@Body() body: { relationship: string },
|
||||
) {
|
||||
return this.schoolService.addChildToParent(
|
||||
req.user.tenantId,
|
||||
+parentId,
|
||||
+studentId,
|
||||
body.relationship,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('parents/:parentId/children/:studentId')
|
||||
removeChildFromParent(
|
||||
@Request() req: any,
|
||||
@Param('parentId') parentId: string,
|
||||
@Param('studentId') studentId: string,
|
||||
) {
|
||||
return this.schoolService.removeChildFromParent(req.user.tenantId, +parentId, +studentId);
|
||||
}
|
||||
|
||||
// ==================== 课程管理 ====================
|
||||
|
||||
@Get('courses')
|
||||
findCourses(@Request() req: any) {
|
||||
return this.schoolService.findCourses(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('courses/:id')
|
||||
findCourse(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findCourse(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
// ==================== 排课管理 ====================
|
||||
|
||||
@Get('schedules')
|
||||
findSchedules(@Request() req: any, @Query() query: QueryScheduleDto) {
|
||||
return this.schoolService.findSchedules(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('schedules/timetable')
|
||||
getTimetable(@Request() req: any, @Query() query: TimetableQueryDto) {
|
||||
return this.schoolService.getTimetable(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('schedules/:id')
|
||||
findSchedule(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.findSchedule(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('schedules')
|
||||
@LogOperation({ module: '排课管理', action: '创建排课', description: '创建新的课程排期' })
|
||||
createSchedule(@Request() req: any, @Body() dto: CreateScheduleDto) {
|
||||
return this.schoolService.createSchedule(req.user.tenantId, dto, req.user.userId);
|
||||
}
|
||||
|
||||
@Put('schedules/:id')
|
||||
updateSchedule(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateScheduleDto,
|
||||
) {
|
||||
return this.schoolService.updateSchedule(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('schedules/:id')
|
||||
cancelSchedule(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.cancelSchedule(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('schedules/batch')
|
||||
@LogOperation({ module: '排课管理', action: '批量创建排课', description: '批量创建课程排期' })
|
||||
batchCreateSchedules(@Request() req: any, @Body() dto: { schedules: any[] }) {
|
||||
return this.schoolService.batchCreateSchedules(req.user.tenantId, dto.schedules);
|
||||
}
|
||||
|
||||
// ==================== 排课模板 ====================
|
||||
|
||||
@Get('schedule-templates')
|
||||
getScheduleTemplates(@Request() req: any, @Query() query: any) {
|
||||
return this.schoolService.getScheduleTemplates(req.user.tenantId, query);
|
||||
}
|
||||
|
||||
@Get('schedule-templates/:id')
|
||||
getScheduleTemplate(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.getScheduleTemplate(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('schedule-templates')
|
||||
createScheduleTemplate(@Request() req: any, @Body() dto: any) {
|
||||
return this.schoolService.createScheduleTemplate(req.user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Put('schedule-templates/:id')
|
||||
updateScheduleTemplate(@Request() req: any, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.schoolService.updateScheduleTemplate(req.user.tenantId, +id, dto);
|
||||
}
|
||||
|
||||
@Delete('schedule-templates/:id')
|
||||
deleteScheduleTemplate(@Request() req: any, @Param('id') id: string) {
|
||||
return this.schoolService.deleteScheduleTemplate(req.user.tenantId, +id);
|
||||
}
|
||||
|
||||
@Post('schedule-templates/:id/apply')
|
||||
applyScheduleTemplate(@Request() req: any, @Param('id') id: string, @Body() dto: any) {
|
||||
return this.schoolService.applyScheduleTemplate(req.user.tenantId, +id, dto);
|
||||
}
|
||||
}
|
||||
17
reading-platform-backend/src/modules/school/school.module.ts
Normal file
17
reading-platform-backend/src/modules/school/school.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchoolController } from './school.controller';
|
||||
import { SchoolService } from './school.service';
|
||||
import { StatsController } from './stats.controller';
|
||||
import { StatsService } from './stats.service';
|
||||
import { PackageController } from './package.controller';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SchoolController, StatsController, PackageController, SettingsController, ExportController],
|
||||
providers: [SchoolService, StatsService, SettingsService, ExportService],
|
||||
exports: [SchoolService, StatsService, SettingsService, ExportService],
|
||||
})
|
||||
export class SchoolModule {}
|
||||
2414
reading-platform-backend/src/modules/school/school.service.ts
Normal file
2414
reading-platform-backend/src/modules/school/school.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@Controller('school/settings')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('school')
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
@Get()
|
||||
getSettings(@Request() req: any) {
|
||||
return this.settingsService.getSettings(req.user.tenantId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
updateSettings(
|
||||
@Request() req: any,
|
||||
@Body() data: {
|
||||
schoolName?: string;
|
||||
schoolLogo?: string;
|
||||
address?: string;
|
||||
notifyOnLesson?: boolean;
|
||||
notifyOnTask?: boolean;
|
||||
notifyOnGrowth?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.settingsService.updateSettings(req.user.tenantId, data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getSettings(tenantId: number) {
|
||||
let settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
// 如果设置不存在,创建默认设置
|
||||
if (!settings) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('学校不存在');
|
||||
}
|
||||
|
||||
settings = await this.prisma.systemSettings.create({
|
||||
data: {
|
||||
tenantId,
|
||||
schoolName: tenant.name,
|
||||
schoolLogo: tenant.logoUrl,
|
||||
address: tenant.address,
|
||||
notifyOnLesson: true,
|
||||
notifyOnTask: true,
|
||||
notifyOnGrowth: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
async updateSettings(tenantId: number, data: {
|
||||
schoolName?: string;
|
||||
schoolLogo?: string;
|
||||
address?: string;
|
||||
notifyOnLesson?: boolean;
|
||||
notifyOnTask?: boolean;
|
||||
notifyOnGrowth?: boolean;
|
||||
}) {
|
||||
let settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
// 创建新设置
|
||||
settings = await this.prisma.systemSettings.create({
|
||||
data: {
|
||||
tenantId,
|
||||
schoolName: data.schoolName,
|
||||
schoolLogo: data.schoolLogo,
|
||||
address: data.address,
|
||||
notifyOnLesson: data.notifyOnLesson ?? true,
|
||||
notifyOnTask: data.notifyOnTask ?? true,
|
||||
notifyOnGrowth: data.notifyOnGrowth ?? false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 更新现有设置
|
||||
settings = await this.prisma.systemSettings.update({
|
||||
where: { tenantId },
|
||||
data: {
|
||||
schoolName: data.schoolName,
|
||||
schoolLogo: data.schoolLogo,
|
||||
address: data.address,
|
||||
notifyOnLesson: data.notifyOnLesson,
|
||||
notifyOnTask: data.notifyOnTask,
|
||||
notifyOnGrowth: data.notifyOnGrowth,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 同步更新租户信息
|
||||
if (data.schoolName || data.schoolLogo || data.address) {
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
name: data.schoolName,
|
||||
logoUrl: data.schoolLogo,
|
||||
address: data.address,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user