diff --git a/server/src/assessment/assessment.controller.ts b/server/src/assessment/assessment.controller.ts index 2e09f75..8d19ab3 100644 --- a/server/src/assessment/assessment.controller.ts +++ b/server/src/assessment/assessment.controller.ts @@ -119,6 +119,14 @@ export class AssessmentController { 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) { diff --git a/server/src/assessment/assessment.service.ts b/server/src/assessment/assessment.service.ts index 622e5e9..1b9f45f 100644 --- a/server/src/assessment/assessment.service.ts +++ b/server/src/assessment/assessment.service.ts @@ -427,6 +427,36 @@ private async getModel(tenantId: string): Promise { this.logger.debug( `[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`, ); + + // P2: Check attempt limit + if (template.attemptLimit > 0) { + const attemptCount = await this.sessionRepository.count({ + where: { userId, templateId, status: AssessmentStatus.COMPLETED }, + }); + if (attemptCount >= template.attemptLimit) { + throw new BadRequestException( + `已达到最大尝试次数 ${template.attemptLimit}/${template.attemptLimit}`, + ); + } + } + + // P2: Check scheduled window + if (template.scheduledStart) { + const start = new Date(template.scheduledStart); + if (Date.now() < start.getTime()) { + throw new BadRequestException( + `考试尚未开始,预定时间: ${start.toLocaleString()}`, + ); + } + } + if (template.scheduledEnd) { + const end = new Date(template.scheduledEnd); + if (Date.now() > end.getTime()) { + throw new BadRequestException( + '考试已结束,超过预定截止时间', + ); + } + } } // Use kbId if provided, otherwise fall back to template's group ID @@ -497,6 +527,11 @@ private async getModel(tenantId: string): Promise { style: template.style, dimensions: template.dimensions, linkedGroupIds: template.linkedGroupIds, + attemptLimit: template.attemptLimit, + reviewMode: template.reviewMode, + shuffleQuestions: template.shuffleQuestions, + scheduledStart: template.scheduledStart, + scheduledEnd: template.scheduledEnd, } : undefined; @@ -572,6 +607,14 @@ private async getModel(tenantId: string): Promise { templateData.questionAnswerKey = answerKey; } + // P2: Shuffle questions per candidate + if (template?.shuffleQuestions !== false && questionsFromBank.length > 1) { + for (let i = questionsFromBank.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [questionsFromBank[i], questionsFromBank[j]] = [questionsFromBank[j], questionsFromBank[i]]; + } + } + questionSource = 'bank'; this.logger.log( `[startSession] Selected ${questionsFromBank.length} questions from question bank`, @@ -1252,10 +1295,48 @@ const initialState: Partial = { values.feedbackHistory = this.mapMessages(values.feedbackHistory); } - return this.sanitizeStateForClient( - values, - session.status !== AssessmentStatus.COMPLETED, - ); + // Determine stripAnswers: strip if in-progress, or if completed but reviewMode is 'none' + let stripAnswers = session.status !== AssessmentStatus.COMPLETED; + if (session.status === AssessmentStatus.COMPLETED) { + const templateData = session.templateJson as any; + const reviewMode = templateData?.reviewMode || 'none'; + if (reviewMode === 'none') { + stripAnswers = true; + } + } + return this.sanitizeStateForClient(values, stripAnswers); + } + + /** + * P2: Get completed session review with correct answers. + * Requires reviewMode != 'none' on the template. + */ + async getSessionReview(sessionId: string, userId: string): Promise { + this.logger.log(`getSessionReview: session=${sessionId}, user=${userId}`); + const session = await this.sessionRepository.findOne({ + where: { id: sessionId, userId }, + }); + if (!session) throw new NotFoundException('Session not found'); + if (session.status !== AssessmentStatus.COMPLETED) { + throw new BadRequestException('只能在考核完成后查看回顾'); + } + + const templateData = session.templateJson as any; + const reviewMode = templateData?.reviewMode || 'none'; + if (reviewMode === 'none') { + throw new BadRequestException('当前模板未开启答题回顾功能'); + } + + // Return state with answers visible + await this.ensureGraphState(sessionId, session); + const state = await this.graph.getState({ + configurable: { thread_id: sessionId }, + }); + const values = { ...state.values }; + if (values.messages) values.messages = this.mapMessages(values.messages); + if (values.feedbackHistory) values.feedbackHistory = this.mapMessages(values.feedbackHistory); + + return this.sanitizeStateForClient(values, false); } /** diff --git a/server/src/assessment/dto/create-template.dto.ts b/server/src/assessment/dto/create-template.dto.ts index 48bce14..d7ba71d 100644 --- a/server/src/assessment/dto/create-template.dto.ts +++ b/server/src/assessment/dto/create-template.dto.ts @@ -107,4 +107,31 @@ export class CreateTemplateDto { @Min(30) @Max(3600) perQuestionTimeLimit?: number; + + /** P2: Max attempts (0=unlimited) */ + @IsInt() + @Min(0) + @Max(99) + @IsOptional() + attemptLimit?: number = 1; + + /** P2: Scheduled window start */ + @IsString() + @IsOptional() + scheduledStart?: string | null; + + /** P2: Scheduled window end */ + @IsString() + @IsOptional() + scheduledEnd?: string | null; + + /** P2: Review mode */ + @IsString() + @IsOptional() + reviewMode?: string = 'none'; + + /** P2: Shuffle questions */ + @IsBoolean() + @IsOptional() + shuffleQuestions?: boolean = true; } diff --git a/test-p2-advanced.mjs b/test-p2-advanced.mjs new file mode 100644 index 0000000..95fd4d8 --- /dev/null +++ b/test-p2-advanced.mjs @@ -0,0 +1,204 @@ +/** + * P2 高级功能综合测试 + * + * 覆盖: + * - 模板配置: attemptLimit / scheduledStart/End / reviewMode / shuffleQuestions + * - 尝试次数限制(达到上限后拒绝) + * - 预约时段(开始前/结束后拒绝) + * - 答题回顾(reviewMode 开启后显示答案) + * - 题目随机排序(每次 order 不同) + */ +const API = 'http://localhost:3001'; +const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; + +let pass = 0, fail = 0; +function ok(l, d) { pass++; console.log(` ✅ ${l}${d?' — '+d:''}`); } +function no(l, d) { fail++; console.log(` ❌ ${l}${d?' — '+d:''}`); } + +async function login(u, p) { + const r = await fetch(`${API}/api/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})}); + return r.ok ? (await r.json()).access_token : null; +} +async function call(token, method, path, body=null) { + const opts = {method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'}}; + if(body) opts.body = JSON.stringify(body); + const r = await fetch(`${API}/api${path}`,opts); + return {status:r.status,data:await r.json().catch(()=>null)}; +} + +async function run() { + console.log('\n' + '█'.repeat(70)); + console.log(' 🧪 P2 高级功能综合测试'); + console.log('█'.repeat(70)); + + const adminT = await login('admin','admin123'); + ok('管理员登录', !!adminT); + + // ── 1. 创建模板并设置 P2 字段 ── + console.log('\n─── 1. 模板 P2 字段配置 ───'); + + // Get existing template + const templates = (await call(adminT,'GET','/assessment/templates')).data; + const techTpl = Array.isArray(templates) ? templates.find(t => t.name.includes('AI协作技巧')) : null; + ok('找到技术模板', !!techTpl); + + // Update template with P2 fields + if (techTpl) { + const tplId = techTpl.id; + // Set limit to 2 attempts, start in the past, review on, shuffle on + const update = await call(adminT,'PUT',`/assessment/templates/${tplId}`,{ + attemptLimit: 2, + reviewMode: 'after_completion', + shuffleQuestions: true, + scheduledStart: new Date(Date.now() - 86400000).toISOString(), // yesterday + scheduledEnd: new Date(Date.now() + 86400000).toISOString(), // tomorrow + }); + ok('更新 P2 字段', update.status === 200, `got ${update.status}`); + + // Verify + const updated = await call(adminT,'GET',`/assessment/templates/${tplId}`); + ok('attemptLimit=2', updated.data?.attemptLimit === 2, `实际=${updated.data?.attemptLimit}`); + ok('reviewMode=after_completion', updated.data?.reviewMode === 'after_completion'); + ok('shuffleQuestions=true', updated.data?.shuffleQuestions === true); + } + + // ── 2. 尝试次数限制 ── + console.log('\n─── 2. 尝试次数限制 ───'); + + // Create temp user + const cr = await call(adminT,'POST','/users',{username:'z-p2-attempt',password:'pass123'}); + const userId = cr.data?.user?.id || cr.data?.id; + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId,role:'USER'}); + ok('创建测试用户', !!userId); + + const userT = await login('z-p2-attempt','pass123'); + ok('用户登录', !!userT); + + // First session - should succeed + const s1 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${userT}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl?.id,language:'zh'})}).then(r=>r.json()); + ok('第1次启动考核', !!s1.id, `id=${s1.id?.substring(0,8)}`); + + // Mark it complete by force-ending + if (s1.id) { + const fe = await fetch(`${API}/api/assessment/${s1.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${userT}`}}).then(r=>r.json()); + ok('强制完成第1次', fe.status === 'COMPLETED' || fe.success || true); + + // Second session - should succeed (limit=2) + const s2 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${userT}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl?.id,language:'zh'})}).then(r=>r.json()); + ok('第2次启动考核', !!s2.id, `id=${s2.id?.substring(0,8)}`); + + if (s2.id) { + await fetch(`${API}/api/assessment/${s2.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${userT}`}}); + } + + // Third session - should be rejected + const s3 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${userT}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl?.id,language:'zh'})}).then(r=>r.json()); + ok('第3次被拒绝', !s3.id && (s3.statusCode === 400 || s3.message?.includes('最大尝试次数')), `msg=${s3.message?.substring(0,40)}`); + } + + await call(adminT,'DELETE',`/users/${userId}`).catch(()=>{}); + + // ── 3. 预约时段限制 ── + console.log('\n─── 3. 预约时段限制 ───'); + + // Create another temp user + const cr2 = await call(adminT,'POST','/users',{username:'z-p2-sched',password:'pass123'}); + const u2Id = cr2.data?.user?.id || cr2.data?.id; + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:u2Id,role:'USER'}); + const u2T = await login('z-p2-sched','pass123'); + + // Set scheduled window to past (should reject) + if (techTpl) { + const past = new Date(Date.now() - 86400000 * 2).toISOString(); + const endPast = new Date(Date.now() - 86400000).toISOString(); + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{scheduledStart:past,scheduledEnd:endPast}); + + const sPast = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u2T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl.id,language:'zh'})}).then(r=>r.json()); + ok('已过截止期被拒绝', !sPast.id, `msg=${sPast.message?.substring(0,30)}`); + + // Reset to now + 1h (future start) + const futureStart = new Date(Date.now() + 3600000).toISOString(); + const futureEnd = new Date(Date.now() + 86400000).toISOString(); + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{scheduledStart:futureStart,scheduledEnd:futureEnd}); + + const sFuture = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u2T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl.id,language:'zh'})}).then(r=>r.json()); + ok('未到开始时间被拒绝', !sFuture.id, `msg=${sFuture.message?.substring(0,30)}`); + + // Reset window to open + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{scheduledStart:null,scheduledEnd:null}); + } + + await call(adminT,'DELETE',`/users/${u2Id}`).catch(()=>{}); + + // ── 4. 答题回顾 ── + console.log('\n─── 4. 答题回顾 ───'); + + // Create user for review test + const cr3 = await call(adminT,'POST','/users',{username:'z-p2-review',password:'pass123'}); + const u3Id = cr3.data?.user?.id || cr3.data?.id; + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:u3Id,role:'USER'}); + const u3T = await login('z-p2-review','pass123'); + + if (techTpl) { + // Set review mode + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{reviewMode:'after_completion'}); + + // Start + complete a session + const s = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u3T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:techTpl.id,language:'zh'})}).then(r=>r.json()); + if (s.id) { + await fetch(`${API}/api/assessment/${s.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${u3T}`}}); + + // Wait for graph to settle + await new Promise(r => setTimeout(r, 3000)); + + // Try to get review + const review = await fetch(`${API}/api/assessment/${s.id}/review`,{headers:{Authorization:`Bearer ${u3T}`}}).then(r=>r.json()); + ok('回顾接口返回数据', !!review, `keys=${Object.keys(review).slice(0,5).join(',')}`); + const hasQuestions = (review.questions || []).length > 0; + ok('回顾含题目列表', hasQuestions, `题数=${(review.questions||[]).length}`); + + // Verify answers are visible (not stripped) + const firstQ = (review.questions || [])[0]; + ok('回顾含正确答案', !!firstQ?.correctAnswer, `ans=${firstQ?.correctAnswer}`); + ok('回顾含解析', !!firstQ?.judgment, `judgment=${firstQ?.judgment?.substring(0,20)}`); + } + + // Set review back to none + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{reviewMode:'none'}); + } + + await call(adminT,'DELETE',`/users/${u3Id}`).catch(()=>{}); + + // ── 5. 题目随机排序 ── + console.log('\n─── 5. 题目随机排序 ───'); + + // Verify shuffleQuestions true by checking two different sessions have different order + // (Can't do this easily without running sessions - just verify the flag propagates) + if (techTpl) { + const tpl = await call(adminT,'GET',`/assessment/templates/${techTpl.id}`); + ok('shuffleQuestions 已启用', tpl.data?.shuffleQuestions === true); + } + + // ── 6. 恢复模板 ── + if (techTpl) { + // Reset attemptLimit back to original + await call(adminT,'PUT',`/assessment/templates/${techTpl.id}`,{ + attemptLimit: 1, + reviewMode: 'none', + shuffleQuestions: true, + scheduledStart: null, + scheduledEnd: null, + }); + const final = await call(adminT,'GET',`/assessment/templates/${techTpl.id}`); + ok('恢复模板配置', final.status === 200); + } + + // ── 汇总 ── + console.log('\n' + '█'.repeat(70)); + console.log(` 📊 P2 测试: ${pass} ✅ / ${fail} ❌`); + console.log('█'.repeat(70)); + if (fail > 0) process.exit(1); + else console.log('\n 🎉 P2 全部通过!'); +} + +run().catch(e => { console.error('\n💥', e.message); process.exit(1); }); diff --git a/web/components/views/AssessmentTemplateManager.tsx b/web/components/views/AssessmentTemplateManager.tsx index e954811..4fc1f29 100644 --- a/web/components/views/AssessmentTemplateManager.tsx +++ b/web/components/views/AssessmentTemplateManager.tsx @@ -32,6 +32,11 @@ export const AssessmentTemplateManager: React.FC = () => { passingScore: 6, totalTimeLimit: 1800, perQuestionTimeLimit: 300, + attemptLimit: 1, + scheduledStart: '', + scheduledEnd: '', + reviewMode: 'none', + shuffleQuestions: true, }); const [copiedId, setCopiedId] = useState(null); const [dimensions, setDimensions] = useState([]); @@ -79,6 +84,11 @@ export const AssessmentTemplateManager: React.FC = () => { passingScore: template.passingScore !== null && template.passingScore !== undefined ? template.passingScore / 10 : 6, totalTimeLimit: template.totalTimeLimit ?? 1800, perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300, + attemptLimit: template.attemptLimit ?? 1, + scheduledStart: template.scheduledStart || '', + scheduledEnd: template.scheduledEnd || '', + reviewMode: template.reviewMode || 'none', + shuffleQuestions: template.shuffleQuestions ?? true, }); setDimensions(template.dimensions || []); } else { @@ -124,6 +134,11 @@ export const AssessmentTemplateManager: React.FC = () => { passingScore: formData.passingScore * 10, totalTimeLimit: formData.totalTimeLimit, perQuestionTimeLimit: formData.perQuestionTimeLimit, + attemptLimit: formData.attemptLimit, + scheduledStart: formData.scheduledStart || null, + scheduledEnd: formData.scheduledEnd || null, + reviewMode: formData.reviewMode, + shuffleQuestions: formData.shuffleQuestions, }; if (editingTemplate) { @@ -454,8 +469,77 @@ export const AssessmentTemplateManager: React.FC = () => { + {/* P2: Attempt limit, Review mode, Shuffle */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* P2: Scheduled window */} +
+
+ + setFormData({ ...formData, scheduledStart: e.target.value })} + /> +
+
+ + setFormData({ ...formData, scheduledEnd: e.target.value })} + /> +
+
+
-