import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { isValidQuestionType, gradeAnswer, type QuestionType } from './quiz-type.contract'; @Injectable() export class QuizService { constructor(private readonly prisma: PrismaService) {} async create(userId: string, dto: { knowledgeBaseId: string; title?: string; sourceType?: string; sourceId?: string; questionCount?: number }) { const count = dto.questionCount ?? 5; const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: dto.knowledgeBaseId } }); if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在'); const quiz = await this.prisma.quiz.create({ data: { userId, knowledgeBaseId: dto.knowledgeBaseId, title: dto.title || `${kb.title} - 自测`, sourceType: dto.sourceType ?? 'kb', sourceId: dto.sourceId ?? null, questionCount: count, }, }); // Generate questions from KB items const items = await this.prisma.knowledgeItem.findMany({ where: { knowledgeBaseId: dto.knowledgeBaseId, deletedAt: null }, orderBy: { updatedAt: 'desc' }, take: count * 2, }); if (items.length === 0) throw new BadRequestException('知识库中没有知识点,无法生成测验'); const shuffled = items.sort(() => Math.random() - 0.5).slice(0, count); const questions: any[] = []; for (let i = 0; i < shuffled.length; i++) { const item = shuffled[i]; const otherItems = items.filter(x => x.id !== item.id); // Alternate question types const types = ['choice', 'fill', 'judge']; const qType = types[i % 3]; let stem = '', options: string[] = [], answer = ''; if (qType === 'choice') { stem = `${item.title} 是什么?`; const correct = item.content?.slice(0, 80) ?? '正确答案'; options = [correct]; for (const o of otherItems.slice(0, 3)) { options.push(o.content?.slice(0, 80) ?? '其他选项'); } options = options.sort(() => Math.random() - 0.5); answer = String(options.indexOf(correct)); } else if (qType === 'fill') { const words = (item.content ?? '').split(/[,。;\s]+/).filter(w => w.length >= 3); const blank = words.length > 0 ? words[Math.floor(Math.random() * words.length)] : '关键概念'; stem = `${item.title}:请填写缺失的关键词。${(item.content ?? '').replace(blank, '____')}`; answer = blank; } else { const isCorrect = Math.random() > 0.5; stem = `关于「${item.title}」,以下说法是否正确?${isCorrect ? item.content?.slice(0, 100) ?? '正确描述' : '错误描述'}`; answer = String(isCorrect); } questions.push({ quizId: quiz.id, type: qType, stem, options: options.length > 0 ? options : undefined, answer, explanation: item.content?.slice(0, 200) ?? '', orderIndex: i, }); } await this.prisma.quizQuestion.createMany({ data: questions }); return this.prisma.quiz.findUnique({ where: { id: quiz.id }, include: { questions: { orderBy: { orderIndex: 'asc' } } } }); } async findAll(userId: string, kbId?: string) { return this.prisma.quiz.findMany({ where: { userId, ...(kbId ? { knowledgeBaseId: kbId } : {}) }, orderBy: { createdAt: 'desc' }, }); } async findOne(userId: string, id: string) { const quiz = await this.prisma.quiz.findUnique({ where: { id, userId }, // M-AI-07-06: 不 include questions(避免答案泄漏) // 客户端应使用 getAttemptQuestions 获取题目,submit 后通过 getResults 获取答案 }); if (!quiz) throw new NotFoundException('测验不存在'); return quiz; } // M-QUIZ-01-01: 创建独立 Attempt(每次答题启动新 Attempt) async start(userId: string, quizId: string) { const quiz = await this.prisma.quiz.findUnique({ where: { id: quizId, userId } }); if (!quiz) throw new NotFoundException('测验不存在'); return this.prisma.quizAttempt.create({ data: { quizId, userId, totalQuestions: quiz.questionCount }, }); } // M-QUIZ-01-01: 按 Attempt 加载题目,未提交前不返回 answer/explanation async getAttemptQuestions(userId: string, attemptId: string) { // N2:userId 进入查询条件,统一 404(与 getResults 一致) const attempt = await this.prisma.quizAttempt.findUnique({ where: { id: attemptId, userId }, select: { id: true, quizId: true, finishedAt: true }, }); if (!attempt) throw new NotFoundException('答题记录不存在'); const isFinished = attempt.finishedAt !== null; return this.prisma.quizQuestion.findMany({ where: { quizId: attempt.quizId }, select: { id: true, type: true, stem: true, options: true, // M-QUIZ-01-01: 仅当前 Attempt 已提交后才返回 answer 和 explanation // 历史 Attempt 的完成状态不影响当前 Attempt 的答案可见性 ...(isFinished ? { answer: true, explanation: true } : {}), sourceBlockIds: true, orderIndex: true, }, orderBy: { orderIndex: 'asc' }, }); } async submit(userId: string, attemptId: string, answers: { questionId: string; answer: string }[]) { if (!answers || answers.length === 0) { throw new BadRequestException('至少需要提交一个答案'); } // M-QUIZ-01-02: 原子提交 + 幂等控制 return this.prisma.$transaction(async (tx) => { // N1 修复:使用 updateMany 写锁替代 findUnique 快照读 // UPDATE ... WHERE finishedAt IS NULL 在 MySQL 中获取排他行锁, // 并发事务只有一个能匹配成功,彻底消除 REPEATABLE READ 下的竞态窗口 const cas = await tx.quizAttempt.updateMany({ where: { id: attemptId, userId, finishedAt: null }, data: { finishedAt: new Date() }, }); if (cas.count === 0) { const attempt = await tx.quizAttempt.findUnique({ where: { id: attemptId } }); if (!attempt || attempt.userId !== userId) throw new NotFoundException('答题记录不存在'); throw new BadRequestException('已提交过答案'); } // 服务端判分:比对正确答案,客户端传入的 isCorrect 不可信 let correctCount = 0; const answerRecords: Array<{ attemptId: string; questionId: string; userAnswer: string; isCorrect: boolean; }> = []; for (const a of answers) { const q = await tx.quizQuestion.findUnique({ where: { id: a.questionId } }); if (!q) throw new BadRequestException(`题目 ${a.questionId} 不存在`); // M-QUIZ-01-04: 按题型分发判分逻辑(fill 使用规范化比较) if (!isValidQuestionType(q.type)) { throw new BadRequestException(`题目 ${a.questionId} 类型非法: ${q.type}`); } const isCorrect = gradeAnswer(q.type as QuestionType, a.answer, q.answer); if (isCorrect) correctCount++; answerRecords.push({ attemptId, questionId: a.questionId, userAnswer: a.answer, isCorrect, }); } // 批量原子写入 QuizAnswer if (answerRecords.length > 0) { await tx.quizAnswer.createMany({ data: answerRecords }); } // 同一事务内结算最终分数(finishedAt 已由 CAS 占位) const score = Math.round((correctCount / answers.length) * 100); await tx.quizAttempt.update({ where: { id: attemptId }, data: { correctCount, score }, }); return { score, correctCount, totalQuestions: answers.length, finishedAt: new Date() }; }); } // M-QUIZ-01-01: 结果查询增加 userId 校验(与 submit 一致:userId 进入查询条件,避免 403 vs 404 暴露 attempt 存在性) async getResults(userId: string, attemptId: string) { const attempt = await this.prisma.quizAttempt.findUnique({ where: { id: attemptId, userId }, include: { answers: { include: { question: true } } }, }); if (!attempt) throw new NotFoundException('答题记录不存在'); return attempt; } async getHistory(userId: string) { return this.prisma.quizAttempt.findMany({ where: { userId }, orderBy: { startedAt: 'desc' }, take: 50, include: { quiz: { select: { title: true } } }, }); } }