167 lines
6.1 KiB
TypeScript
167 lines
6.1 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 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:
|
||
* - 后台生成 UUID(id 由 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 }
|
||
}
|