forked from wangziqi/gongxue-base
feat: add tenant import console
This commit is contained in:
@@ -5,6 +5,7 @@ import { HttpError } from '../../core/http.js';
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type SpreadsheetImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
|
||||
type SupportedSpreadsheetFormat = 'json' | 'csv' | 'excel';
|
||||
type FieldMappingOverrides = Map<string, string>;
|
||||
|
||||
interface ParsedSpreadsheet {
|
||||
rows: JsonObject[];
|
||||
@@ -18,6 +19,134 @@ const MAX_SPREADSHEET_CELL_CHARS = 100_000;
|
||||
const MAX_EXCEL_FILE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const QUESTION_OPTION_KEYS = ['optionA', 'optionB', 'optionC', 'optionD', 'optionE', 'optionF', 'optionG', 'optionH'];
|
||||
const DANGEROUS_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const SAFE_DYNAMIC_FIELD = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
||||
|
||||
const OVERRIDE_TARGET_FIELDS: Record<SpreadsheetImportType, Set<string>> = {
|
||||
questions: new Set([
|
||||
'legacyId',
|
||||
'type',
|
||||
'typeLabel',
|
||||
'content',
|
||||
'options',
|
||||
...QUESTION_OPTION_KEYS,
|
||||
'answer',
|
||||
'correctOptionIndex',
|
||||
'correctOptionIndices',
|
||||
'answerText',
|
||||
'explanation',
|
||||
'difficulty',
|
||||
'tags',
|
||||
'mediaUrl',
|
||||
'subQuestions',
|
||||
'examMarkers',
|
||||
'codeLang',
|
||||
'codeTemplate',
|
||||
'metadata',
|
||||
]),
|
||||
vocabulary: new Set([
|
||||
'legacyId',
|
||||
'unitLegacyId',
|
||||
'unitId',
|
||||
'unitName',
|
||||
'unitDescription',
|
||||
'unitOrder',
|
||||
'wordLegacyId',
|
||||
'word',
|
||||
'phonetic',
|
||||
'meaning',
|
||||
'example',
|
||||
'exampleTranslation',
|
||||
'difficulty',
|
||||
'tags',
|
||||
'order',
|
||||
'wordCount',
|
||||
'isActive',
|
||||
'metadata',
|
||||
]),
|
||||
handbook: new Set([
|
||||
'legacyId',
|
||||
'subjectLegacyId',
|
||||
'subjectId',
|
||||
'subjectName',
|
||||
'subjectType',
|
||||
'chapterLegacyId',
|
||||
'chapterId',
|
||||
'chapterName',
|
||||
'sectionLegacyId',
|
||||
'sectionId',
|
||||
'sectionName',
|
||||
'entryLegacyId',
|
||||
'entryId',
|
||||
'title',
|
||||
'content',
|
||||
'summary',
|
||||
'tags',
|
||||
'description',
|
||||
'type',
|
||||
'icon',
|
||||
'color',
|
||||
'order',
|
||||
'isActive',
|
||||
'metadata',
|
||||
]),
|
||||
scoreline: new Set([
|
||||
'legacyId',
|
||||
'kind',
|
||||
'regionId',
|
||||
'schoolId',
|
||||
'schoolLegacyId',
|
||||
'schoolName',
|
||||
'schoolShortName',
|
||||
'schoolType',
|
||||
'shortName',
|
||||
'majorId',
|
||||
'majorLegacyId',
|
||||
'majorName',
|
||||
'year',
|
||||
'fieldKey',
|
||||
'fieldName',
|
||||
'fieldType',
|
||||
'unit',
|
||||
'isFilter',
|
||||
'isRequired',
|
||||
'isVisible',
|
||||
'isTrend',
|
||||
'options',
|
||||
'placeholder',
|
||||
'description',
|
||||
'sortOrder',
|
||||
'order',
|
||||
'fieldValues',
|
||||
'hasRestriction',
|
||||
'restrictionDesc',
|
||||
'isHot',
|
||||
'metadata',
|
||||
]),
|
||||
videos: new Set([
|
||||
'legacyId',
|
||||
'title',
|
||||
'description',
|
||||
'videoUrl',
|
||||
'thumbnailUrl',
|
||||
'duration',
|
||||
'durationSeconds',
|
||||
'knowledgeTags',
|
||||
'isGeneral',
|
||||
'subjectId',
|
||||
'difficulty',
|
||||
'order',
|
||||
'isActive',
|
||||
'assetId',
|
||||
'accessMode',
|
||||
'freePreviewSeconds',
|
||||
'questionId',
|
||||
'legacyQuestionId',
|
||||
'videoType',
|
||||
'bindings',
|
||||
'metadata',
|
||||
]),
|
||||
};
|
||||
|
||||
function objectValue(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
|
||||
@@ -34,6 +163,17 @@ function normalizedKey(value: string) {
|
||||
.replace(/[\s_\-./\\::()()[\]【】]/g, '');
|
||||
}
|
||||
|
||||
function assertSafeObjectKey(key: string) {
|
||||
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
||||
throw new HttpError(400, `Unsupported spreadsheet header: ${key}`, 'IMPORT_HEADER_UNSAFE');
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedOverrideTarget(importType: SpreadsheetImportType, field: string) {
|
||||
if (OVERRIDE_TARGET_FIELDS[importType].has(field)) return true;
|
||||
return importType === 'scoreline' && SAFE_DYNAMIC_FIELD.test(field) && !DANGEROUS_OBJECT_KEYS.has(field);
|
||||
}
|
||||
|
||||
function sourceFormatValue(value: unknown): SupportedSpreadsheetFormat {
|
||||
const raw = stringValue(value).toLowerCase();
|
||||
if (!raw || raw === 'json') return 'json';
|
||||
@@ -192,8 +332,29 @@ function commonHeaderAlias(header: string) {
|
||||
return aliases[key] || null;
|
||||
}
|
||||
|
||||
function importHeaderAlias(header: string, importType: SpreadsheetImportType) {
|
||||
function fieldMappingOverrides(body: JsonObject, importType: SpreadsheetImportType): FieldMappingOverrides {
|
||||
const raw = objectValue(body.fieldMappingOverrides ?? body.field_mapping_overrides);
|
||||
const mapping = new Map<string, string>();
|
||||
for (const [targetField, aliases] of Object.entries(raw)) {
|
||||
const canonicalField = stringValue(targetField);
|
||||
if (!canonicalField) continue;
|
||||
assertSafeObjectKey(canonicalField);
|
||||
if (!isAllowedOverrideTarget(importType, canonicalField)) {
|
||||
throw new HttpError(400, `Unsupported fieldMappingOverrides target field: ${canonicalField}`, 'IMPORT_FIELD_MAPPING_TARGET_INVALID');
|
||||
}
|
||||
const values = Array.isArray(aliases) ? aliases : String(aliases || '').split(/[,\n,]/);
|
||||
for (const alias of values) {
|
||||
const key = normalizedKey(String(alias || ''));
|
||||
if (key) mapping.set(key, canonicalField);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function importHeaderAlias(header: string, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) {
|
||||
const key = normalizedKey(header);
|
||||
const override = overrides.get(key);
|
||||
if (override) return override;
|
||||
const common = commonHeaderAlias(header);
|
||||
const aliases: Record<SpreadsheetImportType, Record<string, string>> = {
|
||||
questions: {
|
||||
@@ -380,10 +541,12 @@ function importHeaderAlias(header: string, importType: SpreadsheetImportType) {
|
||||
return aliases[importType][key] || common || header.trim();
|
||||
}
|
||||
|
||||
function normalizeSpreadsheetRow(raw: JsonObject, importType: SpreadsheetImportType) {
|
||||
function normalizeSpreadsheetRow(raw: JsonObject, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) {
|
||||
const row: JsonObject = {};
|
||||
for (const [header, value] of Object.entries(raw)) {
|
||||
const key = importHeaderAlias(header, importType);
|
||||
assertSafeObjectKey(header.trim());
|
||||
const key = importHeaderAlias(header, importType, overrides);
|
||||
assertSafeObjectKey(key);
|
||||
let coerced = coerceValue(key, value);
|
||||
if (importType === 'scoreline' && key === header.trim()) {
|
||||
coerced = parseNumericLike(coerced);
|
||||
@@ -475,7 +638,7 @@ function autoDelimiter(text: string) {
|
||||
.sort((left, right) => right.count - left.count)[0]?.delimiter || ',';
|
||||
}
|
||||
|
||||
function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType) {
|
||||
function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType, overrides: FieldMappingOverrides) {
|
||||
if (matrix.length < 2) return [];
|
||||
const headers = matrix[0].map((header, index) => stringValue(header) || `column${index + 1}`);
|
||||
if (headers.length > MAX_SPREADSHEET_COLUMNS) {
|
||||
@@ -485,9 +648,10 @@ function rowsFromMatrix(matrix: string[][], importType: SpreadsheetImportType) {
|
||||
const row: JsonObject = {};
|
||||
headers.forEach((header, index) => {
|
||||
const value = stringValue(line[index]);
|
||||
assertSafeObjectKey(header.trim());
|
||||
if (value) row[header] = value;
|
||||
});
|
||||
return normalizeSpreadsheetRow(row, importType);
|
||||
return normalizeSpreadsheetRow(row, importType, overrides);
|
||||
}).filter(row => Object.keys(row).length > 0);
|
||||
assertRowLimit(rows.length);
|
||||
return rows;
|
||||
@@ -500,7 +664,8 @@ function parseCsvBody(body: JsonObject, importType: SpreadsheetImportType): Pars
|
||||
throw new HttpError(400, 'CSV import requires csvText, fileContent, payload, or fileBase64', 'CSV_CONTENT_REQUIRED');
|
||||
}
|
||||
const delimiter = stringValue(body.delimiter) || autoDelimiter(text);
|
||||
const rows = rowsFromMatrix(parseCsv(text, delimiter), importType);
|
||||
const overrides = fieldMappingOverrides(body, importType);
|
||||
const rows = rowsFromMatrix(parseCsv(text, delimiter), importType, overrides);
|
||||
return {
|
||||
rows,
|
||||
sheets: { csv: rows },
|
||||
@@ -508,11 +673,12 @@ function parseCsvBody(body: JsonObject, importType: SpreadsheetImportType): Pars
|
||||
parser: 'csv',
|
||||
delimiter: delimiter === '\t' ? 'tab' : delimiter,
|
||||
rowCount: rows.length,
|
||||
fieldMappingOverrides: overrides.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImportType) {
|
||||
function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImportType, overrides: FieldMappingOverrides) {
|
||||
const matrix: string[][] = [];
|
||||
worksheet.eachRow({ includeEmpty: false }, row => {
|
||||
const values: string[] = [];
|
||||
@@ -523,7 +689,7 @@ function worksheetRows(worksheet: ExcelJS.Worksheet, importType: SpreadsheetImpo
|
||||
}
|
||||
if (values.some(value => value.trim())) matrix.push(values);
|
||||
});
|
||||
return rowsFromMatrix(matrix, importType);
|
||||
return rowsFromMatrix(matrix, importType, overrides);
|
||||
}
|
||||
|
||||
async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportType): Promise<ParsedSpreadsheet> {
|
||||
@@ -550,9 +716,10 @@ async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportTyp
|
||||
|
||||
const sheets: Record<string, JsonObject[]> = {};
|
||||
let totalRows = 0;
|
||||
const overrides = fieldMappingOverrides(body, importType);
|
||||
for (const sheet of workbook.worksheets) {
|
||||
if (sheet.actualRowCount === 0) continue;
|
||||
const rows = worksheetRows(sheet, importType);
|
||||
const rows = worksheetRows(sheet, importType, overrides);
|
||||
sheets[normalizedKey(sheet.name)] = rows;
|
||||
totalRows += rows.length;
|
||||
}
|
||||
@@ -568,6 +735,7 @@ async function parseExcelBody(body: JsonObject, importType: SpreadsheetImportTyp
|
||||
selectedSheet: selected.name,
|
||||
rowCount: selectedRows.length,
|
||||
totalParsedRows: totalRows,
|
||||
fieldMappingOverrides: overrides.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user