feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user