feat: 添加题目批量导入接口(JSON + CSV)
- POST /admin/questions/import 支持 JSON 数组导入(1-200 条) - POST /admin/questions/import-csv 支持 CSV 文本导入 - 全有或全无事务策略,先验校验后统一插入 - 包含 categoryId 外键存在性校验 - CSV 解析器支持引号内逗号、换行和 "" 转义
This commit is contained in:
parent
1b142f2866
commit
aeebcba77c
@ -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 | 资源不存在 |
|
||||
|
||||
@ -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<void>
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<BatchResult> {
|
||||
export async function batchArchive(ids: string[]): Promise<BatchResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user