diff --git a/apps/server/src/agent-context/index.ts b/apps/server/src/agent-context/index.ts deleted file mode 100644 index 88564af..0000000 --- a/apps/server/src/agent-context/index.ts +++ /dev/null @@ -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'; diff --git a/apps/server/src/ai-chat/ai-chart.service.ts b/apps/server/src/ai-chat/ai-chart.service.ts index 76e68c4..bfbb112 100644 --- a/apps/server/src/ai-chat/ai-chart.service.ts +++ b/apps/server/src/ai-chat/ai-chart.service.ts @@ -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 { - 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, allowed: Set, 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 diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 5948a65..04132e7 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -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 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 diff --git a/apps/server/src/ai-chat/ai-chat.import-confirm.ts b/apps/server/src/ai-chat/ai-chat.import-confirm.ts index 55e20b8..1f8d91f 100644 --- a/apps/server/src/ai-chat/ai-chat.import-confirm.ts +++ b/apps/server/src/ai-chat/ai-chat.import-confirm.ts @@ -87,15 +87,6 @@ export function parseConfirmedSettings(parsedRecord: Record): 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); -} - export function isExcelAttachment(attachment: { mimeType: string; originalName: string; diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 5312c74..81efe87 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -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, diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts index a0e743f..ac23ca5 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -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({ diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 390a140..c4795d5 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -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 { - 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) - : {}; - 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'; diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index bb6f598..8047659 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -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' diff --git a/apps/server/src/ai-chat/ai-form.service.ts b/apps/server/src/ai-chat/ai-form.service.ts index a72488f..74e3b2e 100644 --- a/apps/server/src/ai-chat/ai-form.service.ts +++ b/apps/server/src/ai-chat/ai-form.service.ts @@ -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 { - 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 { return { id: form.id, diff --git a/apps/server/src/ai-chat/ai-review.enrich.ts b/apps/server/src/ai-chat/ai-review.enrich.ts index 1d932e7..386f8d9 100644 --- a/apps/server/src/ai-chat/ai-review.enrich.ts +++ b/apps/server/src/ai-chat/ai-review.enrich.ts @@ -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 { - 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, -): Promise { - const seen = new Set(); - 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, -): Promise { - const seen = new Set(); - 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, - roomRepo: Repository, - occupancyRepo: Repository, -): Promise { - const seen = new Set(); - 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, - roomRepo: Repository, - occupancyRepo: Repository, - incomingRoomNumbers: Set, -): Promise { - 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}」的目标宿舍与当前宿舍相同`); - } - } -} diff --git a/apps/server/src/ai-chat/ai-review.service.spec.ts b/apps/server/src/ai-chat/ai-review.service.spec.ts index a783d44..a2b5a7b 100644 --- a/apps/server/src/ai-chat/ai-review.service.spec.ts +++ b/apps/server/src/ai-chat/ai-review.service.spec.ts @@ -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 = {}) { const reviews = { @@ -19,12 +18,6 @@ function createService(overrides: Record = {}) { 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>; - }>; - 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(); diff --git a/apps/server/src/ai-chat/ai-review.service.ts b/apps/server/src/ai-chat/ai-review.service.ts index b4f40c8..0effea4 100644 --- a/apps/server/src/ai-chat/ai-review.service.ts +++ b/apps/server/src/ai-chat/ai-review.service.ts @@ -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 { - 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 { 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 { - 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 { - return this.reviews.findOne({ - where: { assistantMessageId, status: 'pending' }, - order: { createdAt: 'DESC' }, - }); - } - - /** - * 服务端直接解析上传的 Excel 生成审阅分表:行数据来自文件原文, - * 不经过模型转抄,避免漏行/错值。表头按内置字典自动映射, - * 模型可通过 sections[].columns[].sourceHeader 显式指定映射。 - */ - async buildSectionsFromWorkbook( - sheets: ExcelSheetRows[], - rawArgs: unknown, - ): Promise { - 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 { return { id: review.id, diff --git a/apps/server/src/ai-chat/ai-review.shared.ts b/apps/server/src/ai-chat/ai-review.shared.ts index 615920e..f917ba6 100644 --- a/apps/server/src/ai-chat/ai-review.shared.ts +++ b/apps/server/src/ai-chat/ai-review.shared.ts @@ -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([ +const SECTION_TYPES = new Set([ 'students', 'rooms', 'transfers', @@ -36,171 +23,6 @@ export const SECTION_DEPENDENCIES: Record> = { - 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> = { - 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> = { - 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 { - 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, allowed: Set, 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, - }; -} diff --git a/apps/server/src/ai-chat/ai-review.validation.ts b/apps/server/src/ai-chat/ai-review.validation.ts deleted file mode 100644 index af8c96e..0000000 --- a/apps/server/src/ai-chat/ai-review.validation.ts +++ /dev/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(); - const sections = rawArgs.sections.map((item, index) => - validateSection(item, index, seenKeys), - ); - return { title, summary, sections }; -} - -function validateSection( - raw: unknown, - index: number, - seenKeys: Set, -): AiReviewSection { - if (!isPlainRecord(raw)) throw new BadRequestException(`第 ${index + 1} 个分表格式无效`); - assertKeys(raw, SECTION_KEYS_ALLOWED, `第 ${index + 1} 个分表`); - const key = requireString(raw.key, `第 ${index + 1} 个分表标识`, MAX_COLUMN_KEY); - if (!SECTION_KEY_RE.test(key)) { - throw new BadRequestException( - `分表标识 ${key} 只能包含字母、数字、下划线(≤50)`, - ); - } - const type = normalizeSectionType(key, raw.type); - if (seenKeys.has(key)) throw new BadRequestException(`分表标识重复: ${key}`); - seenKeys.add(key); - const title = requireString(raw.title, `分表「${key}」标题`, MAX_SECTION_TITLE); - if (raw.kind !== 'table') throw new BadRequestException(`分表「${key}」的 kind 只能是 table`); - const sheet = - raw.sheet === undefined || raw.sheet === null - ? undefined - : requireString(raw.sheet, `分表「${key}」工作表`, MAX_SECTION_TITLE); - if (!Array.isArray(raw.columns) || raw.columns.length === 0) { - throw new BadRequestException(`分表「${key}」至少需要一个列`); - } - if (raw.columns.length > MAX_COLUMNS) { - throw new BadRequestException(`分表「${key}」的列数不能超过 ${MAX_COLUMNS}`); - } - const seenColumns = new Set(); - const aliases = SECTION_ALIASES[type] ?? {}; - const columns = raw.columns.map((column, columnIndex) => { - if (!isPlainRecord(column)) { - throw new BadRequestException(`分表「${key}」第 ${columnIndex + 1} 列格式无效`); - } - assertKeys(column, COLUMN_KEYS_ALLOWED, `分表「${key}」第 ${columnIndex + 1} 列`); - const rawKey = requireString(column.key, `分表「${key}」列名`, MAX_COLUMN_KEY); - const columnKey = aliases[rawKey] ?? rawKey; - if (!COLUMN_KEY_RE.test(columnKey)) { - throw new BadRequestException(`分表「${key}」列名 ${columnKey} 只能包含字母、数字、下划线`); - } - if (seenColumns.has(columnKey)) { - throw new BadRequestException(`分表「${key}」列名重复: ${columnKey}`); - } - seenColumns.add(columnKey); - const columnTitle = requireString(column.title, `分表「${key}」列「${columnKey}」标题`, MAX_COLUMN_TITLE); - return { key: columnKey, title: columnTitle }; - }); - if (!Array.isArray(raw.rows) || raw.rows.length > MAX_ROWS) { - throw new BadRequestException(`分表「${key}」的行数不能超过 ${MAX_ROWS}`); - } - const rows = raw.rows.map((row, rowIndex) => - validateRow(row, type, rowIndex, new Set(seenColumns), aliases), - ); - let issues: string[] = []; - if (raw.issues !== undefined) { - if (!Array.isArray(raw.issues) || raw.issues.length > MAX_ISSUES) { - throw new BadRequestException(`分表「${key}」的问题数不能超过 ${MAX_ISSUES}`); - } - issues = raw.issues.map((issue) => - requireString(issue, `分表「${key}」的问题`, MAX_ISSUE_LENGTH), - ); - } - return { - key, - type, - title, - kind: 'table', - ...(sheet ? { sheet } : {}), - columns, - rows, - issues, - }; -} - -function validateRow( - raw: unknown, - sectionType: AiReviewSectionType, - index: number, - knownColumns: Set, - aliases: Record, -): AiReviewRow { - if (!isPlainRecord(raw)) { - throw new BadRequestException(`分表「${sectionType}」第 ${index + 1} 行格式无效`); - } - const row: AiReviewRow = {}; - for (const [key, value] of Object.entries(raw)) { - const canonicalKey = aliases[key] ?? key; - if (!knownColumns.has(canonicalKey)) continue; - if (value === null || typeof value === 'boolean') { - row[canonicalKey] = value; - continue; - } - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 必须是有效数字`, - ); - } - row[canonicalKey] = value; - continue; - } - if (typeof value === 'string') { - if (value.length > MAX_CELL_LENGTH) { - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`, - ); - } - row[canonicalKey] = value; - continue; - } - throw new BadRequestException( - `分表「${sectionType}」第 ${index + 1} 行 ${key} 类型不支持`, - ); - } - return row; -} diff --git a/apps/server/src/ai-chat/ai-review.workbook.ts b/apps/server/src/ai-chat/ai-review.workbook.ts index 49e84dc..fd700ff 100644 --- a/apps/server/src/ai-chat/ai-review.workbook.ts +++ b/apps/server/src/ai-chat/ai-review.workbook.ts @@ -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(); - 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 { - 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(); - 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(); - 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 { - const merged: Record = { - ...SECTION_HEADER_ALIASES[key], - ...SECTION_ALIASES[key], - }; - const map = new Map(); - 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; diff --git a/apps/server/src/ai-chat/ai-validation.ts b/apps/server/src/ai-chat/ai-validation.ts new file mode 100644 index 0000000..6bffb1a --- /dev/null +++ b/apps/server/src/ai-chat/ai-validation.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; + +export function isPlainRecord(value: unknown): value is Record { + 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, allowed: Set, label: string): void { + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); + } +}