import { BadRequestException } from '@nestjs/common'; import type { AiReviewRow, AiReviewSection, AiReviewSectionType, } from './entities/ai-review.entity'; import { assertKeys, COLUMN_KEYS_ALLOWED, COLUMN_KEY_RE, isPlainRecord, MAX_CELL_LENGTH, MAX_COLUMNS, MAX_COLUMN_KEY, MAX_COLUMN_TITLE, MAX_ISSUES, MAX_ISSUE_LENGTH, MAX_ROWS, MAX_SECTIONS, MAX_SECTION_TITLE, MAX_SUMMARY, MAX_TITLE, normalizeSectionType, requireString, SCHEMA_KEYS, SECTION_ALIASES, SECTION_KEYS_ALLOWED, SECTION_KEY_RE, ValidatedReviewSchema, } from './ai-review.shared'; export function validateSchema(rawArgs: unknown): ValidatedReviewSchema { if (!isPlainRecord(rawArgs)) throw new BadRequestException('导入预览参数必须是对象'); assertKeys(rawArgs, SCHEMA_KEYS, '导入预览'); const title = requireString(rawArgs.title, '预览标题', MAX_TITLE); const summary = requireString(rawArgs.summary, '预览说明', MAX_SUMMARY, true) || null; if (!Array.isArray(rawArgs.sections) || rawArgs.sections.length === 0) { throw new BadRequestException('导入预览至少需要一个分表'); } if (rawArgs.sections.length > MAX_SECTIONS) { throw new BadRequestException(`分表数量不能超过 ${MAX_SECTIONS}`); } const seenKeys = new Set(); const sections = rawArgs.sections.map((item, index) => validateSection(item, index, seenKeys), ); return { title, summary, sections }; } function validateSection( raw: unknown, index: number, seenKeys: Set, ): AiReviewSection { if (!isPlainRecord(raw)) throw new BadRequestException(`第 ${index + 1} 个分表格式无效`); assertKeys(raw, SECTION_KEYS_ALLOWED, `第 ${index + 1} 个分表`); const key = requireString(raw.key, `第 ${index + 1} 个分表标识`, MAX_COLUMN_KEY); if (!SECTION_KEY_RE.test(key)) { throw new BadRequestException( `分表标识 ${key} 只能包含字母、数字、下划线(≤50)`, ); } const type = normalizeSectionType(key, raw.type); if (seenKeys.has(key)) throw new BadRequestException(`分表标识重复: ${key}`); seenKeys.add(key); const title = requireString(raw.title, `分表「${key}」标题`, MAX_SECTION_TITLE); if (raw.kind !== 'table') throw new BadRequestException(`分表「${key}」的 kind 只能是 table`); const sheet = raw.sheet === undefined || raw.sheet === null ? undefined : requireString(raw.sheet, `分表「${key}」工作表`, MAX_SECTION_TITLE); if (!Array.isArray(raw.columns) || raw.columns.length === 0) { throw new BadRequestException(`分表「${key}」至少需要一个列`); } if (raw.columns.length > MAX_COLUMNS) { throw new BadRequestException(`分表「${key}」的列数不能超过 ${MAX_COLUMNS}`); } const seenColumns = new Set(); const aliases = SECTION_ALIASES[type] ?? {}; const columns = raw.columns.map((column, columnIndex) => { if (!isPlainRecord(column)) { throw new BadRequestException(`分表「${key}」第 ${columnIndex + 1} 列格式无效`); } assertKeys(column, COLUMN_KEYS_ALLOWED, `分表「${key}」第 ${columnIndex + 1} 列`); const rawKey = requireString(column.key, `分表「${key}」列名`, MAX_COLUMN_KEY); const columnKey = aliases[rawKey] ?? rawKey; if (!COLUMN_KEY_RE.test(columnKey)) { throw new BadRequestException(`分表「${key}」列名 ${columnKey} 只能包含字母、数字、下划线`); } if (seenColumns.has(columnKey)) { throw new BadRequestException(`分表「${key}」列名重复: ${columnKey}`); } seenColumns.add(columnKey); const columnTitle = requireString(column.title, `分表「${key}」列「${columnKey}」标题`, MAX_COLUMN_TITLE); return { key: columnKey, title: columnTitle }; }); if (!Array.isArray(raw.rows) || raw.rows.length > MAX_ROWS) { throw new BadRequestException(`分表「${key}」的行数不能超过 ${MAX_ROWS}`); } const rows = raw.rows.map((row, rowIndex) => validateRow(row, type, rowIndex, new Set(seenColumns), aliases), ); let issues: string[] = []; if (raw.issues !== undefined) { if (!Array.isArray(raw.issues) || raw.issues.length > MAX_ISSUES) { throw new BadRequestException(`分表「${key}」的问题数不能超过 ${MAX_ISSUES}`); } issues = raw.issues.map((issue) => requireString(issue, `分表「${key}」的问题`, MAX_ISSUE_LENGTH), ); } return { key, type, title, kind: 'table', ...(sheet ? { sheet } : {}), columns, rows, issues, }; } function validateRow( raw: unknown, sectionType: AiReviewSectionType, index: number, knownColumns: Set, aliases: Record, ): AiReviewRow { if (!isPlainRecord(raw)) { throw new BadRequestException(`分表「${sectionType}」第 ${index + 1} 行格式无效`); } const row: AiReviewRow = {}; for (const [key, value] of Object.entries(raw)) { const canonicalKey = aliases[key] ?? key; if (!knownColumns.has(canonicalKey)) continue; if (value === null || typeof value === 'boolean') { row[canonicalKey] = value; continue; } if (typeof value === 'number') { if (!Number.isFinite(value)) { throw new BadRequestException( `分表「${sectionType}」第 ${index + 1} 行 ${key} 必须是有效数字`, ); } row[canonicalKey] = value; continue; } if (typeof value === 'string') { if (value.length > MAX_CELL_LENGTH) { throw new BadRequestException( `分表「${sectionType}」第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`, ); } row[canonicalKey] = value; continue; } throw new BadRequestException( `分表「${sectionType}」第 ${index + 1} 行 ${key} 类型不支持`, ); } return row; }