forked from wangziqi/gongxue-base
166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
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<string>();
|
||
const sections = rawArgs.sections.map((item, index) =>
|
||
validateSection(item, index, seenKeys),
|
||
);
|
||
return { title, summary, sections };
|
||
}
|
||
|
||
function validateSection(
|
||
raw: unknown,
|
||
index: number,
|
||
seenKeys: Set<string>,
|
||
): 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<string>();
|
||
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<string>,
|
||
aliases: Record<string, string>,
|
||
): 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;
|
||
}
|