import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../database/prisma.service'; import { CourseValidationService, ValidationResult } from './course-validation.service'; @Injectable() export class CourseService { private readonly logger = new Logger(CourseService.name); constructor( private prisma: PrismaService, private validationService: CourseValidationService, ) {} async findAll(query: any) { const { page = 1, pageSize = 10, grade, status, keyword } = query; const skip = (page - 1) * pageSize; const take = +pageSize; const where: any = {}; // 筛选条件 if (status) { where.status = status; } if (keyword) { where.name = { contains: keyword }; } // 年级筛选 - SQLite使用字符串包含匹配 if (grade) { // 搜索英文值(数据库存储的是英文) // 支持大小写不敏感搜索 const gradeUpper = grade.toUpperCase(); where.OR = [ { gradeTags: { contains: gradeUpper } }, // 大写格式: SMALL, MIDDLE, BIG { gradeTags: { contains: grade.toLowerCase() } }, // 小写格式: small, middle, big ]; } const [items, total] = await Promise.all([ this.prisma.course.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, pictureBookName: true, gradeTags: true, status: true, version: true, usageCount: true, teacherCount: true, avgRating: true, createdAt: true, updatedAt: true, submittedAt: true, reviewedAt: true, reviewComment: true, themeId: true, coreContent: true, coverImagePath: true, domainTags: true, theme: { select: { id: true, name: true }, }, }, }), this.prisma.course.count({ where }), ]); return { items, total, page: +page, pageSize: +pageSize, }; } async findOne(id: number) { const course = await this.prisma.course.findUnique({ where: { id }, include: { theme: { select: { id: true, name: true }, }, resources: { orderBy: { sortOrder: 'asc' }, }, scripts: { orderBy: { sortOrder: 'asc' }, include: { pages: { orderBy: { pageNumber: 'asc' }, }, }, }, activities: { orderBy: { sortOrder: 'asc' }, }, courseLessons: { orderBy: { sortOrder: 'asc' }, include: { steps: { orderBy: { sortOrder: 'asc' }, }, }, }, }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } return course; } async create(createCourseDto: any) { try { this.logger.log(`Creating course with data: ${JSON.stringify(createCourseDto)}`); const result = await this.prisma.course.create({ data: createCourseDto, }); this.logger.log(`Course created successfully with ID: ${result.id}`); return result; } catch (error) { this.logger.error(`Error creating course: ${error.message}`, error.stack); throw error; } } async update(id: number, updateCourseDto: any) { // 需要明确设置为 null 的字段列表(与 schema.prisma 保持一致) const fieldsToClear = [ 'coverImagePath', 'ebookPaths', 'audioPaths', 'videoPaths', 'otherResources', 'pptPath', 'pptName', 'posterPaths', 'tools', 'studentMaterials', 'pictureBookName', 'lessonPlanData', 'activitiesData', 'assessmentData', // 新增课程介绍字段 'introSummary', 'introHighlights', 'introGoals', 'introSchedule', 'introKeyPoints', 'introMethods', 'introEvaluation', 'introNotes', 'coreContent', 'scheduleRefData', 'environmentConstruction', ]; const cleanedData: any = {}; for (const [key, value] of Object.entries(updateCourseDto)) { // 对于可以清除的字段,如果是 null 或空字符串,设置为 null if (fieldsToClear.includes(key) && (value === null || value === '')) { cleanedData[key] = null; } // 对于其他字段,如果有值则添加 else if (value !== undefined) { cleanedData[key] = value; } } this.logger.log(`Updating course ${id} with data: ${JSON.stringify(Object.keys(cleanedData))}`); // 使用事务更新课程和关联表数据 return this.prisma.$transaction(async (tx) => { // 更新课程主表 const updatedCourse = await tx.course.update({ where: { id }, data: cleanedData, }); // 同步 lessonPlanData 到 course_scripts 关联表 if (updateCourseDto.lessonPlanData !== undefined) { await this.syncLessonPlanToScripts(tx, id, updateCourseDto.lessonPlanData); } // 同步 activitiesData 到 course_activities 关联表 if (updateCourseDto.activitiesData !== undefined) { await this.syncActivitiesToTable(tx, id, updateCourseDto.activitiesData); } return updatedCourse; }); } /** * 将 lessonPlanData 同步到 course_scripts 关联表 * 支持新格式(pages在每个phase内部)和旧格式(scriptPages在顶层) */ private async syncLessonPlanToScripts(tx: any, courseId: number, lessonPlanData: string | null) { // 先删除旧的 scripts 和 pages await tx.courseScriptPage.deleteMany({ where: { script: { courseId } }, }); await tx.courseScript.deleteMany({ where: { courseId }, }); if (!lessonPlanData) { this.logger.log(`Course ${courseId}: lessonPlanData is null, cleared scripts`); return; } try { const lessonPlan = JSON.parse(lessonPlanData); const phases = lessonPlan.phases || []; // 兼容旧格式:顶层的 scriptPages const topLevelScriptPages = lessonPlan.scriptPages || []; // 调试日志 this.logger.log(`=== 同步课程 ${courseId} 的教学脚本 ===`); this.logger.log(`phases 数量: ${phases.length}`); this.logger.log(`顶层 scriptPages 数量: ${topLevelScriptPages.length}`); for (let i = 0; i < phases.length; i++) { const phase = phases[i]; this.logger.log(`Phase ${i}: name=${phase.name}, pages=${phase.pages?.length || 0}, enablePageScript=${phase.enablePageScript}`); // 创建 script 记录 const script = await tx.courseScript.create({ data: { courseId, stepIndex: i + 1, stepName: phase.name || `步骤${i + 1}`, stepType: phase.type || 'CUSTOM', duration: phase.duration || 5, objective: phase.objective || null, teacherScript: phase.content || null, interactionPoints: null, resourceIds: phase.resourceIds ? JSON.stringify(phase.resourceIds) : null, sortOrder: i, }, }); // 优先使用 phase 内部的 pages(新格式) // 如果没有,则兼容旧格式:第一个 phase 使用顶层的 scriptPages let pagesToCreate = phase.pages || []; if (pagesToCreate.length === 0 && topLevelScriptPages.length > 0 && i === 0) { pagesToCreate = topLevelScriptPages; } // 创建逐页配置 if (pagesToCreate.length > 0) { this.logger.log(`为 Phase ${i} 创建 ${pagesToCreate.length} 页逐页脚本`); for (const page of pagesToCreate) { await tx.courseScriptPage.create({ data: { scriptId: script.id, pageNumber: page.pageNumber, questions: page.teacherScript || null, interactionComponent: page.actions ? JSON.stringify(page.actions) : null, teacherNotes: page.notes || null, resourceIds: page.resourceIds ? JSON.stringify(page.resourceIds) : null, }, }); } } } this.logger.log(`Course ${courseId}: synced ${phases.length} scripts from lessonPlanData`); } catch (error) { this.logger.error(`Failed to sync lessonPlanData for course ${courseId}: ${error.message}`); } } /** * 将 activitiesData 同步到 course_activities 关联表 */ private async syncActivitiesToTable(tx: any, courseId: number, activitiesData: string | null) { // 先删除旧的活动 await tx.courseActivity.deleteMany({ where: { courseId }, }); if (!activitiesData) { this.logger.log(`Course ${courseId}: activitiesData is null, cleared activities`); return; } try { const activities = JSON.parse(activitiesData); for (let i = 0; i < activities.length; i++) { const activity = activities[i]; await tx.courseActivity.create({ data: { courseId, name: activity.name || `活动${i + 1}`, domain: activity.domain || null, // 使用单独的 domain 字段,不再错误地使用 type domainTagId: null, activityType: this.mapActivityType(activity.type), duration: activity.duration || 15, onlineMaterials: activity.content ? JSON.stringify({ content: activity.content }) : null, offlineMaterials: activity.materials || null, activityGuide: null, objectives: null, sortOrder: i, }, }); } this.logger.log(`Course ${courseId}: synced ${activities.length} activities from activitiesData`); } catch (error) { this.logger.error(`Failed to sync activitiesData for course ${courseId}: ${error.message}`); } } /** * 映射活动类型 */ private mapActivityType(type: string | undefined): string { const typeMap: Record = { 'family': 'FAMILY', 'art': 'ART', 'game': 'GAME', 'outdoor': 'OUTDOOR', 'other': 'OTHER', 'handicraft': 'HANDICRAFT', 'music': 'MUSIC', 'exploration': 'EXPLORATION', 'sports': 'SPORTS', // 中文兼容 '家庭延伸': 'FAMILY', '美工活动': 'ART', '游戏活动': 'GAME', '户外活动': 'OUTDOOR', '其他': 'OTHER', '手工活动': 'HANDICRAFT', '音乐活动': 'MUSIC', '探索活动': 'EXPLORATION', '运动活动': 'SPORTS', '亲子活动': 'FAMILY', }; return typeMap[type || ''] || 'OTHER'; } async remove(id: number) { // Check if course has usage records const usageCount = await this.prisma.lesson.count({ where: { courseId: id }, }); if (usageCount > 0) { throw new BadRequestException(`该课程包已被使用${usageCount}次,无法删除`); } // Delete related records in order // Delete course lessons (if any) await this.prisma.courseLesson.deleteMany({ where: { courseId: id }, }); // Delete tenant course authorizations (if any) await this.prisma.tenantCourse.deleteMany({ where: { courseId: id }, }); // Delete course resources (if any) await this.prisma.courseResource.deleteMany({ where: { courseId: id }, }); // Delete course scripts (if any) await this.prisma.courseScript.deleteMany({ where: { courseId: id }, }); // Delete course activities (if any) await this.prisma.courseActivity.deleteMany({ where: { courseId: id }, }); // Delete course versions (if any) await this.prisma.courseVersion.deleteMany({ where: { courseId: id }, }); // Delete schedule plans (if any) await this.prisma.schedulePlan.deleteMany({ where: { courseId: id }, }); // Delete schedule templates (if any) await this.prisma.scheduleTemplate.deleteMany({ where: { courseId: id }, }); // Delete tasks (if any) await this.prisma.task.deleteMany({ where: { relatedCourseId: id }, }); // Delete task templates (if any) await this.prisma.taskTemplate.deleteMany({ where: { relatedCourseId: id }, }); // Delete package course relations (if any) await this.prisma.coursePackageCourse.deleteMany({ where: { courseId: id }, }); // Delete school courses (if any) await this.prisma.schoolCourse.deleteMany({ where: { sourceCourseId: id }, }); // Finally delete the course return this.prisma.course.delete({ where: { id }, }); } /** * 验证课程完整性 */ async validate(id: number): Promise { const course = await this.findOne(id); return this.validationService.validateForSubmit(course); } /** * 提交审核 */ async submit(id: number, userId: number, copyrightConfirmed: boolean) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } // 检查当前状态是否可以提交 if (course.status !== 'DRAFT' && course.status !== 'REJECTED') { throw new BadRequestException(`课程状态为 ${course.status},无法提交审核`); } // 验证课程完整性 const validationResult = await this.validationService.validateForSubmit(course); if (!validationResult.valid) { throw new BadRequestException({ message: '课程内容不完整,请检查以下问题', errors: validationResult.errors, warnings: validationResult.warnings, }); } // 版权确认 if (!copyrightConfirmed) { throw new BadRequestException('请确认版权合规'); } // 更新课程状态 const updatedCourse = await this.prisma.course.update({ where: { id }, data: { status: 'PENDING', submittedAt: new Date(), submittedBy: userId, }, }); this.logger.log(`Course ${id} submitted for review by user ${userId}`); return { ...updatedCourse, validationSummary: this.validationService.getValidationSummary(validationResult), }; } /** * 撤销审核申请 */ async withdraw(id: number, userId: number) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } if (course.status !== 'PENDING') { throw new BadRequestException(`课程状态为 ${course.status},无法撤销`); } const updatedCourse = await this.prisma.course.update({ where: { id }, data: { status: 'DRAFT', submittedAt: null, submittedBy: null, }, }); this.logger.log(`Course ${id} review withdrawn by user ${userId}`); return updatedCourse; } /** * 审核通过并发布 */ async approve(id: number, reviewerId: number, reviewData: { checklist?: any; comment?: string }) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } if (course.status !== 'PENDING') { throw new BadRequestException(`课程状态为 ${course.status},无法审核`); } // 禁止自审 if (course.submittedBy === reviewerId) { throw new BadRequestException('不能审核自己提交的课程'); } // 使用事务更新课程状态并创建版本快照 const result = await this.prisma.$transaction(async (tx) => { // 更新课程状态 const updatedCourse = await tx.course.update({ where: { id }, data: { status: 'PUBLISHED', reviewedAt: new Date(), reviewedBy: reviewerId, reviewComment: reviewData.comment || null, reviewChecklist: reviewData.checklist ? JSON.stringify(reviewData.checklist) : null, publishedAt: new Date(), }, }); // 创建版本快照 await tx.courseVersion.create({ data: { courseId: id, version: course.version, snapshotData: JSON.stringify(course), changeLog: reviewData.comment || '审核通过发布', publishedBy: reviewerId, }, }); return updatedCourse; }); // 授权给所有活跃租户 const activeTenants = await this.prisma.tenant.findMany({ where: { status: 'ACTIVE' }, select: { id: true }, }); this.logger.log(`Publishing course ${id} to ${activeTenants.length} active tenants`); for (const tenant of activeTenants) { await this.prisma.tenantCourse.upsert({ where: { tenantId_courseId: { tenantId: tenant.id, courseId: id, }, }, update: { authorized: true, authorizedAt: new Date(), }, create: { tenantId: tenant.id, courseId: id, authorized: true, authorizedAt: new Date(), }, }); } this.logger.log(`Course ${id} approved and published by reviewer ${reviewerId}`); return { ...result, authorizedTenantCount: activeTenants.length, }; } /** * 审核驳回 */ async reject(id: number, reviewerId: number, reviewData: { checklist?: any; comment: string }) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } if (course.status !== 'PENDING') { throw new BadRequestException(`课程状态为 ${course.status},无法审核`); } // 禁止自审 if (course.submittedBy === reviewerId) { throw new BadRequestException('不能审核自己提交的课程'); } if (!reviewData.comment || reviewData.comment.trim().length === 0) { throw new BadRequestException('请填写驳回原因'); } const updatedCourse = await this.prisma.course.update({ where: { id }, data: { status: 'REJECTED', reviewedAt: new Date(), reviewedBy: reviewerId, reviewComment: reviewData.comment, reviewChecklist: reviewData.checklist ? JSON.stringify(reviewData.checklist) : null, }, }); this.logger.log(`Course ${id} rejected by reviewer ${reviewerId}: ${reviewData.comment}`); return updatedCourse; } /** * 直接发布(超级管理员专用) */ async directPublish(id: number, userId: number, skipValidation: boolean = false) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } // 检查课程状态 if (course.status === 'PUBLISHED') { throw new BadRequestException('课程已发布'); } // 验证课程完整性(即使跳过也要记录) const validationResult = await this.validationService.validateForSubmit(course); if (!skipValidation && !validationResult.valid) { throw new BadRequestException({ message: '课程内容不完整,请检查以下问题', errors: validationResult.errors, warnings: validationResult.warnings, }); } // 使用事务更新课程状态并创建版本快照 const result = await this.prisma.$transaction(async (tx) => { const updatedCourse = await tx.course.update({ where: { id }, data: { status: 'PUBLISHED', publishedAt: new Date(), reviewedAt: new Date(), reviewedBy: userId, reviewComment: '超级管理员直接发布', }, }); // 创建版本快照 await tx.courseVersion.create({ data: { courseId: id, version: course.version, snapshotData: JSON.stringify(course), changeLog: '超级管理员直接发布', publishedBy: userId, }, }); return updatedCourse; }); // 授权给所有活跃租户 const activeTenants = await this.prisma.tenant.findMany({ where: { status: 'ACTIVE' }, select: { id: true }, }); for (const tenant of activeTenants) { await this.prisma.tenantCourse.upsert({ where: { tenantId_courseId: { tenantId: tenant.id, courseId: id, }, }, update: { authorized: true, authorizedAt: new Date(), }, create: { tenantId: tenant.id, courseId: id, authorized: true, authorizedAt: new Date(), }, }); } this.logger.log(`Course ${id} directly published by super admin ${userId}`); return { ...result, authorizedTenantCount: activeTenants.length, validationSkipped: skipValidation && !validationResult.valid, validationWarnings: validationResult.warnings, }; } /** * 发布课程(兼容旧API) */ async publish(id: number) { // 旧的publish方法改为调用directPublish return this.directPublish(id, 0, false); } /** * 下架课程 */ async unpublish(id: number) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } if (course.status !== 'PUBLISHED') { throw new BadRequestException(`课程状态为 ${course.status},无法下架`); } const updatedCourse = await this.prisma.course.update({ where: { id }, data: { status: 'ARCHIVED', }, }); // 取消所有租户的授权 await this.prisma.tenantCourse.updateMany({ where: { courseId: id }, data: { authorized: false, }, }); this.logger.log(`Course ${id} unpublished`); return updatedCourse; } /** * 重新发布已下架的课程 */ async republish(id: number) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } if (course.status !== 'ARCHIVED') { throw new BadRequestException(`课程状态为 ${course.status},无法重新发布`); } const updatedCourse = await this.prisma.course.update({ where: { id }, data: { status: 'PUBLISHED', }, }); // 重新授权给所有活跃租户 const activeTenants = await this.prisma.tenant.findMany({ where: { status: 'ACTIVE' }, select: { id: true }, }); for (const tenant of activeTenants) { await this.prisma.tenantCourse.upsert({ where: { tenantId_courseId: { tenantId: tenant.id, courseId: id, }, }, update: { authorized: true, authorizedAt: new Date(), }, create: { tenantId: tenant.id, courseId: id, authorized: true, authorizedAt: new Date(), }, }); } this.logger.log(`Course ${id} republished`); return { ...updatedCourse, authorizedTenantCount: activeTenants.length, }; } async getStats(id: number) { const course = await this.prisma.course.findUnique({ where: { id }, select: { id: true, name: true, usageCount: true, teacherCount: true, avgRating: true, }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } const lessons = await this.prisma.lesson.findMany({ where: { courseId: id }, include: { teacher: { select: { id: true, name: true }, }, class: { select: { id: true, name: true }, }, }, orderBy: { createdAt: 'desc' }, take: 10, }); const feedbacks = await this.prisma.lessonFeedback.findMany({ where: { lesson: { courseId: id, }, }, }); const calculateAverage = (field: string) => { const validFeedbacks = feedbacks.filter((f: any) => f[field] != null); if (validFeedbacks.length === 0) return 0; const sum = validFeedbacks.reduce((acc: number, f: any) => acc + f[field], 0); return sum / validFeedbacks.length; }; const studentRecords = await this.prisma.studentRecord.findMany({ where: { lesson: { courseId: id, }, }, }); const calculateStudentAvg = (field: string) => { const validRecords = studentRecords.filter((r: any) => r[field] != null); if (validRecords.length === 0) return 0; const sum = validRecords.reduce((acc: number, r: any) => acc + r[field], 0); return sum / validRecords.length; }; const now = new Date(); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const recentLessons = await this.prisma.lesson.findMany({ where: { courseId: id, createdAt: { gte: weekAgo }, }, select: { createdAt: true, }, }); const lessonTrend = []; for (let i = 6; i >= 0; i--) { const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); const dateStr = date.toLocaleDateString('zh-CN', { weekday: 'short' }); const count = recentLessons.filter((lesson: any) => { const lessonDate = new Date(lesson.createdAt); return lessonDate.toDateString() === date.toDateString(); }).length; lessonTrend.push({ date: dateStr, count }); } const uniqueStudentIds = new Set(); lessons.forEach((lesson: any) => { uniqueStudentIds.add(lesson.classId); }); return { courseName: course.name, totalLessons: course.usageCount || lessons.length, totalTeachers: course.teacherCount || new Set(lessons.map((l: any) => l.teacherId)).size, totalStudents: uniqueStudentIds.size, avgRating: course.avgRating || 0, lessonTrend, feedbackDistribution: { designQuality: calculateAverage('designQuality'), participation: calculateAverage('participation'), goalAchievement: calculateAverage('goalAchievement'), totalFeedbacks: feedbacks.length, }, recentLessons: lessons.map((lesson: any) => ({ ...lesson, date: lesson.createdAt, })), studentPerformance: { avgFocus: calculateStudentAvg('focus'), avgParticipation: calculateStudentAvg('participation'), avgInterest: calculateStudentAvg('interest'), avgUnderstanding: calculateStudentAvg('understanding'), }, }; } /** * 获取审核列表 */ async getReviewList(query: any) { const { page = 1, pageSize = 10, status, submittedBy } = query; const skip = (page - 1) * pageSize; const take = +pageSize; const where: any = { status: { in: ['PENDING', 'REJECTED'] }, }; if (status) { where.status = status; } if (submittedBy) { where.submittedBy = +submittedBy; } const [items, total] = await Promise.all([ this.prisma.course.findMany({ where, skip, take, orderBy: { submittedAt: 'desc' }, select: { id: true, name: true, status: true, submittedAt: true, submittedBy: true, reviewedAt: true, reviewedBy: true, reviewComment: true, coverImagePath: true, gradeTags: true, }, }), this.prisma.course.count({ where }), ]); return { items, total, page: +page, pageSize: +pageSize, }; } /** * 获取版本历史 */ async getVersionHistory(id: number) { const course = await this.prisma.course.findUnique({ where: { id }, }); if (!course) { throw new NotFoundException(`Course #${id} not found`); } const versions = await this.prisma.courseVersion.findMany({ where: { courseId: id }, orderBy: { publishedAt: 'desc' }, }); return versions.map((v) => ({ id: v.id, version: v.version, changeLog: v.changeLog, publishedAt: v.publishedAt, publishedBy: v.publishedBy, })); } }