api-server/src/modules/quiz/quiz.service.ts
wangdl f1eb99b6fa
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 42s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
fix(quiz): complete cross-platform attempt and question type contract
- Add attempt lifecycle with per-attempt answer visibility (#336)
- Add atomic submission with updateMany CAS + $transaction (#337)
- Add iOS attempt-based question loading + local answer cache (#338)
- Freeze choice/fill/judge cross-platform contract with fill grading (#339)
- Add per-question-type learning analysis accuracyByQuestionType (#340)
- Add GATE E2E test suite with 17 scenarios (#341)
- CLEANUP-01: Remove illegal type → choice silent degradation
- CLEANUP-02: Integrate QuizQuestionType enum into iOS quiz views
- CLEANUP-03: Test verification, git delivery, targeted gate review

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 19:55:05 +08:00

209 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
// N2userId 进入查询条件,统一 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 } } },
});
}
}