refactor(server): A2UI 收敛——移除 legacy SSE 双发与 render_review 工具,提取公共校验
This commit is contained in:
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}」的目标宿舍与当前宿舍相同`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('同一业务类型允许多张 sheet,key 保持唯一', 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
31
apps/server/src/ai-chat/ai-validation.ts
Normal file
31
apps/server/src/ai-chat/ai-validation.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user