import type { FastifyRequest } from 'fastify' import { readData, updateData } from '../data/store.js' import { dataFiles } from '../data/files.js' import { ApiError } from '../errors.js' import { newId } from '../utils/ids.js' import { MODULES } from '../schemas/entities.js' import type { PracticeRecord, PracticeSession, Question, WrongQuestion } from '../schemas/entities.js' import type { z } from 'zod' import { PracticeAnswerBodySchema, PracticeStartBodySchema } from '../schemas/api.js' import { addDays, todayISO } from '../utils/dates.js' /** 时长(分钟) → 目标题量(需求:5/10/15 分钟对应约 5/10/15 题) */ const DURATION_TO_COUNT: Record = { 5: 5, 10: 10, 15: 15 } /** 自定义组卷的题量上限 */ const CUSTOM_MAX = 30 /** 错因分布窗口:最近 30 天 */ const WRONG_REASON_WINDOW_DAYS = 30 /** 五大行测模块与题量 */ export async function modules() { const questions = await readData(dataFiles.questions, []) const modules = MODULES.map((name) => ({ name, total: questions.filter((q) => q.module === name).length })) return { modules } } /** 从模块题库中随机抽取指定题量;题库不足时返回全部并打乱 */ export function pickRandom(pool: Question[], count: number): Question[] { const shuffled = [...pool].sort(() => Math.random() - 0.5) return shuffled.slice(0, Math.min(count, shuffled.length)) } interface StartResult { module: (typeof MODULES)[number] durationMinutes: number questionIds: string[] } /** * 开始刷题: * - 自定义组卷:body.custom 直接指定题目 ID(需存在且在题库); * - 普通组卷:按模块 + 时长换算题量随机抽题。 * 持久化会话(recordIndex = -1),返回会话视图。 */ export async function start(request: FastifyRequest) { const body = request.body as z.infer const questions = await readData(dataFiles.questions, []) const byId = new Map(questions.map((q) => [q.id, q])) const picked: StartResult = buildQuestionIds(body, questions, byId) const session: PracticeSession = { id: newId('session'), questionIds: picked.questionIds, mode: body.custom ? 'custom' : 'quick', module: picked.module, durationMinutes: picked.durationMinutes, startedAt: new Date().toISOString(), recordIndex: -1, status: 'active' } await updateData(dataFiles.practiceSessions, [], (sessions) => [...sessions, session]) return { sessionId: session.id, module: session.module, durationMinutes: session.durationMinutes, questionIds: session.questionIds, total: session.questionIds.length, startedAt: session.startedAt, status: session.status as 'active' } } /** 组卷:解析 custom 指定的题目或按模块+时长随机抽取 */ function buildQuestionIds( body: z.infer, questions: Question[], byId: Map ): StartResult { if (body.custom) { if (body.custom.length === 0) throw ApiError.conflict('自定义组卷至少需要一题') if (body.custom.length > CUSTOM_MAX) throw ApiError.conflict(`自定义组卷最多 ${CUSTOM_MAX} 题`) const deduped = [...new Set(body.custom)] const missing = deduped.filter((id) => !byId.has(id)) if (missing.length > 0) throw ApiError.conflict(`存在不存在的题目:${missing.join('、')}`) const module = byId.get(deduped[0])!.module return { module, durationMinutes: body.duration, questionIds: deduped } } const pool = questions.filter((q) => q.module === body.module) if (pool.length === 0) throw ApiError.conflict('该模块暂无题目,请先导入题目') const count = DURATION_TO_COUNT[body.duration] ?? 5 return { module: body.module, durationMinutes: body.duration, questionIds: pickRandom(pool, count).map((q) => q.id) } } async function findSession(sessionId: string): Promise { const sessions = await readData(dataFiles.practiceSessions, []) const session = sessions.find((s) => s.id === sessionId) if (!session) throw ApiError.notFound('刷题会话不存在') return session } /** 当前题目(不返回答案与解析)。按 recordIndex 推进:-1→第0题,已答完 last→null */ export async function currentQuestion(request: FastifyRequest) { const { sessionId } = request.params as { sessionId: string } const session = await findSession(sessionId) if (session.status === 'finished') throw ApiError.conflict('会话已结束') const nextIndex = session.recordIndex + 1 if (nextIndex >= session.questionIds.length) { return { question: null, index: session.questionIds.length - 1, total: session.questionIds.length, answered: nextIndex, durationMinutes: session.durationMinutes } } const questions = await readData(dataFiles.questions, []) const question = questions.find((q) => q.id === session.questionIds[nextIndex]) if (!question) throw ApiError.notFound('会话题目不存在于题库') return { question: { id: question.id, module: question.module, subModule: question.subModule, difficulty: question.difficulty, stem: question.stem, options: question.options }, index: nextIndex, total: session.questionIds.length, answered: nextIndex, durationMinutes: session.durationMinutes } } /** * 提交单题作答: * - 按 sessionId + questionId 幂等校验(重复提交不重复计入,accepted=false + duplicate=true); * - 校验题目属于该会话且是与 recordIndex+1 对齐的当前题; * - 写入作答记录,递增会话 recordIndex。 */ export async function answer(request: FastifyRequest) { const { sessionId } = request.params as { sessionId: string } const body = request.body as z.infer const session = await findSession(sessionId) if (session.status === 'finished') throw ApiError.conflict('会话已结束') const [records, questions] = await Promise.all([ readData(dataFiles.practiceRecords, []), readData(dataFiles.questions, []) ]) // 幂等:同一会话同一题已作答则跳过,不重复计入 const existing = records.find((r) => r.sessionId === sessionId && r.questionId === body.questionId) if (existing) { return { accepted: false, duplicate: true, answeredCount: recordCount(records, sessionId) } } // 校验题目在当前会话内且为待作答的当前题(禁止乱序/跨题提交) const expectedIndex = session.recordIndex + 1 if (session.questionIds[expectedIndex] !== body.questionId) { throw ApiError.conflict('题目与会话作答顺序不符') } const question = questions.find((q) => q.id === body.questionId) if (!question) throw ApiError.notFound('题目不存在于题库') const answerKey = Number(body.answer) const correct = question.answer === body.answer const record: PracticeRecord = { questionId: body.questionId, sessionId, userAnswer: body.answer, correct, secondsUsed: body.secondsUsed, answeredAt: new Date().toISOString() } await updateData(dataFiles.practiceRecords, [], (list) => [...list, record]) await updateData(dataFiles.practiceSessions, [], (sessions) => sessions.map((s) => (s.id === sessionId ? { ...s, recordIndex: expectedIndex } : s)) ) // 错题沉淀:答错立即写入/重置错题记录(任务 09) if (!correct) { await upsertWrongQuestion(body.questionId) } return { accepted: true, answeredCount: recordCount(records, sessionId) + 1, correct, correctAnswer: question.answer, analysis: question.analysis } } /** * 错题沉淀(答错时):已存在该题错题记录 → 重置为当天待复习; * 不存在 → 新建(status=pending, reviewCount=0, nextReviewAt=today)。 */ async function upsertWrongQuestion(questionId: string) { const wrongQuestions = await readData(dataFiles.wrongQuestions, []) const today = todayISO() const existing = wrongQuestions.find((w) => w.questionId === questionId) if (existing) { await updateData(dataFiles.wrongQuestions, [], (list) => list.map((w) => w.id === existing.id ? { ...w, status: 'pending', reviewCount: 0, nextReviewAt: today, updatedAt: new Date().toISOString() } : w ) ) } else { const wrong: WrongQuestion = { id: newId('wrong'), questionId, wrongReason: '', status: 'pending', reviewCount: 0, nextReviewAt: today, updatedAt: new Date().toISOString() } await updateData(dataFiles.wrongQuestions, [], (list) => [...list, wrong]) } } /** 汇总会话的作答记录数 */ function recordCount(records: PracticeRecord[], sessionId: string): number { return records.filter((r) => r.sessionId === sessionId).length } /** * 错因分布(最近 30 天):按错因聚合计数并计算占比。 * 数据来源为错题记录(wrong-question.wrongReason);无记录返回空数组。 */ export async function wrongReasons() { const wrongQuestions = await readData(dataFiles.wrongQuestions, []) const since = addDays(todayISO(), -(WRONG_REASON_WINDOW_DAYS - 1)) const counts = new Map() let total = 0 for (const wrong of wrongQuestions) { if (wrong.updatedAt.slice(0, 10) < since) continue const reason = wrong.wrongReason || '未标记错因' counts.set(reason, (counts.get(reason) ?? 0) + 1) total += 1 } if (total === 0) return { reasons: [] } const reasons = [...counts.entries()] .map(([reason, count]) => ({ reason, count, percent: Math.round((count / total) * 100) })) .sort((a, b) => b.count - a.count) return { reasons } } /** * 结束会话并返回结果: * - 只对已作答的题计分(未作答不计入正确率); * - 计算得分、正确率、总耗时; * - 标记会话 finished;已结束会话重复调用返回 409; * - 错题沉淀在单题作答时(practice.answer 答错)已写入 wrong-questions.json(任务 09), * 本接口仅汇总返回本卷错题列表,供结果页展示。 */ export async function finish(request: FastifyRequest) { const { sessionId } = request.params as { sessionId: string } const session = await findSession(sessionId) if (session.status === 'finished') throw ApiError.conflict('会话已结束') const [records, questions] = await Promise.all([ readData(dataFiles.practiceRecords, []), readData(dataFiles.questions, []) ]) const sessionRecords = records.filter((r) => r.sessionId === sessionId) const answers = new Map(questions.map((q) => [q.id, q])) const answeredIds = new Set(sessionRecords.map((r) => r.questionId)) const questionsList = session.questionIds .filter((id) => answeredIds.has(id)) .map((id) => { const record = sessionRecords.find((r) => r.questionId === id)! const question = answers.get(id) return { questionId: id, userAnswer: record.userAnswer, correctAnswer: question?.answer ?? ('' as never), correct: record.correct, analysis: question?.analysis ?? '' } }) const total = questionsList.length const correctCount = questionsList.filter((q) => q.correct).length const accuracy = total === 0 ? 0 : Math.round((correctCount / total) * 100) const durationSeconds = sessionRecords.reduce((sum, r) => sum + r.secondsUsed, 0) const wrongQuestionIds = questionsList.filter((q) => !q.correct).map((q) => q.questionId) await updateData(dataFiles.practiceSessions, [], (sessions) => sessions.map((s) => (s.id === sessionId ? { ...s, status: 'finished', endedAt: new Date().toISOString() } : s)) ) return { sessionId: session.id, total: session.questionIds.length, answered: total, correctCount, accuracy, durationSeconds, wrongQuestionIds, questions: questionsList } }