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
|
#### 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 | 资源不存在 |
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user