feat: 添加题目批量导入接口(JSON + CSV)

- POST /admin/questions/import 支持 JSON 数组导入(1-200 条)
- POST /admin/questions/import-csv 支持 CSV 文本导入
- 全有或全无事务策略,先验校验后统一插入
- 包含 categoryId 外键存在性校验
- CSV 解析器支持引号内逗号、换行和 "" 转义
This commit is contained in:
Wang Zhuoxuan 2026-04-11 23:23:09 +08:00
parent 1b142f2866
commit aeebcba77c
3 changed files with 421 additions and 2 deletions

View File

@ -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 #### GET /admin/categories
@ -1655,6 +1789,8 @@
| 代码 | 说明 | | 代码 | 说明 |
|------|------| |------|------|
| VALIDATION_ERROR | 请求参数验证失败 | | VALIDATION_ERROR | 请求参数验证失败 |
| VALIDATION_FAILED | 批量导入中部分题目校验失败 |
| CSV_PARSE_ERROR | CSV 解析失败(格式或表头不匹配) |
| UNAUTHORIZED | 未认证或认证失败 | | UNAUTHORIZED | 未认证或认证失败 |
| FORBIDDEN | 权限不足(需要 super_admin | | FORBIDDEN | 权限不足(需要 super_admin |
| NOT_FOUND | 资源不存在 | | NOT_FOUND | 资源不存在 |

View File

@ -16,6 +16,21 @@ const createQuestionSchema = z.object({
}).optional(), }).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({ const updateQuestionSchema = z.object({
stem: z.record(z.unknown()).optional(), stem: z.record(z.unknown()).optional(),
contentType: z.enum(['text', 'image', 'video', 'audio']).optional(), contentType: z.enum(['text', 'image', 'video', 'audio']).optional(),
@ -121,4 +136,67 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise<void>
const data = await questionService.batchArchive(parsed.data.ids); const data = await questionService.batchArchive(parsed.data.ids);
return { success: true, data, error: null }; 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;
}
});
} }

View File

@ -1,8 +1,129 @@
import { db } from '../../db/client.js'; 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 { eq, and, sql, inArray } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; 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 { interface ListOptions {
page?: number; page?: number;
limit?: number; limit?: number;
@ -171,3 +292,87 @@ export async function batchPublish(ids: string[]): Promise<BatchResult> {
export async function batchArchive(ids: string[]): Promise<BatchResult> { export async function batchArchive(ids: string[]): Promise<BatchResult> {
return batchUpdateStatus(ids, 'archived'); return batchUpdateStatus(ids, 'archived');
} }
/**
*
* 1.
* 2.
* 3.
*/
export async function batchImportQuestions(
items: ImportQuestionData[],
validate: (item: ImportQuestionData) => string[] | null,
): Promise<ImportResult> {
// 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 };
}