feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
@@ -0,0 +1,90 @@
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 { EnterpriseService } from './enterprise.service';
@ApiTags('企业版')
@Controller('enterprise')
export class EnterpriseController {
constructor(private enterpriseService: EnterpriseService) {}
@Post('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Req() req: any, @Body() body: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.createOrganization(req.user.userId, body);
}
@Get('organizations')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async list(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.enterpriseService.listOrganizations(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 20,
);
}
@Get('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async get(@Param('id') id: string) {
return this.enterpriseService.getOrganization(parseInt(id));
}
@Put('organizations/:id')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async update(@Param('id') id: string, @Body() body: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
return this.enterpriseService.updateOrganization(parseInt(id), body);
}
@Post('organizations/:id/members')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async addMember(@Req() req: any, @Param('id') id: string, @Body() body: { userId: number; role?: string }) {
return this.enterpriseService.addMember(parseInt(id), req.user.userId, body.userId, body.role);
}
@Delete('organizations/:orgId/members/:userId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeMember(@Req() req: any, @Param('orgId') orgId: string, @Param('userId') userId: string) {
return this.enterpriseService.removeMember(parseInt(orgId), parseInt(userId), req.user.userId);
}
@Post('organizations/:id/assignments')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async assignCourse(@Req() req: any, @Param('id') id: string, @Body() body: { courseId: number; deadline?: string }) {
return this.enterpriseService.assignCourse(parseInt(id), body.courseId, req.user.userId, body.deadline);
}
@Delete('organizations/:orgId/assignments/:courseId')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async removeAssignment(@Param('orgId') orgId: string, @Param('courseId') courseId: string) {
return this.enterpriseService.removeAssignment(parseInt(orgId), parseInt(courseId));
}
@Get('organizations/:id/progress')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getProgress(@Param('id') id: string) {
return this.enterpriseService.getOrgProgress(parseInt(id));
}
@Get('organizations/:id/report')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async getReport(@Param('id') id: string) {
return this.enterpriseService.getOrganizationReport(parseInt(id));
}
@Get('my')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async myOrganizations(@Req() req: any) {
return this.enterpriseService.getMyOrganizations(req.user.userId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EnterpriseController } from './enterprise.controller';
import { EnterpriseService } from './enterprise.service';
@Module({
controllers: [EnterpriseController],
providers: [EnterpriseService],
exports: [EnterpriseService],
})
export class EnterpriseModule {}
@@ -0,0 +1,221 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class EnterpriseService {
constructor(private prisma: PrismaService) {}
async createOrganization(userId: number, data: { name: string; description?: string; contactName?: string; contactPhone?: string }) {
const existing = await this.prisma.organization.findFirst({ where: { name: data.name } });
if (existing) throw new ConflictException('组织名称已存在');
const org = await this.prisma.organization.create({
data: { name: data.name, description: data.description, contactName: data.contactName, contactPhone: data.contactPhone },
});
await this.prisma.organizationMember.create({
data: { organizationId: org.id, userId, role: 'ADMIN' },
});
await this.prisma.organization.update({
where: { id: org.id },
data: { memberCount: { increment: 1 } },
});
return org;
}
async listOrganizations(page = 1, pageSize = 20) {
page = Number(page);
pageSize = Number(pageSize);
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.organization.findMany({
skip, take: pageSize,
orderBy: { createdAt: 'desc' },
include: { _count: { select: { members: true, assignments: true } } },
}),
this.prisma.organization.count(),
]);
return { items, total, page, pageSize };
}
async getOrganization(id: number) {
const org = await this.prisma.organization.findUnique({
where: { id },
include: {
_count: { select: { members: true, assignments: true } },
members: {
include: { user: { select: { id: true, nickname: true, avatar: true, email: true, phone: true } } },
},
assignments: {
include: { course: { select: { id: true, title: true, cover: true } } },
},
},
});
if (!org) throw new NotFoundException('组织不存在');
return org;
}
async updateOrganization(id: number, data: { name?: string; description?: string; contactName?: string; contactPhone?: string }) {
const org = await this.prisma.organization.findUnique({ where: { id } });
if (!org) throw new NotFoundException('组织不存在');
return this.prisma.organization.update({ where: { id }, data });
}
async addMember(orgId: number, adminId: number, userId: number, role = 'MEMBER') {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('组织不存在');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: adminId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (membership) throw new ConflictException('该用户已是组织成员');
await this.prisma.organizationMember.create({
data: { organizationId: orgId, userId, role },
});
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { increment: 1 } },
});
return { added: true };
}
async removeMember(orgId: number, userId: number, callerId: number) {
const membership = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId } },
});
if (!membership) throw new NotFoundException('该用户不是组织成员');
if (membership.role === 'ADMIN') throw new BadRequestException('不能移除管理员');
const caller = await this.prisma.organizationMember.findUnique({
where: { organizationId_userId: { organizationId: orgId, userId: callerId } },
});
if (!caller || caller.role !== 'ADMIN') throw new ForbiddenException('只有管理员可管理成员');
await this.prisma.organizationMember.delete({ where: { id: membership.id } });
await this.prisma.organization.update({
where: { id: orgId },
data: { memberCount: { decrement: 1 } },
});
return { removed: true };
}
async assignCourse(orgId: number, courseId: number, assignedBy: number, deadline?: string) {
const [org, course] = await Promise.all([
this.prisma.organization.findUnique({ where: { id: orgId } }),
this.prisma.course.findUnique({ where: { id: courseId } }),
]);
if (!org) throw new NotFoundException('组织不存在');
if (!course) throw new NotFoundException('课程不存在');
const existing = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (existing) throw new ConflictException('该课程已分配给此组织');
return this.prisma.courseAssignment.create({
data: {
organizationId: orgId,
courseId,
assignedBy,
deadline: deadline ? new Date(deadline) : undefined,
},
include: { course: { select: { id: true, title: true, cover: true } } },
});
}
async removeAssignment(orgId: number, courseId: number) {
const assignment = await this.prisma.courseAssignment.findFirst({
where: { organizationId: orgId, courseId },
});
if (!assignment) throw new NotFoundException('未找到该课程分配');
await this.prisma.courseAssignment.delete({ where: { id: assignment.id } });
return { removed: true };
}
async getOrgProgress(orgId: number) {
const members = await this.prisma.organizationMember.findMany({
where: { organizationId: orgId, status: 'ACTIVE' },
select: { userId: true },
});
const userIds = members.map(m => m.userId);
const assignments = await this.prisma.courseAssignment.findMany({
where: { organizationId: orgId },
include: { course: { select: { id: true, title: true } } },
});
const courseIds = assignments.map(a => a.courseId);
const records = await this.prisma.learnRecord.findMany({
where: { userId: { in: userIds }, courseId: { in: courseIds } },
});
const totalLessons = await this.prisma.lesson.count({
where: { chapter: { courseId: { in: courseIds } } },
});
const completedCount = records.filter(r => r.completed).length;
const activeMembers = userIds.length;
const progressByCourse = courseIds.map(courseId => {
const courseLessons = records.filter(r => r.courseId === courseId);
const uniqueLessons = new Set(courseLessons.map(r => r.lessonId));
return {
courseId,
completedLessons: courseLessons.filter(r => r.completed).length,
totalUniqueLessons: uniqueLessons.size,
};
});
return {
totalMembers: activeMembers,
totalCourses: courseIds.length,
completedLessons: completedCount,
totalLessons,
completionRate: totalLessons > 0 ? Math.round((completedCount / (totalLessons * Math.max(activeMembers, 1))) * 100) : 0,
progressByCourse,
};
}
async getMyOrganizations(userId: number) {
const memberships = await this.prisma.organizationMember.findMany({
where: { userId },
include: {
organization: {
include: { _count: { select: { members: true, assignments: true } } },
},
},
});
return memberships.map(m => ({ ...m.organization, role: m.role }));
}
async getOrganizationReport(orgId: number) {
const org = await this.getOrganization(orgId);
const progress = await this.getOrgProgress(orgId);
const memberProgress = await Promise.all(
org.members.map(async (member) => {
const records = await this.prisma.learnRecord.count({
where: { userId: member.user.id, completed: true },
});
return { user: member.user, completedLessons: records };
}),
);
return {
organization: { id: org.id, name: org.name, memberCount: org._count.members },
summary: progress,
memberProgress,
assignments: org.assignments,
};
}
}
@@ -0,0 +1,158 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { EnterpriseService } from '../enterprise.service';
import { PrismaService } from '../../../prisma/prisma.service';
describe('EnterpriseService', () => {
let service: EnterpriseService;
let prisma: PrismaService;
const mockPrisma = {
organization: {
findFirst: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
organizationMember: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
courseAssignment: {
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
course: {
findUnique: jest.fn(),
},
learnRecord: {
findMany: jest.fn(),
count: jest.fn(),
},
lesson: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnterpriseService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<EnterpriseService>(EnterpriseService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('createOrganization', () => {
it('should create organization and add creator as admin', async () => {
mockPrisma.organization.findFirst.mockResolvedValue(null);
mockPrisma.organization.create.mockResolvedValue({ id: 1, name: '测试企业', memberCount: 0 });
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.createOrganization(1, { name: '测试企业' });
expect(result).toEqual({ id: 1, name: '测试企业', memberCount: 0 });
expect(mockPrisma.organization.create).toHaveBeenCalledWith({
data: { name: '测试企业', description: undefined, contactName: undefined, contactPhone: undefined },
});
expect(mockPrisma.organizationMember.create).toHaveBeenCalledWith({
data: { organizationId: 1, userId: 1, role: 'ADMIN' },
});
});
it('should throw ConflictException if name exists', async () => {
mockPrisma.organization.findFirst.mockResolvedValue({ id: 1 });
await expect(service.createOrganization(1, { name: '测试企业' }))
.rejects.toThrow(ConflictException);
});
});
describe('getOrganization', () => {
it('should return organization with members and assignments', async () => {
const mockOrg = {
id: 1, name: '测试企业',
_count: { members: 2, assignments: 1 },
members: [{ id: 1, userId: 1, user: { id: 1, nickname: '用户1' } }],
assignments: [{ id: 1, courseId: 1, course: { id: 1, title: '课程1' } }],
};
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
const result = await service.getOrganization(1);
expect(result).toEqual(mockOrg);
});
it('should throw NotFoundException if not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.getOrganization(999)).rejects.toThrow(NotFoundException);
});
});
describe('addMember', () => {
it('should add member to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce(null); // user not yet member
mockPrisma.organizationMember.create.mockResolvedValue({});
mockPrisma.organization.update.mockResolvedValue({});
const result = await service.addMember(1, 1, 2);
expect(result).toEqual({ added: true });
});
it('should throw ConflictException if already member', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.organizationMember.findUnique
.mockResolvedValueOnce({ role: 'ADMIN' }) // caller is admin
.mockResolvedValueOnce({ id: 1 }); // user already member
await expect(service.addMember(1, 1, 2)).rejects.toThrow(ConflictException);
});
});
describe('assignCourse', () => {
it('should assign course to organization', async () => {
mockPrisma.organization.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.course.findUnique.mockResolvedValue({ id: 1 });
mockPrisma.courseAssignment.findFirst.mockResolvedValue(null);
mockPrisma.courseAssignment.create.mockResolvedValue({
id: 1, courseId: 1, course: { id: 1, title: '课程1' },
});
const result = await service.assignCourse(1, 1, 1);
expect(result).toHaveProperty('id');
});
it('should throw NotFoundException if org not found', async () => {
mockPrisma.organization.findUnique.mockResolvedValue(null);
await expect(service.assignCourse(1, 1, 1)).rejects.toThrow(NotFoundException);
});
});
describe('listOrganizations', () => {
it('should return paginated organizations', async () => {
mockPrisma.organization.findMany.mockResolvedValue([{ id: 1, name: '测试企业' }]);
mockPrisma.organization.count.mockResolvedValue(1);
const result = await service.listOrganizations(1, 20);
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
});
});