317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
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
|
||
}
|
||
}
|