refactor(server): A2UI 收敛——移除 legacy SSE 双发与 render_review 工具,提取公共校验

This commit is contained in:
2026-08-08 15:05:10 +08:00
parent 1e3d6ee20f
commit b37ae9471e
16 changed files with 60 additions and 1748 deletions

View File

@@ -1,12 +0,0 @@
export { BusinessContextService } from './business-context.service';
export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry';
export type {
BusinessContextPrincipal,
BusinessContextResult,
BusinessEntity,
BusinessEntityField,
BusinessEntityRelation,
BusinessWorkflow,
BusinessWorkflowNextStep,
BusinessWorkflowStage,
} from './business-context.types';

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { uuidV7 } from '../common/uuid-v7';
import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity';
import { assertKeys, isPlainRecord, requireString } from './ai-validation';
const MAX_TITLE = 50;
const MAX_COLUMNS = 10;
@@ -21,27 +22,6 @@ export interface AiChart {
rows: AiReviewRow[];
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function requireString(value: unknown, label: string, max: number): string {
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`${label}必须是字符串`);
}
const trimmed = value.trim();
if (trimmed.length > max) {
throw new BadRequestException(`${label}长度不能超过 ${max}`);
}
return trimmed;
}
function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
}
}
/**
* Validates the `render_chart` tool arguments. The model sends a
* whitelisted tabular shape (columns + rows); the frontend converts it

View File

@@ -175,6 +175,7 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
当用户需要录入或修改业务数据时,先调用 render_form 生成确认表单,提示用户填写并提交;只有在用户通过表单提交确认后,才能执行写操作工具(如 create_student、update_students
新增学生示例render_form 的 fields 使用 name/phone/gender/studentNo。
修改学生示例:批量修改姓名/档案时render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students每条更新必须带学生 id。
需要向用户询问信息、让用户选择或确认时(例如:确认导入策略与列映射、选择班级/房型/老师、补充必填信息、二选一确认),优先调用 render_form 生成简短的问题卡片(字段 1-4 个:选项用 select、短文本用 input、日期用 date、多行说明用 textarea让用户直接在卡片上作答不要用大段文字逐项提问只有开放式讨论或无法用表单表达时才用文字提问。问题卡片标题用一句完整问题字段 label 即选项/问题文案,必填项设置 required。
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。
2. 用户确认后调用 start_import_wizard必须传入 attachmentId 和 stages业务类型 stepKeystudents 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名并把确认结果一并传入mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。

View File

@@ -87,15 +87,6 @@ export function parseConfirmedSettings(parsedRecord: Record<string, unknown>): I
return settings;
}
/** 解析 resolve 端点嵌套的 settings 参数(缺省为空对象)。 */
export function parseNestedSettings(raw: unknown): ImportRunSettings {
if (raw === undefined || raw === null) return {};
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new BadRequestException('settings 参数格式错误');
}
return parseConfirmedSettings(raw as Record<string, unknown>);
}
export function isExcelAttachment(attachment: {
mimeType: string;
originalName: string;

View File

@@ -30,11 +30,8 @@ function createService(
...messageOverrides,
};
const reviewService = {
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findOwned: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
submitSection: jest.fn(),
submitGroup: jest.fn(),
submitAll: jest.fn(),
@@ -303,10 +300,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
@@ -466,10 +460,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
@@ -497,7 +488,7 @@ describe('AiChatService', () => {
jest.fn(),
);
expect(emitted.some(({ event }) => event === 'ui.form')).toBe(true);
expect(emitted.some(({ event }) => event === 'ui.artifact')).toBe(true);
expect(messageSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 12,
@@ -508,7 +499,7 @@ describe('AiChatService', () => {
);
});
it('render_chart 生成的图表通过 ui.chart 推送并追加到消息 metadata', async () => {
it('render_chart 生成的图表通过 ui.artifact 推送并追加到消息 metadata', async () => {
const conversation = {
id: 3,
userId: 7,
@@ -612,10 +603,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
@@ -643,7 +631,7 @@ describe('AiChatService', () => {
jest.fn(),
);
expect(emitted.some(({ event }) => event === 'ui.chart')).toBe(true);
expect(emitted.some(({ event }) => event === 'ui.artifact')).toBe(true);
expect(messageSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 12,
@@ -719,10 +707,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn().mockResolvedValue(review),
findPendingByAssistantMessage: jest.fn(),
findOwned: jest.fn(),
submitSection: jest.fn(),
submitAll,
@@ -757,7 +742,7 @@ describe('AiChatService', () => {
expect(emitted).toHaveLength(0);
});
it('submitReview 校验写入权限、先 onReady 再推 ui.review,并标记原卡片已提交', async () => {
it('submitReview 校验写入权限、先 onReady 再推 ui.artifact,并标记原卡片已提交', async () => {
const conversation = {
id: 3,
userId: 7,
@@ -858,10 +843,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn().mockResolvedValue(review),
findPendingByAssistantMessage: jest.fn(),
findOwned: jest.fn(),
submitSection: jest.fn(),
submitAll,
@@ -897,10 +879,13 @@ describe('AiChatService', () => {
expect(assertPermission).toHaveBeenCalledWith(expect.anything(), 'student:create');
expect(submitAll).toHaveBeenCalledTimes(1);
expect(emitted[0]).toMatchObject({
event: 'ui.review',
data: { messageId: 12, review: expect.objectContaining({ id: 'review-1' }) },
event: 'ui.artifact',
data: {
messageId: 12,
artifact: expect.objectContaining({ id: 'review-1', type: 'review' }),
},
});
expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.review'));
expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.artifact'));
expect(messageSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 12,
@@ -1132,10 +1117,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
@@ -1174,10 +1156,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,
@@ -1260,10 +1239,7 @@ describe('AiChatService', () => {
serialize: jest.fn((value) => value),
} as never,
{
createReview: jest.fn(),
expirePreviousReviews: jest.fn().mockResolvedValue([]),
findOwnedPending: jest.fn(),
findPendingByAssistantMessage: jest.fn(),
serialize: jest.fn((value) => value),
submit: jest.fn(),
} as never,

View File

@@ -190,10 +190,6 @@ export async function submitReview(
const serialized = context.reviewService.serialize(updatedReview);
onReady();
emit('ui.review', {
messageId: updatedReview.assistantMessageId,
review: serialized,
});
emit('ui.artifact', {
messageId: updatedReview.assistantMessageId,
artifact: buildA2uiArtifact({

View File

@@ -1,4 +1,3 @@
import { AiReview } from './entities/ai-review.entity';
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
import { finishToolRun, startToolRun } from './ai-chat.tools';
@@ -46,7 +45,6 @@ export async function executeRenderForm(
await context.messages.save(oldAssistant);
}
const expiredPayload = context.formService.serialize(expired);
emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload });
emit('ui.artifact', {
messageId: expired.assistantMessageId,
artifact: buildA2uiArtifact({
@@ -67,10 +65,6 @@ export async function executeRenderForm(
await context.messages.save(assistant);
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成表单,等待用户填写' }, emit);
emit('ui.form', {
messageId,
form: context.formService.serialize(form),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
@@ -93,131 +87,6 @@ export async function executeRenderForm(
}
}
export async function executeRenderReview(
context: AiChatServiceContext,
messageId: number,
call: ModelToolCall,
userId: number,
emit: AiSseEmitter,
): Promise<string> {
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
toolName: 'render_review',
skillKey: null,
argumentsData: null,
});
try {
const existingReview = await context.reviewService.findPendingByAssistantMessage(messageId);
if (existingReview) {
const denial = `本回合已生成导入预览《${existingReview.title}》,请直接提示用户审阅并确认,不要再次调用 render_review如需多个分表应全部合并到同一张预览卡。`;
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: denial, error: denial }, emit);
return JSON.stringify({ status: 'failed', error: denial });
}
const assistant = await context.messages.findOne({ where: { id: messageId } });
if (!assistant) throw new Error('assistant message missing');
const parsedRecord =
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
? (parsedArgs as Record<string, unknown>)
: {};
const attachmentId =
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
let review: AiReview;
if (Number.isInteger(attachmentId) && (attachmentId as number) > 0) {
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [
attachmentId as number,
]);
if (
!attachment.mimeType.includes('spreadsheetml') &&
!attachment.mimeType.includes('excel') &&
!attachment.mimeType.includes('csv')
) {
throw new Error('附件不是 Excel 文件,无法生成导入预览');
}
if (!context.excelReader) throw new Error('Excel 解析器未配置');
const buffer = await context.attachmentService.readStoredBuffer(attachment);
const sheets = await context.excelReader.loadSheets(buffer);
const sections = await context.reviewService.buildSectionsFromWorkbook(sheets, parsedArgs);
review = await context.reviewService.createReview(
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
{ title: parsedRecord.title, summary: parsedRecord.summary ?? null, sections },
);
} else {
review = await context.reviewService.createReview(
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
parsedArgs,
);
}
const expiredReviews = await context.reviewService.expirePreviousReviews(
userId,
assistant.conversationId,
review.id,
);
await Promise.all(
expiredReviews.map(async (expired) => {
const oldAssistant = await context.messages.findOne({
where: { id: expired.assistantMessageId, conversationId: assistant.conversationId },
});
const oldA2ui = oldAssistant?.metadata?.a2uiReview;
if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) {
oldAssistant.metadata = {
...oldAssistant.metadata,
a2uiReview: context.reviewService.serialize(expired),
};
await context.messages.save(oldAssistant);
}
emit('ui.review', {
messageId: expired.assistantMessageId,
review: context.reviewService.serialize(expired),
});
emit('ui.artifact', {
messageId: expired.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'review',
id: expired.id,
status: expired.status === 'submitted' ? 'submitted' : 'expired',
messageId: expired.assistantMessageId,
conversationId: assistant.conversationId,
payload: context.reviewService.serialize(expired),
}),
});
}),
);
assistant.metadata = {
...assistant.metadata,
a2uiReview: context.reviewService.serialize(review),
};
await context.messages.save(assistant);
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成导入预览,等待用户确认' }, emit);
emit('ui.review', {
messageId,
review: context.reviewService.serialize(review),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
type: 'review',
id: review.id,
status: review.status === 'submitted' ? 'submitted' : 'pending',
messageId,
conversationId: assistant.conversationId,
payload: context.reviewService.serialize(review),
}),
});
return JSON.stringify({
status: 'success',
reviewId: review.id,
message: '导入预览已显示给用户,请提示用户审阅并确认',
});
} catch (reason) {
const errorMessage =
reason instanceof Error && reason.message ? reason.message.slice(0, 120) : '导入预览参数无效';
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: errorMessage, error: errorMessage }, emit);
return JSON.stringify({ status: 'failed', error: errorMessage });
}
}
export async function executeRenderChart(
context: AiChatServiceContext,
messageId: number,
@@ -248,10 +117,6 @@ export async function executeRenderChart(
await context.messages.save(assistant);
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成图表' }, emit);
emit('ui.chart', {
messageId,
chart: context.chartService.serialize(chart),
});
emit('ui.artifact', {
messageId,
artifact: buildA2uiArtifact({
@@ -275,6 +140,5 @@ export async function executeRenderChart(
}
export {
compactImportWizard,
executeStartImportWizard,
} from './ai-chat.tool-actions.import';

View File

@@ -182,9 +182,6 @@ export type AiSseEventName =
| 'tool.started'
| 'tool.completed'
| 'tool.failed'
| 'ui.form'
| 'ui.review'
| 'ui.chart'
| 'ui.artifact'
| 'ui.import_wizard'
| 'attachment.processed'

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { AiForm, type AiFormField } from './entities/ai-form.entity';
import { isPlainRecord, requireString } from './ai-validation';
export const A2UI_FIELD_TYPES = ['input', 'textarea', 'number', 'select', 'date'] as const;
@@ -40,34 +41,10 @@ interface ValidatedFormSchema {
fields: AiFormField[];
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isShortString(value: unknown, max: number): value is string {
return typeof value === 'string' && value.length <= max;
}
function requireString(
value: unknown,
label: string,
max: number,
optional = false,
): string {
if (value === undefined || value === null) {
if (optional) return '';
throw new BadRequestException(`${label}不能为空`);
}
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`${label}必须是字符串`);
}
const trimmed = value.trim();
if (trimmed.length > max) {
throw new BadRequestException(`${label}长度不能超过 ${max}`);
}
return trimmed;
}
/**
* Server-side A2UI form lifecycle:
* schema validation + persistence, owned lookup, submitted-value
@@ -171,7 +148,7 @@ export class AiFormService {
return result;
}
/** Public shape sent via `ui.form` SSE and mirrored into message metadata. */
/** Public shape used by message metadata and the unified `ui.artifact` payload. */
serialize(form: AiForm): Record<string, unknown> {
return {
id: form.id,

View File

@@ -1,10 +1,4 @@
import { DataSource, IsNull, Repository } from 'typeorm';
import { Organization } from '../entities/organization.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import type { AiReviewSection } from './entities/ai-review.entity';
import { DATE_RE, MAX_ISSUES, normalizePhone, toDateString } from './ai-review.shared';
export function resolveOrganizationId(
raw: unknown,
@@ -20,258 +14,3 @@ export function resolveOrganizationId(
const match = organizations.find((org) => org.name === text || org.code === text);
return match?.id ?? null;
}
/**
* Preview-time database validation. The AI's parsed rows are checked
* against the current system (organizations, duplicate students/rooms,
* occupancy state, transfer targets) and the findings are appended to
* each section's issues so the user sees them BEFORE confirming.
* Problems found here do not block preview creation; the import phase
* re-checks everything and skips problematic rows.
*/
export async function enrichWithIssues(
dataSource: DataSource,
sections: AiReviewSection[],
): Promise<AiReviewSection[]> {
try {
const organizationRepo = dataSource.getRepository(Organization);
const studentRepo = dataSource.getRepository(Student);
const roomRepo = dataSource.getRepository(Room);
const occupancyRepo = dataSource.getRepository(Occupancy);
const organizations = await organizationRepo.find({ where: { status: 'active' } });
const roomSections = sections.filter((section) => section.type === 'rooms');
const incomingRoomNumbers = new Set(
roomSections.flatMap((section) =>
(section.rows ?? [])
.map((row) =>
row.roomNumber === undefined ? '' : String(row.roomNumber).trim(),
)
.filter(Boolean),
),
);
const enriched: AiReviewSection[] = [];
for (const section of sections) {
const issues = [...section.issues];
if (section.type === 'students') {
await enrichStudentIssues(section, issues, organizations, studentRepo);
} else if (section.type === 'rooms') {
await enrichRoomIssues(section, issues, roomRepo);
} else if (section.type === 'transfers') {
await enrichTransferIssues(
section,
issues,
studentRepo,
roomRepo,
occupancyRepo,
incomingRoomNumbers,
);
} else if (section.type === 'checkins') {
await enrichCheckinIssues(
section,
issues,
studentRepo,
roomRepo,
occupancyRepo,
);
}
enriched.push({
...section,
issues: [...new Set(issues)].slice(-MAX_ISSUES),
});
}
return enriched;
} catch {
// Database validation is best-effort; fall back to model-provided issues.
return sections;
}
}
async function enrichStudentIssues(
section: AiReviewSection,
issues: string[],
organizations: Organization[],
studentRepo: Repository<Student>,
): Promise<void> {
const seen = new Set<string>();
for (const row of section.rows) {
const name = row.name === undefined || row.name === null ? '' : String(row.name).trim();
const phone = normalizePhone(row.phone);
const studentNo =
row.studentNo === undefined || row.studentNo === null
? ''
: String(row.studentNo).trim();
const organizationId = resolveOrganizationId(row.organization, organizations);
if (organizationId === null) {
issues.push(`学生「${name}」的所属机构无法识别,导入时将按本机构处理`);
}
const dedupeKey = phone ? `phone:${phone}` : studentNo ? `no:${studentNo}` : '';
if (dedupeKey && seen.has(dedupeKey)) {
issues.push(`学生「${name}」与同一批次中的其他学生手机号/学号重复,导入时将跳过`);
}
seen.add(dedupeKey);
if (!dedupeKey) continue;
const existing = phone
? await studentRepo.findOne({ where: { phone } })
: await studentRepo.findOne({ where: { studentNo } });
if (existing) {
issues.push(`学生「${name}」已存在(按手机号/学号匹配),导入时将跳过`);
}
}
}
async function enrichRoomIssues(
section: AiReviewSection,
issues: string[],
roomRepo: Repository<Room>,
): Promise<void> {
const seen = new Set<string>();
for (const row of section.rows) {
const roomNumber =
row.roomNumber === undefined || row.roomNumber === null
? ''
: String(row.roomNumber).trim();
if (!roomNumber) continue;
if (seen.has(roomNumber)) {
issues.push(`宿舍「${roomNumber}」在同一批次中重复,导入时将跳过`);
continue;
}
seen.add(roomNumber);
const existing = await roomRepo.findOne({ where: { roomNumber } });
if (existing) {
issues.push(`宿舍「${roomNumber}」已存在,导入时将跳过`);
}
}
}
async function enrichCheckinIssues(
section: AiReviewSection,
issues: string[],
studentRepo: Repository<Student>,
roomRepo: Repository<Room>,
occupancyRepo: Repository<Occupancy>,
): Promise<void> {
const seen = new Set<string>();
for (const row of section.rows) {
const name = row.name === undefined || row.name === null ? '' : String(row.name).trim();
const phone = normalizePhone(row.phone);
const studentNo =
row.studentNo === undefined || row.studentNo === null
? ''
: String(row.studentNo).trim();
const roomNumber =
row.roomNumber === undefined || row.roomNumber === null
? ''
: String(row.roomNumber).trim();
if (!name || !roomNumber) {
issues.push('存在姓名或宿舍号为空的入住记录行,导入时将跳过');
continue;
}
if (!phone && !studentNo) {
issues.push(`学生「${name}」缺少手机号/学号,无法关联或创建学生`);
continue;
}
const dedupeKey = phone ? `phone:${phone}` : `no:${studentNo}`;
if (seen.has(dedupeKey)) {
issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复,导入时将跳过`);
}
seen.add(dedupeKey);
const rawDate =
row.checkInDate === undefined || row.checkInDate === null
? ''
: String(row.checkInDate).trim();
if (rawDate && !DATE_RE.test(rawDate)) {
issues.push(`学生「${name}」的入住日期格式无效(应为 YYYY-MM-DD导入时按当天处理`);
}
const student = phone
? await studentRepo.findOne({ where: { phone } })
: await studentRepo.findOne({ where: { studentNo } });
if (!student) {
issues.push(`学生「${name}」不存在,导入时将自动创建并归入本机构`);
}
const room = roomNumber
? await roomRepo.findOne({ where: { roomNumber } })
: null;
if (!room) {
issues.push(`宿舍「${roomNumber}」不存在,导入时将自动创建`);
}
const checkOutDate = toDateString(row.checkOutDate);
if (student && !checkOutDate) {
const active = await occupancyRepo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
order: { id: 'DESC' },
});
if (active) {
issues.push(
`学生「${name}」当前已在住,导入时将跳过(如为历史记录请填写退宿日期)`,
);
}
}
}
}
async function enrichTransferIssues(
section: AiReviewSection,
issues: string[],
studentRepo: Repository<Student>,
roomRepo: Repository<Room>,
occupancyRepo: Repository<Occupancy>,
incomingRoomNumbers: Set<string>,
): Promise<void> {
for (const row of section.rows) {
const studentNo =
row.studentNo === undefined || row.studentNo === null
? ''
: String(row.studentNo).trim();
const phone = normalizePhone(row.studentPhone);
const newRoomNumber =
row.newRoom === undefined || row.newRoom === null
? ''
: String(row.newRoom).trim();
const student = studentNo
? await studentRepo.findOne({ where: { studentNo } })
: phone
? await studentRepo.findOne({ where: { phone } })
: null;
if (!student) {
issues.push(`换宿到「${newRoomNumber}」的学生不存在(缺少手机号/学号),导入时将跳过`);
continue;
}
const active = await occupancyRepo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
order: { id: 'DESC' },
});
if (!active) {
issues.push(`学生「${student.name}」当前没有在住记录,无法换宿`);
continue;
}
const oldRoom = await roomRepo.findOne({ where: { id: active.roomId } });
const oldRoomNumber = oldRoom?.roomNumber ?? String(active.roomId);
const expectedOldRoom =
row.oldRoom === undefined || row.oldRoom === null
? ''
: String(row.oldRoom).trim();
if (expectedOldRoom && expectedOldRoom !== oldRoomNumber) {
issues.push(
`学生「${student.name}」原宿舍为「${oldRoomNumber}」,与行内填写的「${expectedOldRoom}」不一致`,
);
}
const targetExists =
incomingRoomNumbers.has(newRoomNumber) ||
Boolean(await roomRepo.findOne({ where: { roomNumber: newRoomNumber } }));
if (!newRoomNumber) {
issues.push('存在目标宿舍为空的行,导入时将跳过');
} else if (!targetExists) {
issues.push(
`学生「${student.name}」的目标宿舍「${newRoomNumber}」不存在,且本次导入未包含该宿舍`,
);
}
if (newRoomNumber && oldRoomNumber === newRoomNumber) {
issues.push(`学生「${student.name}」的目标宿舍与当前宿舍相同`);
}
}
}

View File

@@ -1,6 +1,5 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { AiReviewService } from './ai-review.service';
import type { ExcelSheetRows } from './ai-excel-reader.service';
function createService(overrides: Record<string, unknown> = {}) {
const reviews = {
@@ -19,12 +18,6 @@ function createService(overrides: Record<string, unknown> = {}) {
return { service, reviews, dataSource };
}
const baseArgs = {
userId: 7,
conversationId: 3,
assistantMessageId: 12,
};
const validSchema = {
title: '新生入住批量导入',
summary: '来自报名 Excel',
@@ -48,521 +41,6 @@ const validSchema = {
};
describe('AiReviewService', () => {
describe('createReview', () => {
function bigSchema(sectionCount: number) {
const columns = Array.from({ length: 30 }, (_, i) => ({
key: `c${i + 1}`,
title: `${i + 1}`,
}));
const cell = '中'.repeat(200);
const rows = Array.from({ length: 500 }, (_, _i) =>
Object.fromEntries(columns.map((column) => [column.key, cell])),
);
return {
title: '大体积导入',
summary: null,
sections: Array.from({ length: sectionCount }, (_, i) => ({
key: (['students', 'rooms', 'transfers'] as const)[i],
title: `学生${i + 1}`,
kind: 'table',
columns,
rows,
issues: [],
})),
};
}
it('校验通过的 schema 落库并保留完整 sections', async () => {
const { service, reviews } = createService();
const review = await service.createReview(baseArgs, validSchema);
expect(reviews.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
conversationId: 3,
assistantMessageId: 12,
title: '新生入住批量导入',
status: 'pending',
}),
);
expect(review.id).toBeTruthy();
const sections = JSON.parse(review.sectionsJson) as unknown[];
expect(sections).toHaveLength(1);
expect((sections[0] as { rows: unknown[] }).rows).toHaveLength(2);
});
it('允许大体积合法预览(远超 256KB不再因字节上限失败', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, bigSchema(1));
expect(review.sectionsJson.length).toBeGreaterThan(256 * 1024);
});
it('超过 12MB 总上限的预览仍被拒绝', async () => {
const { service } = createService();
await expect(service.createReview(baseArgs, bigSchema(2))).rejects.toThrow('预览数据过大');
});
it.each([
['标题缺失', { sections: validSchema.sections }, '预览标题'],
['未知顶层字段', { ...validSchema, hack: 1 }, '未知属性'],
['分表为空', { ...validSchema, sections: [] }, '至少需要一个分表'],
[
'分表超过20个',
{
...validSchema,
sections: Array.from({ length: 21 }, (_, i) => ({
...validSchema.sections[0],
key: `students_${i}`,
title: `分表${i}`,
})),
},
'不能超过 20',
],
[
'分表类型无法解析',
{
...validSchema,
sections: [{ ...validSchema.sections[0], key: 'hackers', type: undefined }],
},
'无法解析业务类型',
],
[
'显式非法 type 被拒绝',
{
...validSchema,
sections: [{ ...validSchema.sections[0], type: 'hackers' }],
},
'分表业务类型不支持',
],
[
'分表标识重复',
{
...validSchema,
sections: [validSchema.sections[0], validSchema.sections[0]],
},
'分表标识重复',
],
[
'kind 非 table',
{
...validSchema,
sections: [{ ...validSchema.sections[0], kind: 'chart' }],
},
'只能是 table',
],
[
'列缺失',
{
...validSchema,
sections: [{ ...validSchema.sections[0], columns: [] }],
},
'至少需要一个列',
],
[
'行数超限',
{
...validSchema,
sections: [
{
...validSchema.sections[0],
rows: Array.from({ length: 501 }, (_, i) => ({ name: `学生${i}` })),
},
],
},
'不能超过 500',
],
[
'单元格类型非法',
{
...validSchema,
sections: [
{
...validSchema.sections[0],
rows: [{ name: '张三', phone: { hack: true } }],
},
],
},
'类型不支持',
],
])('非法 schema 被拒绝:%s', async (_name, schema, messagePart) => {
const { service } = createService();
await expect(service.createReview(baseArgs, schema)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(service.createReview(baseArgs, schema)).rejects.toThrow(messagePart);
});
it('行内未知列被剔除,不写入预览', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, {
...validSchema,
sections: [
{
...validSchema.sections[0],
rows: [{ name: '张三', phone: '13800138000', __proto_hack: 'x', token: 'abc' }],
},
],
});
const sections = JSON.parse(review.sectionsJson) as Array<{
rows: Array<Record<string, unknown>>;
}>;
expect(sections[0].rows[0]).toEqual({ name: '张三', phone: '13800138000' });
});
it('同一业务类型允许多张 sheetkey 保持唯一', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, {
title: '多入住 sheet',
sections: Array.from({ length: 6 }, (_, i) => ({
key: `checkins_${i + 1}`,
type: 'checkins',
title: `入住${i + 1}`,
kind: 'table',
sheet: `Sheet${i + 1}`,
columns: [{ key: 'name', title: '姓名' }],
rows: [{ name: `学生${i + 1}` }],
issues: [],
})),
});
const sections = service.parseSections(review.sectionsJson);
expect(sections).toHaveLength(6);
expect(sections.map((section) => section.type)).toEqual(
Array.from({ length: 6 }, () => 'checkins'),
);
expect(new Set(sections.map((section) => section.key)).size).toBe(6);
expect(sections[0].sheet).toBe('Sheet1');
});
it('旧格式 key 带类型前缀时自动解析 type', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, {
...validSchema,
sections: [
{
key: 'checkins_girls_4',
title: '四人间女',
kind: 'table',
columns: [{ key: 'name', title: '姓名' }],
rows: [{ name: '张三' }],
issues: [],
},
],
});
const sections = service.parseSections(review.sectionsJson);
expect(sections[0].type).toBe('checkins');
expect(sections[0].key).toBe('checkins_girls_4');
});
it('模型常见列名别名归一化为规范键名', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, {
title: '别名测试',
sections: [
{
key: 'students',
title: '学生',
kind: 'table',
columns: [{ key: 'org', title: '机构' }],
rows: [{ org: '东校区' }],
issues: [],
},
{
key: 'rooms',
title: '宿舍',
kind: 'table',
columns: [
{ key: 'roomNo', title: '房间号' },
{ key: 'capacity', title: '容量' },
],
rows: [{ roomNo: '4-401', capacity: 4 }],
issues: [],
},
{
key: 'transfers',
title: '换宿',
kind: 'table',
columns: [
{ key: 'studentNo', title: '学号' },
{ key: 'fromRoom', title: '原宿舍' },
{ key: 'toRoom', title: '目标宿舍' },
{ key: 'date', title: '换宿日期' },
],
rows: [
{
studentNo: 'S001',
fromRoom: '1-101',
toRoom: '4-401',
date: '2026-08-10',
},
],
issues: [],
},
],
});
const sections = service.parseSections(review.sectionsJson);
expect(sections[0].columns[0].key).toBe('organization');
expect(sections[0].rows[0]).toEqual({ organization: '东校区' });
expect(sections[1].columns[0].key).toBe('roomNumber');
expect(sections[1].rows[0]).toEqual({ roomNumber: '4-401', capacity: 4 });
expect(sections[2].rows[0]).toEqual({
studentNo: 'S001',
oldRoom: '1-101',
newRoom: '4-401',
transferDate: '2026-08-10',
});
});
it('预览生成时按数据库校验机构、重复与换宿对象并追加 issues', async () => {
const { service } = createService();
const review = await service.createReview(baseArgs, {
title: '预览校验',
sections: [
{
key: 'students',
title: '学生',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'phone', title: '手机号' },
{ key: 'organization', title: '机构' },
],
rows: [{ name: '小王', phone: '13800138000', organization: '不存在的机构' }],
issues: [],
},
{
key: 'rooms',
title: '宿舍',
kind: 'table',
columns: [{ key: 'roomNumber', title: '房间号' }],
rows: [{ roomNumber: '9-901' }],
issues: [],
},
{
key: 'transfers',
title: '换宿',
kind: 'table',
columns: [
{ key: 'studentNo', title: '学号' },
{ key: 'newRoom', title: '目标宿舍' },
],
rows: [{ studentNo: 'S001', newRoom: '9-901' }],
issues: [],
},
],
});
const sections = service.parseSections(review.sectionsJson);
const studentIssues = sections.find((section) => section.key === 'students')?.issues ?? [];
const transferIssues = sections.find((section) => section.key === 'transfers')?.issues ?? [];
expect(studentIssues).toEqual(
expect.arrayContaining([expect.stringContaining('所属机构无法识别')]),
);
expect(transferIssues).toEqual(
expect.arrayContaining([expect.stringContaining('学生不存在')]),
);
});
});
describe('buildSectionsFromWorkbook', () => {
const workbook: ExcelSheetRows[] = [
{
name: '学生名单',
rows: [
['姓名', '手机号', '学号', '性别', '备注'],
['张三', '13800138000', 'S001', '男', ''],
['李四', '13900139000', 'S002', '女', '新生'],
['', '', '', '', ''],
],
},
{
name: '宿舍安排',
rows: [
['宿舍号', '容量', '楼栋', '楼层'],
['3-301', '4', '3号楼', '3'],
['3-302', '6', '3号楼', '3'],
],
},
];
it('按表头自动映射并保留文件原始行数据,未识别列进入 issues', async () => {
const { service } = createService();
const sections = await service.buildSectionsFromWorkbook(workbook, {
sections: [{ key: 'students', title: '学生', sheet: '学生名单' }],
});
expect(sections).toHaveLength(1);
expect(sections[0].rows).toEqual([
{ name: '张三', phone: '13800138000', studentNo: 'S001', gender: '男' },
{ name: '李四', phone: '13900139000', studentNo: 'S002', gender: '女' },
]);
expect(sections[0].columns.map((column) => column.key)).toEqual([
'name',
'phone',
'studentNo',
'gender',
]);
expect(sections[0].issues.join('')).toContain('备注');
});
it('支持显式 sourceHeader 列映射与 headerRow', async () => {
const { service } = createService();
const custom: ExcelSheetRows[] = [
{
name: 'Sheet1',
rows: [['忽略行'], ['学生姓名', '联系方式'], ['王五', '13700137000']],
},
];
const sections = await service.buildSectionsFromWorkbook(custom, {
sections: [
{
key: 'students',
title: '学生',
sheet: 'Sheet1',
headerRow: 2,
columns: [
{ key: 'name', title: '姓名', sourceHeader: '学生姓名' },
{ key: 'phone', title: '手机号', sourceHeader: '联系方式' },
],
},
],
});
expect(sections[0].rows).toEqual([{ name: '王五', phone: '13700137000' }]);
});
it('入住记录表头自动映射为规范列名', async () => {
const { service } = createService();
const custom: ExcelSheetRows[] = [
{
name: '入住名单',
rows: [
['宿舍号', '姓名', '手机号', '入住日期'],
['5-501', '於嘉丽', '13611112222', '2026-08-01'],
['5-502', '刘禹含', '13611113333', '2026/08/02'],
],
},
];
const sections = await service.buildSectionsFromWorkbook(custom, {
sections: [{ key: 'checkins', title: '入住记录', sheet: '入住名单' }],
});
expect(sections[0].rows).toEqual([
{ name: '於嘉丽', phone: '13611112222', roomNumber: '5-501', checkInDate: '2026-08-01' },
{ name: '刘禹含', phone: '13611113333', roomNumber: '5-502', checkInDate: '2026/08/02' },
]);
});
it('英文表头映射为 camelCase 规范键rooms/transfers/checkins', async () => {
const { service } = createService();
const custom: ExcelSheetRows[] = [
{
name: 'Rooms',
rows: [
['RoomNumber', 'RoomType'],
['3-301', '四人间'],
],
},
{
name: 'Transfers',
rows: [
['StudentNo', 'StudentPhone', 'OldRoom', 'NewRoom', 'TransferDate'],
['S001', '13800138000', '3-301', '3-302', '2026-08-05'],
],
},
{
name: 'Checkins',
rows: [
['StudentNo', 'RoomNumber', 'CheckInDate'],
['S001', '3-301', '2026-08-01'],
],
},
];
const sections = await service.buildSectionsFromWorkbook(custom, {
sections: [
{ key: 'rooms', title: '宿舍', sheet: 'Rooms' },
{ key: 'transfers', title: '换宿', sheet: 'Transfers' },
{ key: 'checkins', title: '入住', sheet: 'Checkins' },
],
});
const byKey = Object.fromEntries(sections.map((section) => [section.key, section]));
expect(byKey.rooms.rows).toEqual([{ roomNumber: '3-301', roomType: '四人间' }]);
expect(byKey.transfers.rows).toEqual([
{
studentNo: 'S001',
studentPhone: '13800138000',
oldRoom: '3-301',
newRoom: '3-302',
transferDate: '2026-08-05',
},
]);
expect(byKey.checkins.rows).toEqual([
{ studentNo: 'S001', roomNumber: '3-301', checkInDate: '2026-08-01' },
]);
});
it('工作表不存在时抛出明确错误', async () => {
const { service } = createService();
await expect(
service.buildSectionsFromWorkbook(workbook, {
sections: [{ key: 'rooms', title: '宿舍', sheet: '不存在的表' }],
}),
).rejects.toThrow('找不到工作表');
});
it('分表标识重复或非法时拒绝', async () => {
const { service } = createService();
await expect(
service.buildSectionsFromWorkbook(workbook, {
sections: [
{ key: 'students', title: '学生' },
{ key: 'students', title: '学生2' },
],
}),
).rejects.toThrow('分表标识重复');
await expect(
service.buildSectionsFromWorkbook(workbook, {
sections: [{ key: 'hackers', title: '入侵' }],
}),
).rejects.toThrow('无法解析业务类型');
});
it('同一类型多张 sheet 合并生成,并保留各自 key/sheet', async () => {
const { service } = createService();
const multiSheet: ExcelSheetRows[] = [
{
name: '四人间女',
rows: [
['姓名', '手机号', '宿舍号', '入住日期'],
['张三', '13800138000', '4-401', '2026-08-01'],
],
},
{
name: '四人间男',
rows: [
['姓名', '手机号', '宿舍号', '入住日期'],
['李四', '13900139000', '4-402', '2026-08-01'],
],
},
];
const sections = await service.buildSectionsFromWorkbook(multiSheet, {
sections: [
{
key: 'checkins_girls_4',
type: 'checkins',
title: '四人间女',
sheet: '四人间女',
},
{
key: 'checkins_boys_4',
type: 'checkins',
title: '四人间男',
sheet: '四人间男',
},
],
});
expect(sections).toHaveLength(2);
expect(sections.map((section) => [section.key, section.type, section.sheet])).toEqual([
['checkins_girls_4', 'checkins', '四人间女'],
['checkins_boys_4', 'checkins', '四人间男'],
]);
});
});
describe('findOwnedPending', () => {
it('只返回本人 pending 预览', async () => {
const review = { id: 'review-1', userId: 7, status: 'pending' };
@@ -581,23 +59,6 @@ describe('AiReviewService', () => {
});
});
describe('findPendingByAssistantMessage', () => {
it('按 assistant 消息返回最新的 pending 预览', async () => {
const review = { id: 'review-1', assistantMessageId: 12, status: 'pending' };
const { service, reviews } = createService({ findOne: jest.fn().mockResolvedValue(review) });
await expect(service.findPendingByAssistantMessage(12)).resolves.toBe(review);
expect(reviews.findOne).toHaveBeenCalledWith({
where: { assistantMessageId: 12, status: 'pending' },
order: { createdAt: 'DESC' },
});
});
it('没有待确认预览时返回 null', async () => {
const { service } = createService({ findOne: jest.fn().mockResolvedValue(null) });
await expect(service.findPendingByAssistantMessage(12)).resolves.toBeNull();
});
});
describe('serialize', () => {
it('回传前端所需结构', () => {
const { service } = createService();

View File

@@ -1,27 +1,16 @@
// aislop-ignore-file: duplicate-block -- 导入校验循环结构相似,逻辑已复用现有助手
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { DataSource, Repository } from 'typeorm';
import { AiReview } from './entities/ai-review.entity';
import type {
AiReviewSection,
AiReviewSectionType,
} from './entities/ai-review.entity';
import type { ExcelSheetRows } from './ai-excel-reader.service';
import {
AiReviewStepSubmitResult,
AiReviewSubmitResult,
MAX_SECTIONS_JSON_BYTES,
withInitialSectionState,
} from './ai-review.shared';
import { buildSectionsFromWorkbookAsync, parseSections } from './ai-review.workbook';
import { validateSchema } from './ai-review.validation';
import { enrichWithIssues } from './ai-review.enrich';
import { parseSections } from './ai-review.workbook';
import {
submitAll,
submitGroup,
@@ -30,14 +19,11 @@ import {
import type { AiReviewSubmitContext } from './ai-review.submit';
/**
* A2UI batch-import review lifecycle.
* A2UI batch-import review confirmation lifecycle.
*
* The model parses an uploaded workbook (students / rooms / transfers),
* calls `render_review`, and the user inspects per-table preview cards
* before confirming. Confirmation runs each section in its own
* transaction in dependency order: students → rooms → transfers →
* checkins. Per-row problems are collected as issues and the row is
* skipped instead of failing the whole import.
* Existing previews can be confirmed per section, per group, or all at
* once; each section is imported in its own transaction in dependency
* order: students → rooms → transfers → checkins.
*/
@Injectable()
export class AiReviewService {
@@ -51,38 +37,6 @@ export class AiReviewService {
return { reviews: this.reviews, dataSource: this.dataSource };
}
/**
* Validate `render_review` arguments and persist a pending review.
* Throws BadRequestException when the schema is unsafe/invalid.
*/
async createReview(
input: { userId: number; conversationId: number; assistantMessageId: number },
rawArgs: unknown,
): Promise<AiReview> {
const schema = validateSchema(rawArgs);
const sections = (await enrichWithIssues(this.dataSource, schema.sections)).map(
withInitialSectionState,
);
const sectionsJson = JSON.stringify(sections);
if (Buffer.byteLength(sectionsJson, 'utf8') > MAX_SECTIONS_JSON_BYTES) {
throw new BadRequestException('预览数据过大');
}
return this.reviews.save(
this.reviews.create({
id: uuidV7(),
userId: input.userId,
conversationId: input.conversationId,
assistantMessageId: input.assistantMessageId,
title: schema.title,
summary: schema.summary,
sectionsJson,
status: 'pending',
resultSummary: null,
submittedAt: null,
}),
);
}
async findOwnedPending(reviewId: string, userId: number): Promise<AiReview> {
const review = await this.reviews.findOne({
where: { id: reviewId, userId, status: 'pending' },
@@ -91,26 +45,6 @@ export class AiReviewService {
return review;
}
/**
* Mark every other pending review in the same conversation as expired.
* Called after a new render_review is successfully created so older cards
* are superseded instead of silently staying confirmable.
*/
async expirePreviousReviews(
userId: number,
conversationId: number,
exceptReviewId: string,
): Promise<AiReview[]> {
const pending = await this.reviews.find({
where: { userId, conversationId, status: 'pending' },
});
const expired = pending.filter((review) => review.id !== exceptReviewId);
if (expired.length === 0) return [];
const ids = expired.map((review) => review.id);
await this.reviews.update({ id: In(ids) }, { status: 'expired' });
return expired.map((review) => ({ ...review, status: 'expired' as const }));
}
/**
* Return a review owned by the user regardless of overall status.
* Used by step confirmation so an already-completed card can respond
@@ -124,30 +58,7 @@ export class AiReviewService {
return review;
}
/**
* Return the newest pending review bound to an assistant message, if any.
* Used to guarantee at most one batch-import preview card per message.
*/
async findPendingByAssistantMessage(assistantMessageId: number): Promise<AiReview | null> {
return this.reviews.findOne({
where: { assistantMessageId, status: 'pending' },
order: { createdAt: 'DESC' },
});
}
/**
* 服务端直接解析上传的 Excel 生成审阅分表:行数据来自文件原文,
* 不经过模型转抄,避免漏行/错值。表头按内置字典自动映射,
* 模型可通过 sections[].columns[].sourceHeader 显式指定映射。
*/
async buildSectionsFromWorkbook(
sheets: ExcelSheetRows[],
rawArgs: unknown,
): Promise<AiReviewSection[]> {
return await buildSectionsFromWorkbookAsync(sheets, rawArgs);
}
/** Public shape sent via `ui.review` SSE and mirrored into message metadata. */
/** Public shape used by message metadata and the unified `ui.artifact` payload. */
serialize(review: AiReview): Record<string, unknown> {
return {
id: review.id,

View File

@@ -5,25 +5,12 @@ import type {
AiReviewSectionType,
} from './entities/ai-review.entity';
export const MAX_TITLE = 50;
export const MAX_SUMMARY = 500;
export const MAX_SECTIONS = 20;
export const MAX_SECTION_TITLE = 50;
export const MAX_COLUMNS = 30;
export const MAX_COLUMN_KEY = 50;
export const MAX_COLUMN_TITLE = 50;
export const MAX_ROWS = 500;
export const MAX_CELL_LENGTH = 200;
export const MAX_ISSUES = 50;
export const MAX_ISSUE_LENGTH = 200;
export const MAX_SECTIONS_JSON_BYTES = 12 * 1024 * 1024;
export const MAX_SECTION_JSON_BYTES = Math.floor(MAX_SECTIONS_JSON_BYTES / MAX_SECTIONS);
export const MAX_CAPACITY = 200;
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export const SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
export const SECTION_TYPES = new Set<AiReviewSectionType>([
const SECTION_TYPES = new Set<AiReviewSectionType>([
'students',
'rooms',
'transfers',
@@ -36,171 +23,6 @@ export const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionTy
transfers: ['students', 'rooms'],
checkins: [],
};
export const SCHEMA_KEYS = new Set(['title', 'summary', 'sections']);
export const SECTION_KEYS_ALLOWED = new Set([
'key',
'type',
'title',
'kind',
'sheet',
'columns',
'rows',
'issues',
]);
export const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']);
/**
* Column-key aliases the model may produce when parsing workbooks.
* Keys are normalized to canonical names per section so the import
* logic only deals with one vocabulary.
*/
export const SECTION_ALIASES: Record<AiReviewSectionType, Record<string, string>> = {
students: {
org: 'organization',
organizationName: 'organization',
orgName: 'organization',
},
rooms: {
roomNo: 'roomNumber',
number: 'roomNumber',
},
transfers: {
fromRoom: 'oldRoom',
currentRoom: 'oldRoom',
sourceRoom: 'oldRoom',
toRoom: 'newRoom',
targetRoom: 'newRoom',
destRoom: 'newRoom',
date: 'transferDate',
changeDate: 'transferDate',
moveDate: 'transferDate',
mobile: 'studentPhone',
phone: 'studentPhone',
},
checkins: {
studentName: 'name',
mobile: 'phone',
roomNo: 'roomNumber',
room: 'roomNumber',
date: 'checkInDate',
inDate: 'checkInDate',
checkinDate: 'checkInDate',
outDate: 'checkOutDate',
checkoutDate: 'checkOutDate',
},
};
/**
* Excel 表头 → 规范列名。与 SECTION_ALIASES 合并使用;
* 键会被归一化(去空格/下划线/大小写),因此同时覆盖中文与英文写法。
*/
export const SECTION_HEADER_ALIASES: Record<AiReviewSectionType, Record<string, string>> = {
students: {
: 'name',
: 'name',
name: 'name',
: 'phone',
: 'phone',
: 'phone',
phone: 'phone',
mobile: 'phone',
: 'studentNo',
: 'studentNo',
studentNo: 'studentNo',
studentno: 'studentNo',
: 'gender',
gender: 'gender',
: 'organization',
: 'organization',
: 'organization',
: 'organization',
organization: 'organization',
},
rooms: {
: 'roomNumber',
宿: 'roomNumber',
: 'roomNumber',
roomNumber: 'roomNumber',
roomnumber: 'roomNumber',
: 'capacity',
: 'capacity',
: 'capacity',
capacity: 'capacity',
: 'building',
: 'building',
building: 'building',
: 'floor',
floor: 'floor',
: 'roomType',
: 'roomType',
roomType: 'roomType',
},
transfers: {
: 'studentNo',
studentNo: 'studentNo',
studentno: 'studentNo',
: 'studentPhone',
: 'studentPhone',
: 'studentPhone',
phone: 'studentPhone',
studentPhone: 'studentPhone',
宿: 'oldRoom',
: 'oldRoom',
oldRoom: 'oldRoom',
宿: 'newRoom',
宿: 'newRoom',
newRoom: 'newRoom',
宿: 'transferDate',
: 'transferDate',
transferDate: 'transferDate',
},
checkins: {
: 'name',
: 'name',
name: 'name',
: 'phone',
: 'phone',
phone: 'phone',
mobile: 'phone',
: 'studentNo',
studentNo: 'studentNo',
宿: 'roomNumber',
: 'roomNumber',
roomNumber: 'roomNumber',
: 'building',
building: 'building',
: 'gender',
gender: 'gender',
: 'checkInDate',
: 'checkInDate',
checkInDate: 'checkInDate',
: 'billingStartDate',
: 'billingStartDate',
退宿: 'checkOutDate',
退宿: 'checkOutDate',
宿: 'checkOutDate',
: 'stayType',
宿: 'stayType',
},
};
export const SECTION_CANONICAL_KEYS: Record<AiReviewSectionType, Set<string>> = {
students: new Set(['name', 'phone', 'studentNo', 'gender', 'organization']),
rooms: new Set(['roomNumber', 'capacity', 'building', 'floor', 'roomType']),
transfers: new Set(['studentNo', 'studentPhone', 'oldRoom', 'newRoom', 'transferDate']),
checkins: new Set([
'name',
'phone',
'studentNo',
'roomNumber',
'checkInDate',
'billingStartDate',
'checkOutDate',
'gender',
'building',
'stayType',
]),
};
export interface AiReviewSubmitResult {
students: { created: number; skipped: number; issues: string[] };
@@ -214,47 +36,13 @@ export type AiReviewSectionResult =
| { created: number; skipped: number; issues: string[] }
| { completed: number; skipped: number; issues: string[] };
export interface ValidatedReviewSchema {
title: string;
summary: string | null;
sections: AiReviewSection[];
}
export interface AiReviewStepSubmitResult {
review: AiReview;
result: AiReviewSectionResult;
message: string;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function requireString(
value: unknown,
label: string,
max: number,
optional = false,
): string {
if (value === undefined || value === null) {
if (optional) return '';
throw new BadRequestException(`${label}不能为空`);
}
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`${label}必须是字符串`);
}
const trimmed = value.trim();
if (trimmed.length > max) {
throw new BadRequestException(`${label}长度不能超过 ${max}`);
}
return trimmed;
}
export function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
}
}
export { isPlainRecord } from './ai-validation';
export function toDateString(value: unknown): string | null {
if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim();
@@ -267,7 +55,7 @@ export function normalizePhone(value: unknown): string | null {
return /^1[3-9]\d{9}$/.test(phone) ? phone : null;
}
export function isSectionType(value: unknown): value is AiReviewSectionType {
function isSectionType(value: unknown): value is AiReviewSectionType {
return typeof value === 'string' && SECTION_TYPES.has(value as AiReviewSectionType);
}
@@ -317,12 +105,3 @@ export function sectionResultMessage(
}
return `成功入住 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped}`;
}
export function withInitialSectionState(section: AiReviewSection): AiReviewSection {
return {
...section,
status: 'pending',
resultSummary: null,
submittedAt: null,
};
}

View File

@@ -1,165 +0,0 @@
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;
}

View File

@@ -1,220 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import type {
AiReviewColumn,
AiReviewRow,
AiReviewSection,
AiReviewSectionType,
} from './entities/ai-review.entity';
import type { ExcelSheetRows } from './ai-excel-reader.service';
import {
isPlainRecord,
MAX_CELL_LENGTH,
MAX_COLUMN_TITLE,
MAX_ISSUES,
MAX_ROWS,
MAX_SECTIONS,
MAX_SECTION_JSON_BYTES,
MAX_SECTION_TITLE,
normalizeSectionType,
requireString,
SECTION_ALIASES,
SECTION_CANONICAL_KEYS,
SECTION_HEADER_ALIASES,
SECTION_KEY_RE,
sectionStatus,
} from './ai-review.shared';
export function buildSectionsFromWorkbook(
sheets: ExcelSheetRows[],
rawArgs: unknown,
): AiReviewSection[] {
if (!isPlainRecord(rawArgs)) throw new BadRequestException('导入预览参数必须是对象');
const rawSections = rawArgs.sections;
if (!Array.isArray(rawSections) || rawSections.length === 0) {
throw new BadRequestException('至少需要一个分表');
}
if (rawSections.length > MAX_SECTIONS) {
throw new BadRequestException(`分表不能超过 ${MAX_SECTIONS}`);
}
const seen = new Set<string>();
const sections: AiReviewSection[] = [];
for (let index = 0; index < rawSections.length; index += 1) {
const raw: unknown = rawSections[index];
if (!isPlainRecord(raw) || typeof raw.key !== 'string') {
throw new BadRequestException(`${index + 1} 个分表格式无效`);
}
const key = raw.key.trim();
if (!SECTION_KEY_RE.test(key)) {
throw new BadRequestException(`分表标识 ${key} 只能包含字母、数字、下划线≤50`);
}
const type = normalizeSectionType(key, raw.type);
if (seen.has(key)) throw new BadRequestException(`分表标识重复: ${key}`);
seen.add(key);
const title = requireString(raw.title, '分表标题', MAX_SECTION_TITLE);
const rawSheet = raw.sheet;
const sheetName =
rawSheet === undefined || rawSheet === null
? undefined
: typeof rawSheet === 'string'
? rawSheet.trim()
: (JSON.stringify(rawSheet) ?? '').trim();
const headerRow = raw.headerRow === undefined || raw.headerRow === null ? 1 : Number(raw.headerRow);
if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) {
throw new BadRequestException(`分表 ${key} 的 headerRow 无效`);
}
const sheet = sheetName
? (sheets.find((item) => item.name === sheetName) ??
sheets.find((item) => item.name.includes(sheetName)))
: sheets[0];
if (!sheet) {
throw new BadRequestException(`找不到工作表「${sheetName}`);
}
sections.push(
buildSectionFromSheet(key, type, title, sheet.name, sheet, headerRow, raw.columns),
);
}
return sections;
}
export async function buildSectionsFromWorkbookAsync(
sheets: ExcelSheetRows[],
rawArgs: unknown,
): Promise<AiReviewSection[]> {
return await Promise.resolve(buildSectionsFromWorkbook(sheets, rawArgs));
}
function buildSectionFromSheet(
key: string,
type: AiReviewSectionType,
title: string,
sheetName: string,
sheet: ExcelSheetRows,
headerRow: number,
rawColumns: unknown,
): AiReviewSection {
const issues: string[] = [];
const aliasMap = buildHeaderAliasMap(type);
if (sheet.rows.length < headerRow) {
return {
key,
type,
title,
kind: 'table',
sheet: sheetName,
columns: [],
rows: [],
issues: [`工作表「${sheet.name}」没有第 ${headerRow} 行表头`],
};
}
const explicit = new Map<string, string>();
if (rawColumns !== undefined) {
if (!Array.isArray(rawColumns)) {
throw new BadRequestException(`分表 ${key} 的 columns 无效`);
}
for (const column of rawColumns) {
if (!isPlainRecord(column) || typeof column.key !== 'string') {
throw new BadRequestException(`分表 ${key} 的列定义无效`);
}
const canonical = aliasMap.get(normalizeHeader(column.key));
if (!canonical || !SECTION_CANONICAL_KEYS[type].has(canonical)) {
throw new BadRequestException(`分表 ${key} 的列标识无效: ${column.key}`);
}
if (typeof column.sourceHeader === 'string' && column.sourceHeader.trim()) {
explicit.set(normalizeHeader(column.sourceHeader), canonical);
} else {
explicit.set(normalizeHeader(column.key), canonical);
}
}
}
const headerCells = sheet.rows[headerRow - 1];
const dataRows = sheet.rows.slice(headerRow);
const mapping = new Map<number, string>();
const columns: AiReviewColumn[] = [];
for (let colIndex = 0; colIndex < headerCells.length; colIndex += 1) {
const header = String(headerCells[colIndex] ?? '').trim();
if (!header) continue;
const canonical =
explicit.get(normalizeHeader(header)) ?? aliasMap.get(normalizeHeader(header));
if (!canonical) {
issues.push(`列「${header}」未识别,已忽略`);
continue;
}
if (Array.from(mapping.values()).includes(canonical)) continue;
mapping.set(colIndex, canonical);
columns.push({ key: canonical, title: header.slice(0, MAX_COLUMN_TITLE) });
}
if (columns.length === 0) {
return {
key,
type,
title,
kind: 'table',
sheet: sheetName,
columns: [],
rows: [],
issues: [...issues, '没有识别到可导入的列'],
};
}
const rows: AiReviewRow[] = [];
let totalBytes = 0;
for (const cells of dataRows) {
const row: AiReviewRow = {};
for (const [colIndex, canonical] of mapping) {
const raw = cells[colIndex];
const text = raw === undefined || raw === null ? '' : String(raw).trim();
if (!text) continue;
row[canonical] = text.length > MAX_CELL_LENGTH ? text.slice(0, MAX_CELL_LENGTH) : text;
}
if (Object.keys(row).length === 0) continue;
const rowBytes = Buffer.byteLength(JSON.stringify(row), 'utf8');
if (totalBytes + rowBytes > MAX_SECTION_JSON_BYTES) {
issues.push(`${title}」数据量过大,仅保留前 ${rows.length}`);
break;
}
totalBytes += rowBytes;
rows.push(row);
if (rows.length >= MAX_ROWS) {
issues.push(`${title}」超过 ${MAX_ROWS} 行,仅保留前 ${MAX_ROWS}`);
break;
}
}
return {
key,
type,
title,
kind: 'table',
sheet: sheetName,
columns,
rows,
issues: [...new Set(issues)].slice(-MAX_ISSUES),
};
}
function buildHeaderAliasMap(key: AiReviewSectionType): Map<string, string> {
const merged: Record<string, string> = {
...SECTION_HEADER_ALIASES[key],
...SECTION_ALIASES[key],
};
const map = new Map<string, string>();
for (const [header, canonical] of Object.entries(merged)) {
map.set(normalizeHeader(header), canonical);
}
return map;
}
function normalizeHeader(value: string): string {
return value.trim().toLowerCase().replace(/[\s_-]+/g, '');
}
import type { AiReviewSection } from './entities/ai-review.entity';
import { isPlainRecord, normalizeSectionType, sectionStatus } from './ai-review.shared';
export function parseSections(sectionsJson: string): AiReviewSection[] {
let parsed: unknown;

View File

@@ -0,0 +1,31 @@
import { BadRequestException } from '@nestjs/common';
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function requireString(
value: unknown,
label: string,
max: number,
optional = false,
): string {
if (value === undefined || value === null) {
if (optional) return '';
throw new BadRequestException(`${label}不能为空`);
}
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`${label}必须是字符串`);
}
const trimmed = value.trim();
if (trimmed.length > max) {
throw new BadRequestException(`${label}长度不能超过 ${max}`);
}
return trimmed;
}
export function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
}
}