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

167 lines
6.1 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 type {
PracticeRecord,
PracticeSession,
Question,
WrongQuestion
} from '../schemas/entities.js'
import type { z } from 'zod'
import {
importItemMessage,
QuestionImportItemSchema,
QuestionsImportBodySchema,
QuestionsListQuerySchema
} from '../schemas/api.js'
/** 内容指纹:题干 + 模块 + 答案 + 选项文本(排序后),用于导入去重 */
function contentFingerprint(q: Pick<Question, 'stem' | 'module' | 'answer' | 'options'>) {
const options = [...q.options.map((o) => `${o.key}:${o.text}`)].sort().join('|')
return `${q.stem}\u0000${q.module}\u0000${q.answer}\u0000${options}`
}
/** 题库列表:模块 / 难度 / 关键词筛选 + 分页 */
export async function list(request: FastifyRequest) {
const query = request.query as z.infer<typeof QuestionsListQuerySchema>
const questions = await readData<Question[]>(dataFiles.questions, [])
const filtered = questions
.filter((q) => (query.module ? q.module === query.module : true))
.filter((q) => (query.difficulty ? q.difficulty === query.difficulty : true))
.filter((q) => (query.keyword ? q.stem.includes(query.keyword) : true))
const start = (query.page - 1) * query.pageSize
return {
items: filtered.slice(start, start + query.pageSize),
total: filtered.length,
page: query.page,
pageSize: query.pageSize
}
}
/**
* 题目 JSON 导入。任务07
* - 后台生成 UUIDid 由 newId('q') 生成createdAt 取当前时间;
* - 按「题干 + 模块 + 答案 + 选项文本」内容指纹去重(含与题库内已有题目的比对);
* - 逐条校验,任何一条格式错误不影响其它有效题导入,返回导入结果报告。
*/
export async function importQuestions(request: FastifyRequest) {
const body = request.body as z.infer<typeof QuestionsImportBodySchema>
const current = await readData<Question[]>(dataFiles.questions, [])
const seen = new Set(current.map((q) => contentFingerprint(q)))
const errors: { index: number; message: string }[] = []
const added: Question[] = []
let skipped = 0
body.questions.forEach((raw, index) => {
const parsed = QuestionImportItemSchema.safeParse(raw)
if (!parsed.success) {
errors.push({ index, message: importItemMessage(parsed.error) })
return
}
const item = parsed.data
const fingerprint = contentFingerprint(item)
if (seen.has(fingerprint)) {
skipped += 1
return
}
seen.add(fingerprint)
added.push({
id: newId('q'),
type: item.type,
module: item.module,
subModule: item.subModule,
difficulty: item.difficulty,
stem: item.stem,
options: item.options,
answer: item.answer,
analysis: item.analysis,
tags: item.tags,
source: item.source,
createdAt: new Date().toISOString()
})
})
if (added.length > 0) {
await updateData<Question[]>(dataFiles.questions, [], (questions) => [...questions, ...added])
}
return {
total: body.questions.length,
success: added.length,
skipped,
failed: errors.length,
errors
}
}
/** 导出当前题库(与导入格式一致,不含 id */
export async function exportQuestions() {
const questions = await readData<Question[]>(dataFiles.questions, [])
return {
version: '1.0',
exportedAt: new Date().toISOString(),
questions: questions.map(({ id: _id, createdAt: _createdAt, ...rest }) => rest)
}
}
/**
* 统计一道题被「错题 / 作答记录 / 刷题会话」引用的数量。
* 供删除预检(前端删除弹层提前展示)与 remove 校验共用。
*/
export async function countReferences(id: string) {
const [wrongQuestions, practiceRecords, practiceSessions] = await Promise.all([
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<PracticeSession[]>(dataFiles.practiceSessions, [])
])
return {
wrongQuestions: wrongQuestions.filter((w) => w.questionId === id).length,
practiceRecords: practiceRecords.filter((r) => r.questionId === id).length,
practiceSessions: practiceSessions.filter((s) => s.questionIds.includes(id)).length
}
}
/** 删除前预检:返回该题引用数量,便于前端弹层提前展示;题目不存在抛 404 */
export async function refs(request: FastifyRequest) {
const { id } = request.params as { id: string }
const questions = await readData<Question[]>(dataFiles.questions, [])
if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在')
const ref = await countReferences(id)
return {
wrongQuestions: ref.wrongQuestions,
practiceRecords: ref.practiceRecords,
practiceSessions: ref.practiceSessions,
total: ref.wrongQuestions + ref.practiceRecords + ref.practiceSessions
}
}
/**
* 删除题目。任务07
* - 删前校验该题是否被「错题 / 作答记录 / 刷题会话」引用;
* - 被引用则抛 409 提示先处理关联数据,避免产生悬挂引用;
* - 无引用则从 questions.json 移除。
*/
export async function remove(request: FastifyRequest) {
const { id } = request.params as { id: string }
const questions = await readData<Question[]>(dataFiles.questions, [])
if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在')
const referenced = await countReferences(id)
if (referenced.wrongQuestions + referenced.practiceRecords + referenced.practiceSessions > 0) {
const parts: string[] = []
if (referenced.wrongQuestions > 0) parts.push(`错题 ${referenced.wrongQuestions}`)
if (referenced.practiceRecords > 0) parts.push(`作答记录 ${referenced.practiceRecords}`)
if (referenced.practiceSessions > 0) parts.push(`刷题会话 ${referenced.practiceSessions}`)
throw ApiError.conflict(`题目已被${parts.join('、')}引用,无法删除`)
}
await updateData<Question[]>(dataFiles.questions, [], (list) => list.filter((q) => q.id !== id))
return { success: true }
}