feat: add tenant import console

This commit is contained in:
Codex
2026-06-29 13:35:34 +08:00
parent 65905aaf8f
commit 93ec1a07d2
13 changed files with 623 additions and 30 deletions

View File

@@ -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,
},
};
}