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,86 @@
import { Controller, Post, Get, Put, Body, UseGuards, Req, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesService } from '../courses/courses.service';
import { PrismaService } from '../../prisma/prisma.service';
@ApiTags('管理后台')
@Controller('admin')
export class AdminController {
constructor(
private adminService: AdminService,
private coursesService: CoursesService,
private prisma: PrismaService,
) {}
@Post('login')
async login(@Body() body: { username: string; password: string }) {
return this.adminService.login(body.username, body.password);
}
@Get('dashboard')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async dashboard() {
return this.adminService.getDashboard();
}
@Get('orders')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async orders(@Req() req: any) {
const items = await this.prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { user: { select: { id: true, nickname: true, phone: true } } },
});
return { items };
}
@Put('courses/:id/chapters')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async updateCourseChapters(@Param('id') id: string, @Body() body: any) {
return this.coursesService.updateWithChapters(+id, body);
}
@Get('comments/pending')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async pendingComments() {
const items = await this.prisma.comment.findMany({
where: { status: 'PENDING_REVIEW' },
include: {
user: { select: { id: true, nickname: true, avatar: true } },
post: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
return { items };
}
@Put('comments/:id/approve')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async approveComment(@Param('id') id: string) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'PUBLISHED', reviewedAt: new Date() },
});
return { ok: true };
}
@Put('comments/:id/reject')
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
async rejectComment(@Param('id') id: string, @Body() body: { reason?: string }) {
await this.prisma.comment.update({
where: { id: parseInt(id) },
data: { status: 'REJECTED', reviewNote: body.reason || '违规内容', reviewedAt: new Date() },
});
return { ok: true };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
if (!request.user?.isAdmin) {
throw new ForbiddenException('无管理员权限');
}
return true;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './admin.guard';
import { CoursesModule } from '../courses/courses.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [CoursesModule, AuthModule],
controllers: [AdminController],
providers: [AdminService, AdminGuard],
exports: [AdminService],
})
export class AdminModule {}
@@ -0,0 +1,56 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(username: string, password: string) {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号不可用');
}
const isValid = await bcrypt.compare(password, admin.passwordHash);
if (!isValid) {
throw new UnauthorizedException('密码错误');
}
await this.prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = this.jwtService.sign(
{ sub: admin.id, type: 'admin' },
{ expiresIn: '8h' },
);
return { token, id: admin.id, username: admin.username, role: admin.role };
}
async getDashboard() {
const [userCount, courseCount, contentCount, promptCount, orderCount] = await Promise.all([
this.prisma.user.count({ where: { deletedAt: null } }),
this.prisma.course.count({ where: { deletedAt: null } }),
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED' } }),
this.prisma.order.count(),
]);
return {
stats: { userCount, courseCount, contentCount, promptCount, orderCount },
};
}
async logAction(adminId: number, action: string, target?: string, detail?: string, ip?: string) {
return this.prisma.adminLog.create({
data: { adminId, action, target, detail, ip },
});
}
}
@@ -0,0 +1,172 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { AdminService } from '../admin.service';
import { PrismaService } from '../../../prisma/prisma.service';
// Mock bcrypt
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(),
}));
describe('AdminService', () => {
let service: AdminService;
let prisma: PrismaService;
const mockPrisma = {
adminUser: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: { count: jest.fn() },
course: { count: jest.fn() },
content: { count: jest.fn() },
prompt: { count: jest.fn() },
order: { count: jest.fn() },
adminLog: { create: jest.fn() },
};
const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
service = module.get<AdminService>(AdminService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('login', () => {
const mockAdmin = {
id: 1,
username: 'admin',
passwordHash: '$2a$10$hashed',
role: 'superadmin',
status: 'ACTIVE',
};
it('should login successfully', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(true as never);
mockPrisma.adminUser.update.mockResolvedValue(mockAdmin);
const result = await service.login('admin', 'admin123456');
expect(result).toEqual(expect.objectContaining({ id: 1, username: 'admin', role: 'superadmin' }));
expect(mockPrisma.adminUser.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
data: { lastLoginAt: expect.any(Date) },
})
);
});
it('should throw on wrong password', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(mockAdmin);
jest.spyOn(bcrypt, 'compare').mockResolvedValue(false as never);
await expect(service.login('admin', 'wrongpass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin not found', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue(null);
await expect(service.login('unknown', 'pass')).rejects.toThrow(UnauthorizedException);
});
it('should throw when admin is inactive', async () => {
mockPrisma.adminUser.findUnique.mockResolvedValue({ ...mockAdmin, status: 'INACTIVE' });
await expect(service.login('admin', 'pass')).rejects.toThrow(UnauthorizedException);
});
});
describe('getDashboard', () => {
it('should return all stats', async () => {
mockPrisma.user.count.mockResolvedValue(100);
mockPrisma.course.count.mockResolvedValue(20);
mockPrisma.content.count.mockResolvedValue(45);
mockPrisma.prompt.count.mockResolvedValue(30);
mockPrisma.order.count.mockResolvedValue(15);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 100,
courseCount: 20,
contentCount: 45,
promptCount: 30,
orderCount: 15,
},
});
});
it('should return zeroes when no data', async () => {
mockPrisma.user.count.mockResolvedValue(0);
mockPrisma.course.count.mockResolvedValue(0);
mockPrisma.content.count.mockResolvedValue(0);
mockPrisma.prompt.count.mockResolvedValue(0);
mockPrisma.order.count.mockResolvedValue(0);
const result = await service.getDashboard();
expect(result).toEqual({
stats: {
userCount: 0, courseCount: 0, contentCount: 0,
promptCount: 0, orderCount: 0,
},
});
});
it('should filter deleted users and courses', async () => {
mockPrisma.user.count.mockResolvedValue(50);
mockPrisma.course.count.mockResolvedValue(10);
mockPrisma.content.count.mockResolvedValue(5);
mockPrisma.prompt.count.mockResolvedValue(3);
mockPrisma.order.count.mockResolvedValue(2);
await service.getDashboard();
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.course.count).toHaveBeenCalledWith({ where: { deletedAt: null } });
expect(mockPrisma.content.count).toHaveBeenCalledWith({
where: { deletedAt: null, status: 'PUBLISHED' },
});
});
});
describe('logAction', () => {
it('should create an admin log entry', async () => {
const mockLog = { id: 1, adminId: 1, action: 'login', target: null, detail: null, ip: null };
mockPrisma.adminLog.create.mockResolvedValue(mockLog);
const result = await service.logAction(1, 'login');
expect(result).toEqual(mockLog);
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'login', target: undefined, detail: undefined, ip: undefined },
});
});
it('should create log with full details', async () => {
mockPrisma.adminLog.create.mockResolvedValue({ id: 2 });
await service.logAction(1, 'update', 'courses/1', '修改课程标题', '127.0.0.1');
expect(mockPrisma.adminLog.create).toHaveBeenCalledWith({
data: { adminId: 1, action: 'update', target: 'courses/1', detail: '修改课程标题', ip: '127.0.0.1' },
});
});
});
});