diff --git a/docs/api-reference.md b/docs/api-reference.md index dc708c0..f6323f3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1348,7 +1348,141 @@ --- -### 分类管理 +#### POST /admin/questions/import + +批量导入题目(JSON 格式,全有或全无策略)。 + +**认证**: Admin Token + +**请求体**: +```json +{ + "questions": [ + { + "stem": { "text": "题目内容" }, + "contentType": "text | image | video | audio", + "correctAnswer": "正确答案 (必填)", + "distractors": ["干扰项1", "干扰项2"], + "categoryId": "分类ID (必填)", + "difficulty": 3, + "knowledgeCard": { + "summary": "知识点摘要 (必填)", + "deepDive": "深入解析", + "sourceRef": "来源引用" + } + } + ] +} +``` + +**参数说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| questions | array | 是 | 题目数组,1-200 条 | +| questions[].stem | object | 是 | 题干,至少包含 text 字段 | +| questions[].contentType | string | 是 | 内容类型:text/image/video/audio | +| questions[].correctAnswer | string | 是 | 正确答案,至少1字符 | +| questions[].distractors | string[] | 是 | 干扰项,至少2个 | +| questions[].categoryId | string | 是 | 所属分类 ID | +| questions[].difficulty | number | 否 | 难度 1-5 | +| questions[].knowledgeCard | object | 否 | 知识点卡片 | +| questions[].knowledgeCard.summary | string | 是* | 知识点摘要 | +| questions[].knowledgeCard.deepDive | string | 否 | 深入解析 | +| questions[].knowledgeCard.sourceRef | string | 否 | 来源引用 | + +**成功响应**: +```json +{ + "success": true, + "data": { + "total": 10, + "succeeded": 10, + "ids": ["uuid1", "uuid2", "..."] + }, + "error": null +} +``` + +**校验失败响应 (400)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_FAILED", + "message": "部分题目校验失败", + "details": [ + { "index": 2, "errors": ["distractors: 必须包含至少2个元素"] }, + { "index": 5, "errors": ["categoryId: 必填字段"] } + ] + } +} +``` + +**说明**: +- 导入的题目默认状态为 `draft` +- 全有或全无:任一条目校验失败则全部不导入 +- 所有条目先校验完毕,再统一报告错误,最后才执行事务插入 +- 校验包含 categoryId 外键存在性检查:不存在的分类 ID 会触发 `VALIDATION_FAILED` 错误 + +--- + +#### POST /admin/questions/import-csv + +批量导入题目(CSV 格式,全有或全无策略)。 + +**认证**: Admin Token + +**Content-Type**: `text/plain` + +**请求体**: CSV 文本,首行为表头 + +**CSV 表头(固定列顺序)**: +``` +categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef +``` + +**CSV 示例**: +```csv +categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef +history,text,1,秦始皇统一六国是在哪一年?,公元前221年,公元前206年,公元前256年,公元前230年,,,秦始皇嬴政于公元前221年完成统一,建立了中国历史上第一个大一统王朝,《史记·秦始皇本纪》 +history,text,2,被称为'诗仙'的唐代诗人是谁?,李白,杜甫,白居易,王维,,李白是唐代最伟大的浪漫主义诗人,, +``` + +**列说明**: +| 列名 | 必填 | 说明 | +|------|------|------| +| categoryId | 是 | 分类 ID | +| contentType | 是 | text/image/video/audio | +| difficulty | 否 | 难度 1-5,留空则不设置 | +| stemText | 是 | 题目文本 | +| correctAnswer | 是 | 正确答案 | +| distractor1-5 | 至少填2个 | 干扰项,留空的列会被忽略 | +| cardSummary | 否 | 知识点摘要(填则整行知识卡片必填 summary) | +| cardDeepDive | 否 | 深入解析 | +| cardSourceRef | 否 | 来源引用 | + +**成功/失败响应**: 与 JSON 导入相同格式。 + +**说明**: +- CSV 字段可用双引号包裹,支持字段内逗号和换行 +- 字段内的双引号用 `""` 表示 +- categoryId 外键存在性校验与 JSON 导入一致 +- 单次导入上限 200 条,超出返回 `VALIDATION_ERROR` + +**额外错误**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "CSV_PARSE_ERROR", + "message": "CSV 表头列数应为 13,实际 10" + } +} +``` + +--- #### GET /admin/categories @@ -1655,6 +1789,8 @@ | 代码 | 说明 | |------|------| | VALIDATION_ERROR | 请求参数验证失败 | +| VALIDATION_FAILED | 批量导入中部分题目校验失败 | +| CSV_PARSE_ERROR | CSV 解析失败(格式或表头不匹配) | | UNAUTHORIZED | 未认证或认证失败 | | FORBIDDEN | 权限不足(需要 super_admin) | | NOT_FOUND | 资源不存在 | diff --git a/src/routes/admin/questions.ts b/src/routes/admin/questions.ts index d90f06a..35a7d7b 100644 --- a/src/routes/admin/questions.ts +++ b/src/routes/admin/questions.ts @@ -16,6 +16,21 @@ const createQuestionSchema = z.object({ }).optional(), }); +/** 单条导入项的校验 schema */ +const importItemSchema = createQuestionSchema; + +/** 导入数组 body schema */ +const importBodySchema = z.object({ + questions: z.array(importItemSchema).min(1).max(200), +}); + +/** 用 Zod schema 校验单条数据,返回错误消息数组或 null */ +function validateImportItem(item: unknown): string[] | null { + const result = importItemSchema.safeParse(item); + if (result.success) return null; + return result.error.issues.map((iss) => `${iss.path.join('.')}: ${iss.message}`); +} + const updateQuestionSchema = z.object({ stem: z.record(z.unknown()).optional(), contentType: z.enum(['text', 'image', 'video', 'audio']).optional(), @@ -121,4 +136,67 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise const data = await questionService.batchArchive(parsed.data.ids); return { success: true, data, error: null }; }); + + // ── 批量导入(JSON) ── + app.post('/import', async (request, reply) => { + const parsed = importBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, data: null, + error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message }, + }); + } + + try { + const data = await questionService.batchImportQuestions(parsed.data.questions, validateImportItem); + return { success: true, data, error: null }; + } catch (err) { + if (err instanceof Error && err.message === 'VALIDATION_FAILED') { + return reply.status(400).send({ + success: false, data: null, + error: { code: 'VALIDATION_FAILED', message: '部分题目校验失败', details: (err as unknown as { errors: questionService.ImportError[] }).errors }, + }); + } + throw err; + } + }); + + // ── 批量导入(CSV) ── + app.post('/import-csv', async (request, reply) => { + const csvText = typeof request.body === 'string' ? request.body : String(request.body ?? ''); + if (!csvText.trim()) { + return reply.status(400).send({ + success: false, data: null, + error: { code: 'VALIDATION_ERROR', message: 'CSV 内容不能为空' }, + }); + } + + let items: questionService.ImportQuestionData[]; + try { + items = questionService.parseCsv(csvText); + } catch (err) { + const message = err instanceof Error ? err.message : 'CSV 解析失败'; + return reply.status(400).send({ success: false, data: null, error: { code: 'CSV_PARSE_ERROR', message } }); + } + + if (items.length === 0) { + return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: 'CSV 无有效数据行' } }); + } + if (items.length > 200) { + return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: `单次导入上限 200 条,当前 ${items.length} 条` } }); + } + + try { + const data = await questionService.batchImportQuestions(items, validateImportItem); + return { success: true, data, error: null }; + } catch (err) { + if (err instanceof Error && err.message === 'VALIDATION_FAILED') { + return reply.status(400).send({ + success: false, data: null, + error: { code: 'VALIDATION_FAILED', message: '部分题目校验失败', details: (err as unknown as { errors: questionService.ImportError[] }).errors }, + }); + } + throw err; + } + }); } diff --git a/src/services/admin/question-service.ts b/src/services/admin/question-service.ts index f7e1cf8..81d19bc 100644 --- a/src/services/admin/question-service.ts +++ b/src/services/admin/question-service.ts @@ -1,8 +1,129 @@ import { db } from '../../db/client.js'; -import { questions, knowledgeCards } from '../../db/schema.js'; +import { questions, knowledgeCards, categories } from '../../db/schema.js'; import { eq, and, sql, inArray } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; +/** 单条导入数据的输入结构 */ +export interface ImportQuestionData { + stem: unknown; + contentType: string; + correctAnswer: string; + distractors: unknown; + categoryId: string; + difficulty?: number; + knowledgeCard?: { summary: string; deepDive?: string; sourceRef?: string }; +} + +export interface ImportResult { + total: number; + succeeded: number; + ids: string[]; +} + +export interface ImportError { + index: number; + errors: string[]; +} + +/** CSV 列顺序定义 */ +const CSV_COLUMNS = [ + 'categoryId', 'contentType', 'difficulty', 'stemText', + 'correctAnswer', 'distractor1', 'distractor2', 'distractor3', + 'distractor4', 'distractor5', 'cardSummary', 'cardDeepDive', 'cardSourceRef', +] as const; + +/** + * 解析 CSV 文本为题目数据数组 + * - 首行为表头(必须匹配 CSV_COLUMNS) + * - 字段可用双引号包裹,支持内部逗号和换行 + */ +export function parseCsv(csvText: string): ImportQuestionData[] { + const lines = splitCsvLines(csvText.trim()); + if (lines.length < 2) throw new Error('CSV 至少需要表头行和一行数据'); + + const header = parseCsvRow(lines[0]!); + if (header.length !== CSV_COLUMNS.length) { + throw new Error(`CSV 表头列数应为 ${CSV_COLUMNS.length},实际 ${header.length}`); + } + for (let i = 0; i < CSV_COLUMNS.length; i++) { + if (header[i] !== CSV_COLUMNS[i]) { + throw new Error(`CSV 表头第 ${i + 1} 列应为 "${CSV_COLUMNS[i]}",实际 "${header[i]}"`); + } + } + + const result: ImportQuestionData[] = []; + for (let i = 1; i < lines.length; i++) { + const fields = parseCsvRow(lines[i]!); + if (fields.length === 1 && fields[0] === '') continue; // 跳过空行 + + const distractors: string[] = []; + for (let d = 0; d < 5; d++) { + const val = fields[5 + d]?.trim(); + if (val) distractors.push(val); + } + + const difficultyStr = fields[2]?.trim(); + const cardSummary = fields[10]?.trim(); + const cardDeepDive = fields[11]?.trim(); + const cardSourceRef = fields[12]?.trim(); + + result.push({ + categoryId: fields[0] ?? '', + contentType: fields[1] ?? '', + ...(difficultyStr ? { difficulty: Number(difficultyStr) } : {}), + stem: { text: fields[3] ?? '' }, + correctAnswer: fields[4] ?? '', + distractors, + ...(cardSummary ? { knowledgeCard: { summary: cardSummary, ...(cardDeepDive ? { deepDive: cardDeepDive } : {}), ...(cardSourceRef ? { sourceRef: cardSourceRef } : {}) } } : {}), + }); + } + return result; +} + +/** 按 CSV 规则拆分多行(处理双引号内的换行) */ +function splitCsvLines(text: string): string[] { + const lines: string[] = []; + let current = ''; + let inQuotes = false; + for (const ch of text) { + if (ch === '"') { inQuotes = !inQuotes; current += ch; } + else if ((ch === '\n' || ch === '\r') && !inQuotes) { + if (ch === '\r') { /* skip CR */ } + else { lines.push(current); current = ''; } + } else { current += ch; } + } + if (current) lines.push(current); + return lines; +} + +/** 解析单行 CSV(处理双引号内的逗号和 "" 转义) */ +function parseCsvRow(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + let i = 0; + while (i < line.length) { + const ch = line[i]!; + const next = line[i + 1]; + if (ch === '"' && next === '"' && inQuotes) { + current += '"'; + i += 2; + } else if (ch === '"') { + inQuotes = !inQuotes; + i++; + } else if (ch === ',' && !inQuotes) { + fields.push(current); + current = ''; + i++; + } else { + current += ch; + i++; + } + } + fields.push(current); + return fields; +} + interface ListOptions { page?: number; limit?: number; @@ -171,3 +292,87 @@ export async function batchPublish(ids: string[]): Promise { export async function batchArchive(ids: string[]): Promise { return batchUpdateStatus(ids, 'archived'); } + +/** + * 批量导入题目(全有或全无策略) + * 1. 先校验所有条目 + * 2. 全部通过后开启事务插入 + * 3. 任一失败则全部回滚 + */ +export async function batchImportQuestions( + items: ImportQuestionData[], + validate: (item: ImportQuestionData) => string[] | null, +): Promise { + // Phase 1: 先验校验所有条目 + const errors: ImportError[] = []; + const validated: ImportQuestionData[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const itemErrors = validate(item); + if (itemErrors && itemErrors.length > 0) { + errors.push({ index: i, errors: itemErrors }); + } else { + validated.push(item); + } + } + + if (errors.length > 0) { + throw Object.assign(new Error('VALIDATION_FAILED'), { errors }); + } + + // Phase 1.5: 校验 categoryId 外键存在性 + const uniqueCategoryIds = [...new Set(validated.map((item) => item.categoryId))]; + if (uniqueCategoryIds.length > 0) { + const existing = await db.select({ id: categories.id }) + .from(categories) + .where(inArray(categories.id, uniqueCategoryIds)); + const validCategoryIds = new Set(existing.map((c) => c.id)); + + const categoryErrors: ImportError[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + if (!validCategoryIds.has(item.categoryId)) { + categoryErrors.push({ index: i, errors: [`categoryId "${item.categoryId}" 不存在`] }); + } + } + if (categoryErrors.length > 0) { + throw Object.assign(new Error('VALIDATION_FAILED'), { errors: categoryErrors }); + } + } + + // Phase 2: 事务插入 + const ids = await db.transaction(async (tx) => { + const insertedIds: string[] = []; + + for (const item of validated) { + const id = uuid(); + await tx.insert(questions).values({ + id, + stem: item.stem, + contentType: item.contentType as 'text', + correctAnswer: item.correctAnswer, + distractors: item.distractors, + categoryId: item.categoryId, + difficulty: item.difficulty, + status: 'draft', + }); + + if (item.knowledgeCard) { + await tx.insert(knowledgeCards).values({ + id: uuid(), + questionId: id, + summary: item.knowledgeCard.summary, + deepDive: item.knowledgeCard.deepDive ?? null, + sourceRef: item.knowledgeCard.sourceRef ?? null, + }); + } + + insertedIds.push(id); + } + + return insertedIds; + }); + + return { total: items.length, succeeded: ids.length, ids }; +}