gwy-exam/server/src/handlers/practice.ts

317 lines
12 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 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<number, number> = { 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<Question[]>(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<typeof PracticeStartBodySchema>
const questions = await readData<Question[]>(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<PracticeSession[]>(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<typeof PracticeStartBodySchema>,
questions: Question[],
byId: Map<string, Question>
): 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<PracticeSession> {
const sessions = await readData<PracticeSession[]>(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<Question[]>(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<typeof PracticeAnswerBodySchema>
const session = await findSession(sessionId)
if (session.status === 'finished') throw ApiError.conflict('会话已结束')
const [records, questions] = await Promise.all([
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<Question[]>(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<PracticeRecord[]>(dataFiles.practiceRecords, [], (list) => [...list, record])
await updateData<PracticeSession[]>(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<WrongQuestion[]>(dataFiles.wrongQuestions, [])
const today = todayISO()
const existing = wrongQuestions.find((w) => w.questionId === questionId)
if (existing) {
await updateData<WrongQuestion[]>(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<WrongQuestion[]>(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<WrongQuestion[]>(dataFiles.wrongQuestions, [])
const since = addDays(todayISO(), -(WRONG_REASON_WINDOW_DAYS - 1))
const counts = new Map<string, number>()
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<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<Question[]>(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<PracticeSession[]>(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
}
}