- reading-platform-backend:NestJS 后端 - reading-platform-frontend:Vue3 前端 - reading-platform-java:Spring Boot 服务端
1034 lines
28 KiB
TypeScript
1034 lines
28 KiB
TypeScript
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<string, string> = {
|
||
'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<ValidationResult> {
|
||
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,
|
||
}));
|
||
}
|
||
}
|