初始提交:幼儿园阅读平台三端代码

- reading-platform-backend:NestJS 后端
- reading-platform-frontend:Vue3 前端
- reading-platform-java:Spring Boot 服务端
This commit is contained in:
tonytech 2026-02-28 17:51:15 +08:00
commit 7f757b6a63
390 changed files with 100418 additions and 0 deletions

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

View File

@ -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");

View File

@ -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;

View File

@ -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;

View File

@ -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");

View File

@ -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"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

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

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

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

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

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

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

View File

@ -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();
}
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

View 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('无效的角色');
}
}

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

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

View File

@ -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;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} 个问题需要修复`;
}
}

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

@ -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: '文件删除成功',
};
}
}

View File

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

View File

@ -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: '文件删除失败' };
}
}
}

View File

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

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

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

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

View File

@ -0,0 +1,13 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateLessonDto {
@IsNumber()
courseId: number;
@IsNumber()
classId: number;
@IsOptional()
@IsString()
plannedDatetime?: string;
}

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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());
}
}

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsInt } from 'class-validator';
export class ImportStudentsDto {
@IsOptional()
@IsInt()
classId?: number;
}

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

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

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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