feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { CoursesService } from './courses.service';
|
||||
|
||||
@ApiTags('课程')
|
||||
@Controller('courses')
|
||||
export class CoursesController {
|
||||
constructor(private coursesService: CoursesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
|
||||
return this.coursesService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(@Param('id') id: string) {
|
||||
return this.coursesService.findById(+id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: {
|
||||
title: string; description?: string; cover?: string; categoryId?: number;
|
||||
price?: number; isFree?: boolean;
|
||||
}) {
|
||||
return this.coursesService.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.coursesService.update(+id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.coursesService.remove(+id);
|
||||
}
|
||||
|
||||
@Post(':courseId/lessons/:lessonId/progress')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async updateProgress(
|
||||
@Req() req: any,
|
||||
@Param('courseId') courseId: string,
|
||||
@Param('lessonId') lessonId: string,
|
||||
@Body() body: { completed?: boolean; progress?: number },
|
||||
) {
|
||||
return this.coursesService.updateProgress(
|
||||
req.user.userId,
|
||||
+courseId,
|
||||
+lessonId,
|
||||
body.completed,
|
||||
body.progress,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('my-learning')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async getMyLearning(@Req() req: any, @Query('courseId') courseId?: string) {
|
||||
return this.coursesService.getLearningProgress(
|
||||
req.user.userId,
|
||||
courseId ? +courseId : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoursesController } from './courses.controller';
|
||||
import { CoursesService } from './courses.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CoursesController],
|
||||
providers: [CoursesService],
|
||||
exports: [CoursesService],
|
||||
})
|
||||
export class CoursesModule {}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CoursesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFree?: boolean }) {
|
||||
const page = Number(params.page ?? 1);
|
||||
const pageSize = Number(params.pageSize ?? 20);
|
||||
const { categoryId, isFree } = params;
|
||||
const where: any = { status: 'PUBLISHED', deletedAt: null };
|
||||
if (categoryId) where.categoryId = categoryId;
|
||||
if (isFree !== undefined) where.isFree = isFree;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { category: true, chapters: { include: { lessons: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
}),
|
||||
this.prisma.course.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findById(id: number) {
|
||||
return this.prisma.course.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: true,
|
||||
chapters: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { lessons: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
title: string; description?: string; cover?: string; categoryId?: number;
|
||||
price?: number; isFree?: boolean; sortOrder?: number;
|
||||
}) {
|
||||
return this.prisma.course.create({ data });
|
||||
}
|
||||
|
||||
async update(id: number, data: any) {
|
||||
const { chapters, ...courseData } = data;
|
||||
return this.prisma.course.update({
|
||||
where: { id },
|
||||
data: courseData,
|
||||
});
|
||||
}
|
||||
|
||||
async updateWithChapters(id: number, data: any) {
|
||||
const { chapters, ...courseData } = data;
|
||||
if (chapters) {
|
||||
const existingChapters = await this.prisma.chapter.findMany({ where: { courseId: id } });
|
||||
const existingIds = existingChapters.map(c => c.id);
|
||||
const incomingIds = chapters.filter((c: any) => c.id).map((c: any) => c.id);
|
||||
const toDelete = existingIds.filter(eid => !incomingIds.includes(eid));
|
||||
for (const cid of toDelete) {
|
||||
await this.prisma.lesson.deleteMany({ where: { chapterId: cid } });
|
||||
await this.prisma.chapter.delete({ where: { id: cid } });
|
||||
}
|
||||
for (const ch of chapters) {
|
||||
if (ch.id) {
|
||||
const { lessons, id: chId, courseId, ...chData } = ch;
|
||||
await this.prisma.chapter.update({ where: { id: ch.id }, data: chData });
|
||||
if (lessons) {
|
||||
const existingLessons = await this.prisma.lesson.findMany({ where: { chapterId: ch.id } });
|
||||
const existingLessonIds = existingLessons.map(l => l.id);
|
||||
const incomingLessonIds = lessons.filter((l: any) => l.id).map((l: any) => l.id);
|
||||
const lessonsToDelete = existingLessonIds.filter(eid => !incomingLessonIds.includes(eid));
|
||||
for (const lid of lessonsToDelete) {
|
||||
await this.prisma.lesson.delete({ where: { id: lid } });
|
||||
}
|
||||
for (const le of lessons) {
|
||||
if (le.id) {
|
||||
const { id: lessonId, chapterId, ...lessonData } = le;
|
||||
await this.prisma.lesson.update({ where: { id: le.id }, data: lessonData });
|
||||
} else {
|
||||
const { id: _li, ...lessonData } = le;
|
||||
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: ch.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { lessons, id: _newChId, ...chData } = ch;
|
||||
const newCh = await this.prisma.chapter.create({ data: { ...chData, courseId: id } });
|
||||
if (lessons) {
|
||||
for (const le of lessons) {
|
||||
const { id: _li, ...lessonData } = le;
|
||||
await this.prisma.lesson.create({ data: { ...lessonData, chapterId: newCh.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.prisma.course.update({
|
||||
where: { id },
|
||||
data: courseData,
|
||||
include: { chapters: { include: { lessons: { orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
return this.prisma.course.update({ where: { id }, data: { deletedAt: new Date() } });
|
||||
}
|
||||
|
||||
async updateProgress(userId: number, courseId: number, lessonId: number, completed?: boolean, progress?: number) {
|
||||
const existing = await this.prisma.learnRecord.findUnique({
|
||||
where: { userId_lessonId: { userId, lessonId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.learnRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
completed: completed ?? existing.completed,
|
||||
progress: progress ?? existing.progress,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return this.prisma.learnRecord.create({
|
||||
data: {
|
||||
userId,
|
||||
courseId,
|
||||
lessonId,
|
||||
completed: completed ?? false,
|
||||
progress: progress ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getLearningProgress(userId: number, courseId?: number) {
|
||||
const where: any = { userId };
|
||||
if (courseId) {
|
||||
where.courseId = courseId;
|
||||
}
|
||||
|
||||
const records = await this.prisma.learnRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
course: { select: { id: true, title: true } },
|
||||
lesson: { select: { id: true, title: true, chapterId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const courseMap: Record<number, any> = {};
|
||||
records.forEach(r => {
|
||||
if (!courseMap[r.courseId]) {
|
||||
courseMap[r.courseId] = {
|
||||
courseId: r.courseId,
|
||||
courseTitle: r.course.title,
|
||||
totalLessons: 0,
|
||||
completedLessons: 0,
|
||||
progress: 0,
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
courseMap[r.courseId].records.push(r);
|
||||
courseMap[r.courseId].totalLessons++;
|
||||
if (r.completed) courseMap[r.courseId].completedLessons++;
|
||||
});
|
||||
|
||||
Object.values(courseMap).forEach(c => {
|
||||
c.progress = c.totalLessons > 0 ? Math.round((c.completedLessons / c.totalLessons) * 100) : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
items: Object.values(courseMap),
|
||||
total: records.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CoursesService } from '../courses.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
describe('CoursesService', () => {
|
||||
let service: CoursesService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrisma = {
|
||||
course: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
chapter: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
lesson: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CoursesService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CoursesService>(CoursesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated courses with chapters/lessons', async () => {
|
||||
const mockCourses = [
|
||||
{ id: 1, title: 'AI 入门', status: 'PUBLISHED', chapters: [{ id: 1, lessons: [{ id: 1 }] }] },
|
||||
];
|
||||
mockPrisma.course.findMany.mockResolvedValue(mockCourses);
|
||||
mockPrisma.course.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll({ page: 1, pageSize: 20 });
|
||||
|
||||
expect(result).toEqual({ items: mockCourses, total: 1, page: 1, pageSize: 20 });
|
||||
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'PUBLISHED', deletedAt: null },
|
||||
skip: 0,
|
||||
take: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by categoryId', async () => {
|
||||
mockPrisma.course.findMany.mockResolvedValue([]);
|
||||
mockPrisma.course.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll({ categoryId: 3 });
|
||||
|
||||
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ categoryId: 3 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter free courses', async () => {
|
||||
mockPrisma.course.findMany.mockResolvedValue([]);
|
||||
mockPrisma.course.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll({ isFree: true });
|
||||
|
||||
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ isFree: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply pagination correctly', async () => {
|
||||
mockPrisma.course.findMany.mockResolvedValue([]);
|
||||
mockPrisma.course.count.mockResolvedValue(50);
|
||||
|
||||
const result = await service.findAll({ page: 3, pageSize: 10 });
|
||||
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(mockPrisma.course.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skip: 20, take: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return course with chapters and lessons', async () => {
|
||||
const mockCourse = {
|
||||
id: 1,
|
||||
title: 'AI 入门',
|
||||
chapters: [{ id: 1, title: '第一章', lessons: [{ id: 1, title: '第一课' }] }],
|
||||
};
|
||||
mockPrisma.course.findUnique.mockResolvedValue(mockCourse);
|
||||
|
||||
const result = await service.findById(1);
|
||||
|
||||
expect(result).toEqual(mockCourse);
|
||||
expect(mockPrisma.course.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
include: expect.objectContaining({
|
||||
chapters: expect.objectContaining({
|
||||
include: { lessons: expect.any(Object) },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-existent course', async () => {
|
||||
mockPrisma.course.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new course', async () => {
|
||||
const newCourse = { id: 2, title: '新课程', isFree: true };
|
||||
mockPrisma.course.create.mockResolvedValue(newCourse);
|
||||
|
||||
const result = await service.create({ title: '新课程', isFree: true });
|
||||
|
||||
expect(result).toEqual(newCourse);
|
||||
expect(mockPrisma.course.create).toHaveBeenCalledWith({
|
||||
data: { title: '新课程', isFree: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create course with all fields', async () => {
|
||||
mockPrisma.course.create.mockResolvedValue({ id: 3 });
|
||||
|
||||
await service.create({
|
||||
title: '完整课程',
|
||||
description: '描述',
|
||||
categoryId: 1,
|
||||
price: 99,
|
||||
isFree: false,
|
||||
sortOrder: 5,
|
||||
});
|
||||
|
||||
expect(mockPrisma.course.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
title: '完整课程',
|
||||
description: '描述',
|
||||
categoryId: 1,
|
||||
price: 99,
|
||||
isFree: false,
|
||||
sortOrder: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update course fields', async () => {
|
||||
mockPrisma.course.update.mockResolvedValue({ id: 1, title: '更新标题' });
|
||||
|
||||
const result = await service.update(1, { title: '更新标题' });
|
||||
|
||||
expect(result).toEqual({ id: 1, title: '更新标题' });
|
||||
expect(mockPrisma.course.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { title: '更新标题' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip chapters from course data update', async () => {
|
||||
mockPrisma.course.update.mockResolvedValue({ id: 1 });
|
||||
|
||||
await service.update(1, { title: 'test', chapters: [{ title: 'ch1' }] });
|
||||
|
||||
expect(mockPrisma.course.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { title: 'test' }, // chapters stripped
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft-delete a course', async () => {
|
||||
mockPrisma.course.update.mockResolvedValue({ id: 1, deletedAt: new Date() });
|
||||
|
||||
const result = await service.remove(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockPrisma.course.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { deletedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateWithChapters', () => {
|
||||
beforeEach(() => {
|
||||
mockPrisma.chapter.findMany.mockResolvedValue([]);
|
||||
mockPrisma.course.update.mockResolvedValue({ id: 1 });
|
||||
});
|
||||
|
||||
it('should create new chapters and lessons', async () => {
|
||||
mockPrisma.chapter.findMany.mockResolvedValue([]);
|
||||
mockPrisma.chapter.create.mockResolvedValue({ id: 10 });
|
||||
mockPrisma.lesson.create.mockResolvedValue({ id: 20 });
|
||||
|
||||
const result = await service.updateWithChapters(1, {
|
||||
title: '更新课程',
|
||||
chapters: [
|
||||
{
|
||||
title: '新章节',
|
||||
sortOrder: 1,
|
||||
lessons: [{ title: '新课时', sortOrder: 1, status: 'PUBLISHED' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockPrisma.chapter.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: '新章节',
|
||||
courseId: 1,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete removed chapters', async () => {
|
||||
mockPrisma.chapter.findMany.mockResolvedValue([
|
||||
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
|
||||
]);
|
||||
mockPrisma.lesson.deleteMany.mockResolvedValue({ count: 0 });
|
||||
mockPrisma.chapter.delete.mockResolvedValue({ id: 5 });
|
||||
|
||||
await service.updateWithChapters(1, {
|
||||
title: '更新',
|
||||
chapters: [], // no chapters = delete everything
|
||||
});
|
||||
|
||||
expect(mockPrisma.chapter.delete).toHaveBeenCalledWith({ where: { id: 5 } });
|
||||
});
|
||||
|
||||
it('should update existing chapters', async () => {
|
||||
mockPrisma.chapter.findMany.mockResolvedValue([
|
||||
{ id: 5, courseId: 1, title: '旧章节', sortOrder: 1 },
|
||||
]);
|
||||
mockPrisma.lesson.findMany.mockResolvedValue([]);
|
||||
mockPrisma.chapter.update.mockResolvedValue({ id: 5 });
|
||||
|
||||
await service.updateWithChapters(1, {
|
||||
chapters: [{ id: 5, title: '更新章节', sortOrder: 2 }],
|
||||
});
|
||||
|
||||
expect(mockPrisma.chapter.update).toHaveBeenCalledWith({
|
||||
where: { id: 5 },
|
||||
data: { title: '更新章节', sortOrder: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user