Files
gongxue-base/apps/server/src/ai-chat/ai-review.validation.ts

166 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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