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) { 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 const questions = await readData(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: * - 后台生成 UUID(id 由 newId('q') 生成),createdAt 取当前时间; * - 按「题干 + 模块 + 答案 + 选项文本」内容指纹去重(含与题库内已有题目的比对); * - 逐条校验,任何一条格式错误不影响其它有效题导入,返回导入结果报告。 */ export async function importQuestions(request: FastifyRequest) { const body = request.body as z.infer const current = await readData(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(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(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(dataFiles.wrongQuestions, []), readData(dataFiles.practiceRecords, []), readData(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(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(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(dataFiles.questions, [], (list) => list.filter((q) => q.id !== id)) return { success: true } }