46a10ba091
后端: - assessment-template entity: attemptLimit/scheduledStart/End/reviewMode/shuffleQuestions - DTO 更新: 新增 P2 字段验证 - startSession: 尝试次数检查、预约时段检查、题目随机排序 - getSessionState: reviewMode 控制答案可见性 - 新增 GET /assessment/:id/review 回顾端点 前端: - AssessmentTemplateManager: 新增尝试次数/答题回顾/题目排序/预约时段配置 - AssessmentView: 答题回顾按钮(完成页)+提交确认弹窗+标记回头功能 - types.ts: 新增 P2 字段类型 - assessmentService: 新增 getReview 方法 - 进度导航点: 可视化题序+标记状态 测试 20项全部通过 + 系统测试 142项全部通过 ✅ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Body,
|
|
Get,
|
|
Param,
|
|
UseGuards,
|
|
Request,
|
|
Req,
|
|
Sse,
|
|
MessageEvent,
|
|
Query,
|
|
Delete,
|
|
Put,
|
|
ForbiddenException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { map } from 'rxjs/operators';
|
|
import { AssessmentService } from './assessment.service';
|
|
import { ExportService } from './services/export.service';
|
|
import { AuditLogService } from './services/audit-log.service';
|
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
|
import { Public } from '../auth/public.decorator';
|
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
|
|
|
@ApiTags('Assessment')
|
|
@Controller('assessment')
|
|
@UseGuards(CombinedAuthGuard)
|
|
export class AssessmentController {
|
|
private readonly logger = new Logger(AssessmentController.name);
|
|
|
|
constructor(
|
|
private readonly assessmentService: AssessmentService,
|
|
private readonly exportService: ExportService,
|
|
private readonly auditLog: AuditLogService,
|
|
) {}
|
|
|
|
@Post('start')
|
|
@ApiOperation({ summary: 'Start a new assessment session' })
|
|
async startSession(
|
|
@Request() req: any,
|
|
@Body()
|
|
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
|
|
) {
|
|
const { id: userId, tenantId } = req.user;
|
|
this.logger.log(
|
|
`startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
|
);
|
|
const session = await this.assessmentService.startSession(
|
|
userId,
|
|
body.knowledgeBaseId,
|
|
tenantId,
|
|
body.language,
|
|
body.templateId,
|
|
);
|
|
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
|
|
return session;
|
|
}
|
|
|
|
@Post(':id/answer')
|
|
@ApiOperation({ summary: 'Submit an answer to the current question' })
|
|
async submitAnswer(
|
|
@Request() req: any,
|
|
@Param('id') sessionId: string,
|
|
@Body() body: { answer: string; language?: string },
|
|
) {
|
|
const { id: userId, tenantId } = req.user;
|
|
this.logger.log(
|
|
`submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
|
);
|
|
const result = await this.assessmentService.submitAnswer(
|
|
sessionId,
|
|
userId,
|
|
body.answer,
|
|
body.language,
|
|
);
|
|
this.auditLog.log({ userId, tenantId, action: 'session.answer', resourceType: 'assessment_session', resourceId: sessionId, details: { answerLength: body.answer?.length } });
|
|
return result;
|
|
}
|
|
|
|
@Sse(':id/start-stream')
|
|
@ApiOperation({ summary: 'Stream initial session generation' })
|
|
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
|
|
const { id: userId } = req.user;
|
|
this.logger.log(
|
|
`startSessionStream: user=${userId}, session=${sessionId}`,
|
|
);
|
|
return this.assessmentService
|
|
.startSessionStream(sessionId, userId)
|
|
.pipe(map((data) => ({ data }) as MessageEvent));
|
|
}
|
|
|
|
@Sse(':id/answer-stream')
|
|
@ApiOperation({
|
|
summary: 'Stream answer evaluation and next question generation',
|
|
})
|
|
submitAnswerStream(
|
|
@Request() req: any,
|
|
@Param('id') sessionId: string,
|
|
@Query('answer') answer: string,
|
|
@Query('language') language?: string,
|
|
) {
|
|
const { id: userId } = req.user;
|
|
this.logger.log(
|
|
`submitAnswerStream: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
|
|
);
|
|
return this.assessmentService
|
|
.submitAnswerStream(sessionId, userId, answer, language)
|
|
.pipe(map((data) => ({ data }) as MessageEvent));
|
|
}
|
|
|
|
@Get(':id/state')
|
|
@ApiOperation({ summary: 'Get the current state of an assessment session' })
|
|
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
|
|
const { id: userId } = req.user;
|
|
this.logger.log(
|
|
`getSessionState: user=${userId}, session=${sessionId}`,
|
|
);
|
|
return this.assessmentService.getSessionState(sessionId, userId);
|
|
}
|
|
|
|
@Get(':id/review')
|
|
@ApiOperation({ summary: 'Get review data for a completed assessment (shows correct answers)' })
|
|
async getReview(@Request() req: any, @Param('id') sessionId: string) {
|
|
const { id: userId } = req.user;
|
|
this.logger.log(`getReview: user=${userId}, session=${sessionId}`);
|
|
return this.assessmentService.getSessionReview(sessionId, userId);
|
|
}
|
|
|
|
@Delete(':id')
|
|
@ApiOperation({ summary: 'Delete an assessment session' })
|
|
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
|
|
const user = req.user;
|
|
this.logger.log(
|
|
`deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
|
);
|
|
await this.assessmentService.deleteSession(sessionId, user);
|
|
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.delete', resourceType: 'assessment_session', resourceId: sessionId });
|
|
return { success: true };
|
|
}
|
|
|
|
@Get(':id/certificate')
|
|
@ApiOperation({ summary: 'Get certificate for completed assessment' })
|
|
async getCertificate(
|
|
@Request() req: any,
|
|
@Param('id') sessionId: string,
|
|
) {
|
|
const { id: userId, tenantId } = req.user;
|
|
this.logger.log(
|
|
`getCertificate: user=${userId}, session=${sessionId}`,
|
|
);
|
|
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
|
|
}
|
|
|
|
@Public()
|
|
@Get('certificate/verify/:certificateId')
|
|
@ApiOperation({ summary: 'Verify certificate by ID (public)' })
|
|
async verifyCertificate(
|
|
@Param('certificateId') certificateId: string,
|
|
) {
|
|
return this.assessmentService.verifyCertificate(certificateId);
|
|
}
|
|
|
|
@Public()
|
|
@Get('certificate/public/:sessionId')
|
|
@ApiOperation({ summary: 'Get public certificate info for verification' })
|
|
async getPublicCertificate(
|
|
@Param('sessionId') sessionId: string,
|
|
) {
|
|
return this.assessmentService.getPublicCertificateInfo(sessionId);
|
|
}
|
|
|
|
@Get('history')
|
|
@ApiOperation({ summary: 'Get current user assessment history (keep latest 3)' })
|
|
async getHistory(
|
|
@Request() req: any,
|
|
) {
|
|
const { id: userId } = req.user;
|
|
return this.assessmentService.getUserHistory(userId);
|
|
}
|
|
|
|
@Get('stats')
|
|
@ApiOperation({ summary: 'Get assessment statistics for admin' })
|
|
async getStats(
|
|
@Request() req: any,
|
|
@Query('startDate') startDate?: string,
|
|
@Query('endDate') endDate?: string,
|
|
@Query('templateId') templateId?: string,
|
|
@Query('knowledgeGroupId') knowledgeGroupId?: string,
|
|
) {
|
|
const { id: userId, tenantId, role } = req.user;
|
|
this.logger.log(
|
|
`getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
|
|
);
|
|
return this.assessmentService.getStats(
|
|
userId,
|
|
tenantId,
|
|
role,
|
|
startDate,
|
|
endDate,
|
|
templateId,
|
|
knowledgeGroupId,
|
|
);
|
|
}
|
|
|
|
@Get('stats/radar')
|
|
@ApiOperation({ summary: 'Get radar chart data for dimension scores' })
|
|
async getRadarStats(
|
|
@Request() req: any,
|
|
@Query('templateId') templateId?: string,
|
|
) {
|
|
const { id: userId, tenantId, role } = req.user;
|
|
return this.assessmentService.getRadarStats(
|
|
userId,
|
|
tenantId,
|
|
role,
|
|
templateId,
|
|
);
|
|
}
|
|
|
|
@Get('stats/trend')
|
|
@ApiOperation({ summary: 'Get trend data for scores over time' })
|
|
async getTrendStats(
|
|
@Request() req: any,
|
|
@Query('startDate') startDate?: string,
|
|
@Query('endDate') endDate?: string,
|
|
) {
|
|
const { id: userId, tenantId, role } = req.user;
|
|
return this.assessmentService.getTrendStats(
|
|
userId,
|
|
tenantId,
|
|
role,
|
|
startDate,
|
|
endDate,
|
|
);
|
|
}
|
|
|
|
@Post('batch-delete')
|
|
@ApiOperation({ summary: 'Batch delete assessment sessions (admin only)' })
|
|
async batchDelete(@Request() req: any, @Body() body: { ids: string[] }) {
|
|
const user = req.user;
|
|
const isAdmin = user.role?.toLowerCase() === 'super_admin' || user.role?.toLowerCase() === 'admin';
|
|
if (!isAdmin) {
|
|
throw new ForbiddenException('Only admin can batch delete');
|
|
}
|
|
const count = await this.assessmentService.batchDeleteSessions(body.ids, user);
|
|
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.batch_delete', resourceType: 'assessment_session', details: { count, ids: body.ids } });
|
|
return { deleted: count };
|
|
}
|
|
|
|
@Post('batch-export')
|
|
@ApiOperation({ summary: 'Batch export assessments as JSON array' })
|
|
async batchExport(@Request() req: any, @Body() body: { ids: string[] }) {
|
|
const { id: userId } = req.user;
|
|
return this.assessmentService.batchExportSessions(body.ids, userId);
|
|
}
|
|
|
|
@Put(':id/review')
|
|
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
|
async review(
|
|
@Param('id') sessionId: string,
|
|
@Body() body: { newScore: number; comment?: string },
|
|
@Req() req: any,
|
|
) {
|
|
const { id: userId, tenantId } = req.user;
|
|
const result = await this.assessmentService.reviewAssessment(
|
|
sessionId,
|
|
body.newScore,
|
|
body.comment,
|
|
userId,
|
|
tenantId,
|
|
);
|
|
this.auditLog.log({ userId, tenantId, action: 'session.review', resourceType: 'assessment_session', resourceId: sessionId, details: { newScore: body.newScore, comment: body.comment } });
|
|
return result;
|
|
}
|
|
|
|
@Get(':id/time-check')
|
|
@ApiOperation({ summary: 'Check assessment time limits' })
|
|
async checkTimeLimits(@Param('id') sessionId: string) {
|
|
return this.assessmentService.checkTimeLimits(sessionId);
|
|
}
|
|
|
|
@Post(':id/next-question')
|
|
@ApiOperation({ summary: 'Start timing for next question' })
|
|
async nextQuestion(@Param('id') sessionId: string) {
|
|
await this.assessmentService.updateQuestionStartTime(sessionId);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post(':id/force-end')
|
|
@ApiOperation({ summary: 'Force end assessment (admin only)' })
|
|
async forceEnd(
|
|
@Param('id') sessionId: string,
|
|
@Request() req: any,
|
|
) {
|
|
const { id: userId, tenantId, role } = req.user;
|
|
const isAdmin = role?.toLowerCase() === 'super_admin' || role?.toLowerCase() === 'admin';
|
|
if (!isAdmin) {
|
|
throw new ForbiddenException('Only admin can force end assessment');
|
|
}
|
|
const result = await this.assessmentService.forceEndAssessment(sessionId);
|
|
this.auditLog.log({ userId, tenantId, action: 'session.force_end', resourceType: 'assessment_session', resourceId: sessionId });
|
|
return result;
|
|
}
|
|
|
|
@Get(':id/export/excel')
|
|
@ApiOperation({ summary: 'Export assessment to Excel' })
|
|
async exportExcel(@Param('id') sessionId: string) {
|
|
const buffer = await this.exportService.exportToExcel(sessionId);
|
|
return {
|
|
filename: `assessment-${sessionId}.xlsx`,
|
|
buffer: buffer.toString('base64'),
|
|
};
|
|
}
|
|
|
|
@Get(':id/export/pdf')
|
|
@ApiOperation({ summary: 'Export assessment to HTML report' })
|
|
async exportPdf(@Param('id') sessionId: string) {
|
|
const buffer = await this.exportService.exportToPdf(sessionId);
|
|
return {
|
|
filename: `assessment-${sessionId}.html`,
|
|
buffer: buffer.toString('base64'),
|
|
};
|
|
}
|
|
}
|