diff --git a/apps/server/src/ai-chat/ai-attachment.service.spec.ts b/apps/server/src/ai-chat/ai-attachment.service.spec.ts index afca1c7..cb5cbb2 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.spec.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.spec.ts @@ -20,6 +20,14 @@ describe('AiAttachmentService', () => { expect(detectMimeType(buffer, declared)).toBe(expected); }); + it('detects CSV from the declared MIME type without a binary signature', () => { + const detectMimeType = ( + service as unknown as { detectMimeType(buffer: Buffer, declared: string): string } + ).detectMimeType.bind(service); + expect(detectMimeType(Buffer.from('姓名,学号\n张三,1\n'), 'text/csv')).toBe('text/csv'); + expect(detectMimeType(Buffer.from('a,b\n1,2\n'), 'application/csv')).toBe('text/csv'); + }); + it('rejects more than five attachments before repository access', async () => { await expect(service.requireReadyOwned(7, [1, 2, 3, 4, 5, 6])).rejects.toBeInstanceOf( BadRequestException, @@ -48,6 +56,8 @@ describe('AiAttachmentService', () => { ).assertFileExtension.bind(service); expect(() => assertFileExtension('report.exe', 'application/pdf')).toThrow(BadRequestException); expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow(); + expect(() => assertFileExtension('students.csv', 'text/csv')).not.toThrow(); + expect(() => assertFileExtension('students.xlsx', 'text/csv')).toThrow(BadRequestException); }); it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => { diff --git a/apps/server/src/ai-chat/ai-attachment.service.ts b/apps/server/src/ai-chat/ai-attachment.service.ts index 0f07732..82a1544 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.ts @@ -20,6 +20,8 @@ const ACCEPTED_MIME_TYPES = new Set([ 'image/png', 'image/webp', 'application/pdf', + 'text/csv', + 'application/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', @@ -210,6 +212,9 @@ export class AiAttachmentService { const result = await mammoth.extractRawText({ buffer }); return this.normalizeExtractedText(result.value); } + if (mimeType.includes('csv')) { + return this.normalizeExtractedText(buffer.toString('utf8').replace(/^\uFEFF/, '')); + } if (mimeType.includes('spreadsheetml')) { return this.normalizeExtractedText(await this.excelReader.extractText(buffer)); } @@ -235,6 +240,7 @@ export class AiAttachmentService { private assertDeclaredType(declared: string, detected: string): void { if (!declared || declared === 'application/octet-stream') return; + if (detected.includes('csv') || declared.includes('csv')) return; if (declared !== detected) throw new BadRequestException('附件类型与文件内容不一致'); } @@ -245,6 +251,8 @@ export class AiAttachmentService { 'image/png': ['png'], 'image/webp': ['webp'], 'application/pdf': ['pdf'], + 'text/csv': ['csv'], + 'application/csv': ['csv'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'], 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'], @@ -278,6 +286,7 @@ export class AiAttachmentService { ) { return declaredMimeType; } + if (/csv/i.test(declaredMimeType)) return 'text/csv'; return 'application/octet-stream'; } @@ -305,6 +314,8 @@ export class AiAttachmentService { 'image/png': 'png', 'image/webp': 'webp', 'application/pdf': 'pdf', + 'text/csv': 'csv', + 'application/csv': 'csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 308ee72..746366f 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -18,7 +18,7 @@ export const A2UI_TOOL_SCHEMAS = [ function: { name: 'preflight_import', description: - '对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;根据报告向用户确认后,再调用 start_import_wizard。', + '对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;预检结果会以可交互卡片展示列映射与策略确认,引导用户在卡内点击「生成导入向导」,无需在聊天里重复确认卡内已覆盖的问题。', parameters: { type: 'object', properties: { @@ -26,6 +26,12 @@ export const A2UI_TOOL_SCHEMAS = [ type: 'integer', description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。', }, + headerRow: { + type: 'integer', + description: '表头所在行(从 1 开始,默认 1)。预检时对整个文件使用该行作为表头。', + minimum: 1, + maximum: 1000, + }, }, required: ['attachmentId'], additionalProperties: false, @@ -144,70 +150,6 @@ export const A2UI_TOOL_SCHEMAS = [ }, }, }, - { - type: 'function' as const, - function: { - name: 'render_review', - description: - '生成一张“批量导入工作流预览卡”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 后,系统直接解析文件生成行数据(推荐,避免抄录错误),sections 只需给出分表、表名和列映射;无附件时才手工提供 rows。用户确认后系统才会入库。每个回答回合只能调用一次,且只生成一张预览卡:需要导入的多个分表(最多 20 个)必须合并到同一次调用的 sections 里,一次全部给出;同一业务类型可有多张 sheet,每张 sheet 分配唯一 key 并填写正确的 type;生成成功后直接提示用户审阅,可逐表确认、整组确认或一次全部确认,不要重复调用本工具。', - parameters: { - type: 'object', - properties: { - title: { type: 'string', description: '预览标题(≤50字)', maxLength: 50 }, - summary: { type: 'string', description: '预览说明(≤500字)', maxLength: 500 }, - attachmentId: { - type: 'integer', - description: '上传的 Excel 附件 ID。传入后系统直接从文件读取全部行数据,无需(也不要)在 rows 里抄录数据。', - }, - sections: { - type: 'array', - description: '分表预览(1-20个)。每张 sheet 的 key 必须是唯一实例 ID(仅字母数字下划线,≤50),type 为业务类型。', - minItems: 1, - maxItems: 20, - items: { - type: 'object', - properties: { - key: { type: 'string', description: '唯一实例 ID(如 checkins_girls_4、students_building_2),仅字母数字下划线且 ≤50 字符', pattern: '^[a-zA-Z0-9_]{1,50}$' }, - type: { type: 'string', description: '业务类型:students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录', enum: ['students', 'rooms', 'transfers', 'checkins'] }, - title: { type: 'string', description: '分表标题(≤50字)', maxLength: 50 }, - kind: { type: 'string', enum: ['table'], description: '固定为 table' }, - sheet: { type: 'string', description: '工作表名称(与 Excel 中的 sheet 名一致);省略时使用第一个工作表' }, - headerRow: { type: 'integer', description: '表头所在行(从 1 开始),默认 1' }, - columns: { - type: 'array', - description: '表格列定义(1-30个)。省略 sourceHeader 时系统按表头文字自动识别;给出 sourceHeader 可指定该列在工作表中的原始表头。', - items: { - type: 'object', - properties: { - key: { type: 'string', description: '列标识,仅字母数字下划线', pattern: '^[a-zA-Z0-9_]{1,50}$' }, - title: { type: 'string', description: '列中文标题(≤50字)', maxLength: 50 }, - sourceHeader: { type: 'string', description: '工作表中对应的原始表头文字(如 姓名/手机号)', maxLength: 50 }, - }, - required: ['key', 'title'], - additionalProperties: false, - }, - }, - rows: { - type: 'array', - description: '行数据(≤500行)。建议键名:学生 name/phone/studentNo/gender/organization;宿舍 roomNumber/capacity/building/floor/roomType;换宿 studentNo 或 studentPhone、oldRoom、newRoom、transferDate(YYYY-MM-DD);入住记录 name/phone 或 studentNo、roomNumber、checkInDate(YYYY-MM-DD)。服务端兼容常见别名。', - items: { - type: 'object', - description: '单元格值仅允许字符串、数字、布尔或 null', - additionalProperties: { anyOf: CELL_VALUE_ANY_OF }, - }, - }, - issues: { type: 'array', description: '解析中发现的问题(≤50条)', items: { type: 'string' } }, - }, - required: ['key', 'type', 'title', 'kind', 'columns', 'rows'], - additionalProperties: false, - }, - }, - }, - required: ['title', 'sections'], - additionalProperties: false, - }, - }, - }, { type: 'function' as const, function: { @@ -260,10 +202,11 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。 当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: 1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。 -2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。 +2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。 报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 -3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 +3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。 4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 +工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。 每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。 @@ -271,6 +214,6 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 - 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。 - 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 - 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。 -- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再按报告提问并生成导入向导,按依赖顺序执行。 +- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts index 49339f9..e092adc 100644 --- a/apps/server/src/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/ai-chat/ai-chat.controller.ts @@ -30,6 +30,7 @@ import { EditMessageDto, MessagePageQueryDto, RegenerateMessageDto, + ResolveImportPreflightDto, SendMessageDto, SubmitFormDto, SubmitReviewDto, @@ -237,6 +238,23 @@ export class AiChatController { ); } + @Post('import/preflight/:messageId/resolve/stream') + @Throttle({ default: { ttl: 60000, limit: 10 } }) + async resolveImportPreflight( + @Req() req: AuthenticatedRequest, + @Res() res: Response, + @Param('messageId', ParseIntPipe) messageId: number, + @Body() dto: ResolveImportPreflightDto, + ): Promise { + const conversationId = await this.service.resolvePreflightConversationId( + req.user.id, + messageId, + ); + return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) => + this.service.resolveImportPreflight(req.user, messageId, dto, signal, emit, onReady), + ); + } + @Post('reviews/:reviewId/steps/:sectionKey/confirm') async confirmReviewStep( @Req() req: AuthenticatedRequest, diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index 5c87a9b..6a63bf6 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -78,7 +78,6 @@ export async function executeGeneration( ); } tools.push(...A2UI_TOOL_SCHEMAS); - tools = tools.filter((tool) => tool.function.name !== 'render_review'); const runtimeConfig = await context.configService.getRuntimeConfig(); const config = { ...runtimeConfig, diff --git a/apps/server/src/ai-chat/ai-chat.import-confirm.ts b/apps/server/src/ai-chat/ai-chat.import-confirm.ts new file mode 100644 index 0000000..55e20b8 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.import-confirm.ts @@ -0,0 +1,109 @@ +import { BadRequestException } from '@nestjs/common'; +import { + IMPORT_FIELD_ALIASES, + IMPORT_STEP_KEYS, + type ColumnMapping, + type ImportRunSettings, + type ImportStepKey, +} from '../imports/imports.types'; + +export interface ConfirmedMappingOptions { + /** 每个业务阶段允许的表头集合;为空时不校验表头是否存在。 */ + allowedHeadersByStep?: Partial>; +} + +/** + * 解析并校验用户确认的列映射。 + * 与 start_import_wizard 工具、预检卡 resolve 端点共用,保证两条入口口径一致。 + */ +export function parseConfirmedMapping( + raw: unknown, + options: ConfirmedMappingOptions = {}, +): Partial> | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new BadRequestException('mapping 参数格式错误'); + } + const mapping: Partial> = {}; + for (const [stepKey, fields] of Object.entries(raw as Record)) { + if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) { + throw new BadRequestException(`mapping 包含未知业务类型:${stepKey}`); + } + if (fields === undefined || fields === null) continue; + if (typeof fields !== 'object' || Array.isArray(fields)) { + throw new BadRequestException(`mapping 中「${stepKey}」的列映射格式错误`); + } + const typedStepKey = stepKey as ImportStepKey; + const allowedFields = new Set(Object.keys(IMPORT_FIELD_ALIASES[typedStepKey])); + const allowedHeaders = new Set(options.allowedHeadersByStep?.[typedStepKey] ?? []); + const columnMapping: ColumnMapping = {}; + for (const [field, header] of Object.entries(fields as Record)) { + if (typeof field !== 'string' || !field.trim() || field.length > 50) continue; + if (!allowedFields.has(field)) { + throw new BadRequestException(`mapping 中「${stepKey}」包含未知字段:${field}`); + } + if (typeof header !== 'string' || !header.trim()) continue; + const headerName = header.slice(0, 200); + if (allowedHeaders.size > 0 && !allowedHeaders.has(headerName)) { + throw new BadRequestException( + `「${stepKey}」列映射「${headerName}」不在工作表表头中`, + ); + } + columnMapping[field] = headerName; + } + mapping[typedStepKey] = columnMapping; + } + return mapping; +} + +/** 从包含顶层 organization/updateExisting/duplicatePolicy/skipUnmatched 的对象解析策略设置。 */ +export function parseConfirmedSettings(parsedRecord: Record): ImportRunSettings { + const settings: ImportRunSettings = {}; + if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) { + if (typeof parsedRecord.organization !== 'string') { + throw new BadRequestException('organization 必须是字符串'); + } + const organization = parsedRecord.organization.trim().slice(0, 100); + if (organization) settings.organization = organization; + } + if (parsedRecord.updateExisting !== undefined) { + if (typeof parsedRecord.updateExisting !== 'boolean') { + throw new BadRequestException('updateExisting 必须是布尔值'); + } + settings.updateExisting = parsedRecord.updateExisting; + } + if (parsedRecord.duplicatePolicy !== undefined) { + if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') { + throw new BadRequestException('duplicatePolicy 只能是 error 或 skip'); + } + settings.duplicatePolicy = parsedRecord.duplicatePolicy; + } + if (parsedRecord.skipUnmatched !== undefined) { + if (typeof parsedRecord.skipUnmatched !== 'boolean') { + throw new BadRequestException('skipUnmatched 必须是布尔值'); + } + settings.skipUnmatched = parsedRecord.skipUnmatched; + } + 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; +}): boolean { + return ( + attachment.mimeType.includes('spreadsheetml') || + attachment.mimeType.includes('excel') || + attachment.mimeType.includes('csv') || + /\.(xlsx|csv)$/i.test(attachment.originalName) + ); +} 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 518a54d..b976e61 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1,4 +1,9 @@ -import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { AiChatService } from './ai-chat.service'; const authenticatedUser = { @@ -498,158 +503,6 @@ describe('AiChatService', () => { ); }); - it('render_review 生成的预览通过 ui.review 推送并保留在消息 metadata 中', async () => { - const conversation = { - id: 3, - userId: 7, - title: '测试', - lockedSkillKey: null, - lastMessageAt: null, - }; - const assistant = { - id: 12, - conversationId: 3, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: 11, - metadata: {}, - }; - const reviewShape = { - id: 'review-1', - conversationId: 3, - title: '批量导入', - status: 'pending', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '张三' }], - issues: [], - }, - ], - }; - const messageSave = jest.fn(async (value) => value); - const messages = { - exists: jest.fn().mockResolvedValue(false), - find: jest.fn().mockResolvedValue([]), - findOne: jest.fn().mockImplementation((options?: unknown) => { - const opts = options as { select?: { metadata?: boolean } } | undefined; - if (opts?.select?.metadata) - return Promise.resolve({ metadata: { a2uiReview: reviewShape } }); - return Promise.resolve(assistant); - }), - save: messageSave, - }; - const manager = { - create: jest.fn((_entity, value) => value), - save: jest - .fn() - .mockResolvedValueOnce({ - id: 11, - conversationId: 3, - role: 'user', - content: '导入这个Excel', - }) - .mockResolvedValueOnce(assistant), - update: jest.fn(), - }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => value), - find: jest.fn().mockResolvedValue([]), - }; - const modelStream = { - stream: async function* () { - yield { - type: 'complete' as const, - toolCalls: [ - { - id: 'call-1', - name: 'render_review', - arguments: JSON.stringify({ - title: '批量导入', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '张三' }], - }, - ], - }), - }, - ], - }; - }, - }; - const service = new AiChatService( - { findOne: jest.fn().mockResolvedValue(conversation) } as never, - messages as never, - toolRuns as never, - { transaction: jest.fn(async (callback) => callback(manager)) } as never, - { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, - { listAvailable: jest.fn().mockReturnValue([]) } as never, - modelStream as never, - { - requireReadyOwned: jest.fn().mockResolvedValue([]), - toModelParts: jest.fn().mockResolvedValue([]), - serialize: jest.fn((value) => value), - } as never, - { - createForm: jest.fn(), - findOwnedPending: jest.fn(), - validateValues: jest.fn(), - markSubmitted: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { - createReview: jest.fn().mockResolvedValue(reviewShape), - expirePreviousReviews: jest.fn().mockResolvedValue([]), - findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), - serialize: jest.fn((value) => value), - submit: jest.fn(), - } as never, - { - createChart: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { createForUser: jest.fn().mockReturnValue({}) } as never, - { assertPermission: jest.fn(), canPermission: jest.fn() } as never, - ); - const emitted: Array<{ event: string; data: Record }> = []; - - await service.streamMessage( - authenticatedUser as never, - 3, - { - message: '导入这个Excel', - attachmentIds: [], - skillKey: null, - clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', - }, - new AbortController().signal, - (event, data) => emitted.push({ event, data }), - jest.fn(), - ); - - expect(emitted.some(({ event }) => event === 'ui.review')).toBe(true); - expect(messageSave).toHaveBeenCalledWith( - expect.objectContaining({ - id: 12, - metadata: expect.objectContaining({ - a2uiReview: expect.objectContaining({ id: 'review-1' }), - }), - }), - ); - }); - it('render_chart 生成的图表通过 ui.chart 推送并追加到消息 metadata', async () => { const conversation = { id: 3, @@ -794,301 +647,6 @@ describe('AiChatService', () => { ); }); - it('批量导入确认后的生成轮次中,模型再调 render_review 被拒绝', async () => { - const conversation = { - id: 3, - userId: 7, - title: '测试', - lockedSkillKey: null, - lastMessageAt: null, - }; - const assistant = { - id: 12, - conversationId: 3, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: 11, - metadata: {}, - }; - const messageSave = jest.fn(async (value) => value); - const messages = { - exists: jest.fn().mockResolvedValue(false), - find: jest.fn().mockResolvedValue([]), - findOne: jest.fn().mockImplementation((options?: unknown) => { - const opts = options as { select?: { metadata?: boolean } } | undefined; - if (opts?.select?.metadata) return Promise.resolve({ metadata: {} }); - return Promise.resolve(assistant); - }), - save: messageSave, - }; - const manager = { - create: jest.fn((_entity, value) => value), - save: jest - .fn() - .mockResolvedValueOnce({ - id: 11, - conversationId: 3, - role: 'user', - content: '已确认导入', - metadata: { - a2uiReviewSubmit: { - reviewId: 'review-1', - reviewTitle: '批量导入', - resultMessage: '成功导入学生 1 人', - }, - }, - }) - .mockResolvedValueOnce(assistant), - update: jest.fn(), - }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => value), - find: jest.fn().mockResolvedValue([]), - }; - let toolRound = 0; - const modelStream = { - stream: async function* () { - toolRound += 1; - if (toolRound === 1) { - yield { - type: 'complete' as const, - toolCalls: [ - { id: 'call-1', name: 'render_review', arguments: '{}' }, - { id: 'call-2', name: 'create_student', arguments: '{}' }, - { id: 'call-3', name: 'update_students', arguments: '{}' }, - ], - }; - } else { - yield { type: 'complete' as const, toolCalls: [] }; - } - }, - }; - const createReview = jest.fn(); - const service = new AiChatService( - { findOne: jest.fn().mockResolvedValue(conversation) } as never, - messages as never, - toolRuns as never, - { transaction: jest.fn(async (callback) => callback(manager)) } as never, - { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, - { listAvailable: jest.fn().mockReturnValue([]) } as never, - modelStream as never, - { - requireReadyOwned: jest.fn().mockResolvedValue([]), - toModelParts: jest.fn().mockResolvedValue([]), - serialize: jest.fn((value) => value), - } as never, - { - createForm: jest.fn(), - findOwnedPending: jest.fn(), - validateValues: jest.fn(), - markSubmitted: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { - createReview, - expirePreviousReviews: jest.fn().mockResolvedValue([]), - findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), - serialize: jest.fn((value) => value), - submit: jest.fn(), - } as never, - { - createChart: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { createForUser: jest.fn().mockReturnValue({}) } as never, - { assertPermission: jest.fn(), canPermission: jest.fn() } as never, - ); - const emitted: Array<{ event: string; data: Record }> = []; - - await service.streamMessage( - authenticatedUser as never, - 3, - { - message: '已确认导入', - attachmentIds: [], - skillKey: null, - clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', - }, - new AbortController().signal, - (event, data) => emitted.push({ event, data }), - jest.fn(), - ); - - expect(createReview).not.toHaveBeenCalled(); - const failedTools = emitted.filter(({ event }) => event === 'tool.failed'); - expect(failedTools.map(({ data }) => (data as { toolName?: string }).toolName)).toEqual([ - 'render_review', - 'create_student', - 'update_students', - ]); - }); - - it('同一消息回合内 render_review 只生成一张预览卡,重复调用被拒绝', async () => { - const conversation = { - id: 3, - userId: 7, - title: '测试', - lockedSkillKey: null, - lastMessageAt: null, - }; - const assistant = { - id: 12, - conversationId: 3, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: 11, - metadata: {}, - }; - const reviewShape = { - id: 'review-1', - conversationId: 3, - title: '批量导入', - status: 'pending', - sections: [ - { - key: 'students', - title: '学生', - kind: 'table', - columns: [{ key: 'name', title: '姓名' }], - rows: [{ name: '张三' }], - issues: [], - }, - ], - }; - const messageSave = jest.fn(async (value) => value); - const messages = { - exists: jest.fn().mockResolvedValue(false), - find: jest.fn().mockResolvedValue([]), - findOne: jest.fn().mockImplementation((options?: unknown) => { - const opts = options as { select?: { metadata?: boolean } } | undefined; - if (opts?.select?.metadata) - return Promise.resolve({ metadata: { a2uiReview: reviewShape } }); - return Promise.resolve(assistant); - }), - save: messageSave, - }; - const manager = { - create: jest.fn((_entity, value) => value), - save: jest - .fn() - .mockResolvedValueOnce({ - id: 11, - conversationId: 3, - role: 'user', - content: '导入这个Excel', - }) - .mockResolvedValueOnce(assistant), - update: jest.fn(), - }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => value), - find: jest.fn().mockResolvedValue([]), - }; - let toolRound = 0; - const modelStream = { - stream: async function* () { - toolRound += 1; - if (toolRound === 1) { - yield { - type: 'complete' as const, - toolCalls: [ - { - id: 'call-1', - name: 'render_review', - arguments: JSON.stringify({ - title: '批量导入', - sections: [{ key: 'students', title: '学生', kind: 'table' }], - }), - }, - { - id: 'call-2', - name: 'render_review', - arguments: JSON.stringify({ - title: '批量导入', - sections: [{ key: 'rooms', title: '宿舍', kind: 'table' }], - }), - }, - ], - }; - } else { - yield { type: 'complete' as const, toolCalls: [] }; - } - }, - }; - const createReview = jest.fn().mockResolvedValue(reviewShape); - const service = new AiChatService( - { findOne: jest.fn().mockResolvedValue(conversation) } as never, - messages as never, - toolRuns as never, - { transaction: jest.fn(async (callback) => callback(manager)) } as never, - { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, - { listAvailable: jest.fn().mockReturnValue([]) } as never, - modelStream as never, - { - requireReadyOwned: jest.fn().mockResolvedValue([]), - toModelParts: jest.fn().mockResolvedValue([]), - serialize: jest.fn((value) => value), - } as never, - { - createForm: jest.fn(), - findOwnedPending: jest.fn(), - validateValues: jest.fn(), - markSubmitted: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { - createReview, - expirePreviousReviews: jest.fn().mockResolvedValue([]), - findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(reviewShape), - serialize: jest.fn((value) => value), - submit: jest.fn(), - } as never, - { - createChart: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { createForUser: jest.fn().mockReturnValue({}) } as never, - { assertPermission: jest.fn(), canPermission: jest.fn() } as never, - ); - const emitted: Array<{ event: string; data: Record }> = []; - - await service.streamMessage( - authenticatedUser as never, - 3, - { - message: '导入这个Excel', - attachmentIds: [], - skillKey: null, - clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', - }, - new AbortController().signal, - (event, data) => emitted.push({ event, data }), - jest.fn(), - ); - - expect(createReview).toHaveBeenCalledTimes(1); - expect(emitted.filter(({ event }) => event === 'ui.review')).toHaveLength(1); - const denied = emitted.find( - ({ event, data }) => - event === 'tool.failed' && (data as { toolName?: string }).toolName === 'render_review', - ); - expect(denied).toBeDefined(); - expect((denied?.data as { error?: string }).error).toContain('不要再次调用 render_review'); - }); - it('submitReview 无写入权限时拒绝批量导入', async () => { const conversation = { id: 3, @@ -1787,6 +1345,62 @@ describe('AiChatService', () => { expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true); }); + it('start_import_wizard 拒绝非法 headerRow', async () => { + const { service } = createService(); + const toolRun = { id: 1, status: 'running' }; + const toolRuns = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...toolRun, ...value })), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ id: 42, metadata: null }), + save: jest.fn(async (value) => value), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + }, + ]), + readStoredBuffer: jest.fn(), + }; + const importsService = { createRun: jest.fn() }; + (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + const result = await ( + service as unknown as { + executeStartImportWizard( + messageId: number, + call: { id: string; name: string; arguments: string }, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executeStartImportWizard( + 42, + { + id: 'call-1', + name: 'start_import_wizard', + arguments: JSON.stringify({ + attachmentId: 9, + stages: [{ stepKey: 'students', sheet: '学生', headerRow: 0 }], + }), + }, + { userId: 7, permissions: [], isSuperAdmin: false }, + jest.fn(), + ); + + const parsed = JSON.parse(result) as { status: string; error: string }; + expect(parsed.status).toBe('failed'); + expect(parsed.error).toContain('headerRow'); + expect(importsService.createRun).not.toHaveBeenCalled(); + }); + it('start_import_wizard 接收确认参数并写入导入任务', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; @@ -1840,7 +1454,7 @@ describe('AiChatService', () => { name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, - stages: [{ stepKey: 'students', sheet: '学生' }], + stages: [{ stepKey: 'students', sheet: '学生', headerRow: 2 }], mapping: { students: { name: '姓名', studentNo: '学号' } }, organization: '主校区', updateExisting: false, @@ -1854,12 +1468,13 @@ describe('AiChatService', () => { const parsed = JSON.parse(result) as { status: string }; expect(parsed.status).toBe('success'); + expect(parsed).toMatchObject({ permittedSteps: [] }); expect(importsService.createRun).toHaveBeenCalledWith( { id: 7, permissions: [], isSuperAdmin: false }, 'ai', expect.objectContaining({ originalName: 'students.xlsx' }), 3, - [{ stepKey: 'students', sheet: '学生' }], + [{ stepKey: 'students', sheet: '学生', headerRow: 2 }], { students: { name: '姓名', studentNo: '学号' } }, { organization: '主校区', @@ -1924,7 +1539,7 @@ describe('AiChatService', () => { executePreflightImport( messageId: number, call: { id: string; name: string; arguments: string }, - userId: number, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } @@ -1935,24 +1550,193 @@ describe('AiChatService', () => { name: 'preflight_import', arguments: JSON.stringify({ attachmentId: 9 }), }, - 7, + { userId: 7, permissions: [], isSuperAdmin: false }, (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), ); const parsed = JSON.parse(result) as { status: string; report: unknown }; expect(parsed.status).toBe('success'); expect(parsed.report).toEqual(report); + expect(parsed).toMatchObject({ permittedSteps: [] }); expect(importsService.preflightFile).toHaveBeenCalledWith( expect.objectContaining({ originalName: 'students.xlsx' }), + 1, ); expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); expect(messages.save).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.objectContaining({ a2uiImportPreflight: report }), + metadata: expect.objectContaining({ + a2uiImportPreflight: { + ...report, + attachmentId: 9, + headerRow: 1, + permittedSteps: [], + resolved: false, + runId: null, + }, + }), }), ); }); + it('preflight_import 校验并透传 headerRow', async () => { + const { service } = createService(); + const toolRun = { id: 1, status: 'running' }; + const toolRuns = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...toolRun, ...value })), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), + save: jest.fn(async (value) => value), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const report = { + verdict: 'ready', + stages: [], + blocks: [], + questions: [], + errorSamples: [], + nextSteps: [], + }; + const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; + (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + const result = await ( + service as unknown as { + executePreflightImport( + messageId: number, + call: { id: string; name: string; arguments: string }, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executePreflightImport( + 42, + { + id: 'call-1', + name: 'preflight_import', + arguments: JSON.stringify({ attachmentId: 9, headerRow: 5 }), + }, + { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, + jest.fn(), + ); + + const parsed = JSON.parse(result) as { status: string; permittedSteps: string[] }; + expect(parsed.status).toBe('success'); + expect(parsed.permittedSteps).toEqual(['students']); + expect(importsService.preflightFile).toHaveBeenCalledWith( + expect.objectContaining({ originalName: 'students.xlsx' }), + 5, + ); + }); + + it('preflight_import 结果超过 32KB 时返回精简版,SSE 仍推送完整报告', async () => { + const { service } = createService(); + const toolRun = { id: 1, status: 'running' }; + const toolRuns = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...toolRun, ...value })), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), + save: jest.fn(async (value) => value), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const report = { + verdict: 'needs_input', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + total: 2000, + create: 0, + update: 0, + error: 2000, + skip: 0, + mapping: { name: '姓名' }, + missingRequired: [], + }, + ], + blocks: [], + questions: [{ key: 'mapping_students', type: 'mapping', label: '确认列映射' }], + errorSamples: Array.from({ length: 2000 }, (_, index) => ({ + code: 'format_error', + stepKey: 'students', + sheet: '学生', + rowNumber: index + 2, + errors: ['手机号格式不正确:'.repeat(20)], + })), + nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '下一步' }], + }; + const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; + (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + const emitted: Array<{ event: string; data: Record }> = []; + + const result = await ( + service as unknown as { + executePreflightImport( + messageId: number, + call: { id: string; name: string; arguments: string }, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executePreflightImport( + 42, + { + id: 'call-1', + name: 'preflight_import', + arguments: JSON.stringify({ attachmentId: 9 }), + }, + { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, + (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), + ); + + const parsed = JSON.parse(result) as { + status: string; + truncated: boolean; + report: { verdict: string; errorSamples: unknown[] }; + }; + expect(parsed.status).toBe('success'); + expect(parsed.truncated).toBe(true); + expect(parsed.report.verdict).toBe('needs_input'); + expect(parsed.report.errorSamples).toHaveLength(10); + const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); + expect(preflightEvent).toBeDefined(); + expect( + (preflightEvent?.data as { preflight?: { errorSamples?: unknown[] } }).preflight + ?.errorSamples, + ).toHaveLength(2000); + }); + it('start_import_wizard 拒绝非法的确认参数', async () => { const { service } = createService(); @@ -2010,4 +1794,448 @@ describe('AiChatService', () => { expect(parsed.error).toContain('duplicatePolicy'); expect(importsService.createRun).not.toHaveBeenCalled(); }); + + it('resolveImportPreflight 生成导入任务并原位更新预检卡', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const message = { + id: 42, + conversationId: 3, + role: 'assistant', + metadata: { + a2uiImportPreflight: { + verdict: 'needs_input', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + headers: ['姓名', '学号', '手机号'], + mapping: { name: '姓名' }, + missingRequired: [], + total: 1, + create: 1, + update: 0, + error: 0, + skip: 0, + }, + ], + blocks: [], + questions: [], + nextSteps: [], + errorSamples: [], + attachmentId: 9, + headerRow: 1, + permittedSteps: ['students'], + resolved: false, + runId: null, + }, + }, + }; + const messages = { + findOne: jest.fn().mockResolvedValue(message), + save: jest.fn(async (value) => value), + exists: jest.fn().mockResolvedValue(false), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const importsService = { + createRun: jest.fn().mockResolvedValue({ + id: 'run-9', + fileName: 'students.xlsx', + sheets: [], + steps: [ + { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, + ], + }), + }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + const emitted: Array<{ event: string; data: Record }> = []; + await service.resolveImportPreflight( + authenticatedUser, + 42, + { + clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', + mapping: { students: { name: '姓名', studentNo: '学号' } }, + settings: { updateExisting: false }, + }, + new AbortController().signal, + (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), + jest.fn(), + ); + + expect(importsService.createRun).toHaveBeenCalledWith( + { id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false }, + 'ai', + expect.objectContaining({ originalName: 'students.xlsx' }), + 3, + [{ stepKey: 'students', sheet: '学生', headerRow: 1 }], + { students: { name: '姓名', studentNo: '学号' } }, + { updateExisting: false }, + ); + expect(messages.save).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), + a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), + }), + }), + ); + expect(emitted.map(({ event }) => event)).toEqual( + expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), + ); + const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); + expect( + (preflightEvent?.data as { preflight?: { resolved?: boolean; runId?: string | null } }) + .preflight, + ).toMatchObject({ resolved: true, runId: 'run-9' }); + }); + + it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const preflight = { + verdict: 'ready', + stages: [], + blocks: [], + questions: [], + nextSteps: [], + errorSamples: [], + attachmentId: 9, + headerRow: 1, + permittedSteps: [], + resolved: true, + runId: 'run-1', + }; + const messages = { + findOne: jest + .fn() + .mockResolvedValue({ + id: 42, + conversationId: 3, + role: 'assistant', + metadata: { + a2uiImportPreflight: preflight, + a2uiImportWizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] }, + }, + }), + exists: jest.fn().mockResolvedValue(false), + }; + const importsService = { createRun: jest.fn() }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { importsService: unknown }).importsService = importsService; + + const emitted: Array<{ event: string }> = []; + await service.resolveImportPreflight( + authenticatedUser, + 42, + { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, + new AbortController().signal, + (event) => emitted.push({ event }), + jest.fn(), + ); + + expect(importsService.createRun).not.toHaveBeenCalled(); + expect(emitted.map(({ event }) => event)).toEqual( + expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), + ); + }); + + it('resolveImportPreflight 无预检 metadata 时拒绝', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ + id: 42, + conversationId: 3, + role: 'assistant', + metadata: null, + }), + exists: jest.fn().mockResolvedValue(false), + }; + const importsService = { createRun: jest.fn() }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { importsService: unknown }).importsService = importsService; + + await expect( + service.resolveImportPreflight( + authenticatedUser, + 42, + { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, + new AbortController().signal, + jest.fn(), + jest.fn(), + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(importsService.createRun).not.toHaveBeenCalled(); + }); + + it('resolveImportPreflight 拒绝映射到表头之外的列', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ + id: 42, + conversationId: 3, + role: 'assistant', + metadata: { + a2uiImportPreflight: { + verdict: 'blocked', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + headers: ['姓名', '学号'], + mapping: { name: '姓名' }, + missingRequired: [], + total: 1, + create: 0, + update: 0, + error: 0, + skip: 0, + }, + ], + blocks: [], + questions: [], + nextSteps: [], + errorSamples: [], + attachmentId: 9, + headerRow: 1, + permittedSteps: ['students'], + resolved: false, + runId: null, + }, + }, + }), + exists: jest.fn().mockResolvedValue(false), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const importsService = { createRun: jest.fn() }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + await expect( + service.resolveImportPreflight( + authenticatedUser, + 42, + { + clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', + mapping: { students: { name: '不存在的列' } }, + }, + new AbortController().signal, + jest.fn(), + jest.fn(), + ), + ).rejects.toThrow('不在工作表表头中'); + expect(importsService.createRun).not.toHaveBeenCalled(); + }); + + it('resolveImportPreflight 拒绝非法的策略参数', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ + id: 42, + conversationId: 3, + role: 'assistant', + metadata: { + a2uiImportPreflight: { + verdict: 'ready', + stages: [], + blocks: [], + questions: [], + nextSteps: [], + errorSamples: [], + attachmentId: 9, + headerRow: 1, + permittedSteps: [], + resolved: false, + runId: null, + }, + }, + }), + exists: jest.fn().mockResolvedValue(false), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const importsService = { createRun: jest.fn() }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + await expect( + service.resolveImportPreflight( + authenticatedUser, + 42, + { + clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', + settings: { duplicatePolicy: 'bogus' }, + }, + new AbortController().signal, + jest.fn(), + jest.fn(), + ), + ).rejects.toThrow('duplicatePolicy'); + expect(importsService.createRun).not.toHaveBeenCalled(); + }); + + it('resolvePreflightConversationId 返回消息所属会话', async () => { + const { service } = createService(); + const conversations = { + findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ + id: 42, + role: 'assistant', + conversation: { id: 3, userId: 7 }, + }), + }; + (service as unknown as { conversations: unknown }).conversations = conversations; + (service as unknown as { messages: unknown }).messages = messages; + + await expect(service.resolvePreflightConversationId(7, 42)).resolves.toBe(3); + expect(messages.findOne).toHaveBeenCalledWith({ + where: { id: 42 }, + relations: { conversation: true }, + }); + }); + + it('start_import_wizard 在已有预检卡时同步标记 resolved', async () => { + const { service } = createService(); + const toolRun = { id: 1, status: 'running' }; + const toolRuns = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...toolRun, ...value })), + }; + const messages = { + findOne: jest.fn().mockResolvedValue({ + id: 42, + conversationId: 3, + metadata: { + a2uiImportPreflight: { + verdict: 'ready', + stages: [], + blocks: [], + questions: [], + nextSteps: [], + errorSamples: [], + attachmentId: 9, + headerRow: 1, + permittedSteps: ['students'], + resolved: false, + runId: null, + }, + }, + }), + save: jest.fn(async (value) => value), + }; + const attachmentService = { + requireReadyOwned: jest.fn().mockResolvedValue([ + { + id: 9, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + originalName: 'students.xlsx', + size: 10, + }, + ]), + readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), + }; + const importsService = { + createRun: jest.fn().mockResolvedValue({ + id: 'run-9', + fileName: 'students.xlsx', + sheets: [], + steps: [ + { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, + ], + }), + }; + (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; + (service as unknown as { messages: unknown }).messages = messages; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { importsService: unknown }).importsService = importsService; + + const emitted: Array<{ event: string; data: Record }> = []; + await ( + service as unknown as { + executeStartImportWizard( + messageId: number, + call: { id: string; name: string; arguments: string }, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executeStartImportWizard( + 42, + { + id: 'call-1', + name: 'start_import_wizard', + arguments: JSON.stringify({ + attachmentId: 9, + stages: [{ stepKey: 'students', sheet: '学生' }], + }), + }, + { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, + (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), + ); + + expect(messages.save).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), + a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), + }), + }), + ); + expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); + }); }); diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index 2a801a1..de180fa 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -58,7 +58,9 @@ import { } from './ai-chat.streaming'; import { resolveFormConversationId, + resolvePreflightConversationId, resolveReviewConversationId, + resolveImportPreflight, submitForm, submitReview, confirmReviewStep, @@ -297,10 +299,10 @@ export class AiChatService implements AiChatServiceContext { executePreflightImport( messageId: number, call: ModelToolCall, - userId: number, + context: ReturnType, emit: AiSseEmitter, ): Promise { - return executePreflightImport(this, messageId, call, userId, emit); + return executePreflightImport(this, messageId, call, context, emit); } executeGeneration(input: GenerationInput): Promise { @@ -407,6 +409,10 @@ export class AiChatService implements AiChatServiceContext { return resolveReviewConversationId(this, userId, reviewId); } + resolvePreflightConversationId(userId: number, messageId: number): Promise { + return resolvePreflightConversationId(this, userId, messageId); + } + submitForm( user: AuthenticatedUser, formId: string, @@ -433,6 +439,21 @@ export class AiChatService implements AiChatServiceContext { return submitReview(this, user, reviewId, dto, signal, emit, onReady); } + resolveImportPreflight( + user: AuthenticatedUser, + messageId: number, + dto: { + clientRequestId: string; + mapping?: Record; + settings?: Record; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); + } + confirmReviewStep( user: AuthenticatedUser, reviewId: string, diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index be942da..8479662 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -13,6 +13,17 @@ import type { } from './ai-chat.types'; import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; +import type { + ImportStageRequest, + ImportStepKey, + PreflightReport, +} from '../imports/imports.types'; +import { + isExcelAttachment, + parseConfirmedMapping, + parseNestedSettings, +} from './ai-chat.import-confirm'; +import { compactImportWizard } from './ai-chat.tool-actions'; export async function resolveFormConversationId( context: AiChatServiceContext, @@ -32,6 +43,193 @@ export async function resolveReviewConversationId( return review.conversationId; } +export async function resolvePreflightConversationId( + context: AiChatServiceContext, + userId: number, + messageId: number, +): Promise { + const message = await context.messages.findOne({ + where: { id: messageId }, + relations: { conversation: true }, + }); + if (!message) throw new NotFoundException('消息不存在'); + if (message.role !== 'assistant') { + throw new BadRequestException('该消息不是助手消息,无法确认导入预检'); + } + const conversation = await context.requireOwnedConversation(userId, message.conversation.id); + return conversation.id; +} + +function readPreflightCard( + metadata: Record | null | undefined, +): PreflightReport { + const card = metadata?.a2uiImportPreflight; + if (!card || typeof card !== 'object' || Array.isArray(card)) { + throw new BadRequestException('预检报告不存在或已失效'); + } + if (!isPreflightReport(card)) { + throw new BadRequestException('预检报告格式异常'); + } + return card; +} + +function isPreflightReport(value: object): value is PreflightReport { + const record = value as Record; + return ( + typeof record.verdict === 'string' && + Array.isArray(record.stages) && + Array.isArray(record.blocks) && + Array.isArray(record.questions) && + Array.isArray(record.nextSteps) && + Array.isArray(record.errorSamples) + ); +} + +async function loadReviewForConfirm( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, +): Promise { + const review = await context.reviewService.findOwned(reviewId, user.id); + if (review.status === 'submitted') { + throw new ConflictException('导入已全部确认,无需重复确认'); + } + if (review.status === 'expired') { + throw new ConflictException('导入预览已失效,请重新生成预览'); + } + return review; +} + +async function finalizeReview( + context: AiChatServiceContext, + updated: AiReview, +): Promise> { + await context.markReviewSubmittedOnMessage( + updated.assistantMessageId, + updated.conversationId, + updated, + ); + return context.reviewService.serialize(updated); +} + +async function logImportOp( + context: AiChatServiceContext, + user: AuthenticatedUser, + action: string, + detail: string, +): Promise { + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action, + detail, + targetType: 'ai_review', + status: 'success', + }); +} + +export async function resolveImportPreflight( + context: AiChatServiceContext, + user: AuthenticatedUser, + messageId: number, + dto: { + clientRequestId: string; + mapping?: Record; + settings?: Record; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, +): Promise { + context.throwIfAborted(signal); + const message = await context.messages.findOne({ where: { id: messageId } }); + if (!message) throw new NotFoundException('消息不存在'); + if (message.role !== 'assistant') { + throw new BadRequestException('该消息不是助手消息,无法确认导入预检'); + } + const conversation = await context.requireOwnedConversation(user.id, message.conversationId); + const preflight = readPreflightCard(message.metadata); + + await context.acquireConversation(conversation.id); + try { + context.throwIfAborted(signal); + const existingWizard = message.metadata?.a2uiImportWizard; + if (existingWizard && typeof existingWizard === 'object' && !Array.isArray(existingWizard)) { + onReady(); + emit('ui.import_preflight', { + messageId, + preflight: { ...preflight, resolved: true }, + }); + emit('ui.import_wizard', { messageId, wizard: existingWizard }); + return; + } + + const attachmentId = preflight.attachmentId; + const headerRow = preflight.headerRow ?? 1; + if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { + throw new BadRequestException('预检报告缺少附件信息,请重新预检'); + } + const [attachment] = await context.attachmentService.requireReadyOwned(user.id, [ + attachmentId as number, + ]); + if (!isExcelAttachment(attachment)) { + throw new BadRequestException('附件不是 Excel 文件,无法生成导入向导'); + } + const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({ + stepKey: stage.stepKey, + sheet: stage.sheetNames[0], + headerRow, + })); + if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) { + throw new BadRequestException('预检报告缺少工作表信息,请重新预检'); + } + const allowedHeadersByStep: Partial> = {}; + for (const stage of preflight.stages) { + allowedHeadersByStep[stage.stepKey] = stage.headers ?? []; + } + const mapping = parseConfirmedMapping(dto.mapping, { allowedHeadersByStep }); + const settings = parseNestedSettings(dto.settings); + if (!context.importsService) throw new BadRequestException('导入向导服务未配置'); + + const buffer = await context.attachmentService.readStoredBuffer(attachment); + const detail = await context.importsService.createRun( + { + id: user.id, + permissions: [...user.permissions], + isSuperAdmin: user.isSuperAdmin, + }, + 'ai', + { + originalName: attachment.originalName, + mimeType: attachment.mimeType, + size: attachment.size, + buffer, + }, + conversation.id, + stages, + mapping, + settings, + ); + const wizard = compactImportWizard(detail); + message.metadata = { + ...message.metadata, + a2uiImportPreflight: { ...preflight, resolved: true, runId: detail.id }, + a2uiImportWizard: wizard, + }; + await context.messages.save(message); + + onReady(); + emit('ui.import_preflight', { + messageId, + preflight: { ...preflight, resolved: true, runId: detail.id }, + }); + emit('ui.import_wizard', { messageId, wizard }); + } finally { + context.activeConversations.delete(conversation.id); + } +} + export function assertReviewImportPermissions( context: AiChatServiceContext, user: AuthenticatedUser, @@ -205,34 +403,20 @@ export async function confirmReviewStep( reviewId: string, sectionKey: string, ): Promise> { - const review = await context.reviewService.findOwned(reviewId, user.id); - if (review.status === 'submitted') { - throw new ConflictException('导入已全部确认,无需重复确认'); - } - if (review.status === 'expired') { - throw new ConflictException('导入预览已失效,请重新生成预览'); - } + const review = await loadReviewForConfirm(context, user, reviewId); assertReviewImportPermissions(context, user, review, sectionKey); const { review: updated, message } = await context.reviewService.submitSection( review.id, user.id, sectionKey, ); - await context.opLog?.log({ - userId: user.id, - username: user.username, - module: '批量导入', - action: '确认导入分表', - detail: `「${review.title}」分表「${sectionKey}」:${message}`, - targetType: 'ai_review', - status: 'success', - }); - await context.markReviewSubmittedOnMessage( - updated.assistantMessageId, - updated.conversationId, - updated, + await logImportOp( + context, + user, + '确认导入分表', + `「${review.title}」分表「${sectionKey}」:${message}`, ); - return context.reviewService.serialize(updated); + return finalizeReview(context, updated); } export async function confirmReviewGroup( @@ -244,13 +428,7 @@ export async function confirmReviewGroup( if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') { throw new BadRequestException(`业务类型不支持: ${String(type)}`); } - const review = await context.reviewService.findOwned(reviewId, user.id); - if (review.status === 'submitted') { - throw new ConflictException('导入已全部确认,无需重复确认'); - } - if (review.status === 'expired') { - throw new ConflictException('导入预览已失效,请重新生成预览'); - } + const review = await loadReviewForConfirm(context, user, reviewId); assertReviewImportPermissions(context, user, review, undefined, type); const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type); const sectionTitles = context.reviewService @@ -258,21 +436,13 @@ export async function confirmReviewGroup( .filter((section) => section.type === type) .map((section) => section.title) .join('、'); - await context.opLog?.log({ - userId: user.id, - username: user.username, - module: '批量导入', - action: '确认导入分组', - detail: `「${review.title}」分组「${type}」:${sectionTitles}`, - targetType: 'ai_review', - status: 'success', - }); - await context.markReviewSubmittedOnMessage( - updated.assistantMessageId, - updated.conversationId, - updated, + await logImportOp( + context, + user, + '确认导入分组', + `「${review.title}」分组「${type}」:${sectionTitles}`, ); - return context.reviewService.serialize(updated); + return finalizeReview(context, updated); } export function a2uiSubmitInfo( 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 1fda1c1..6ac659d 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,54 +1,76 @@ import { AiReview } from './entities/ai-review.entity'; import { IMPORT_STEP_KEYS, - type ColumnMapping, - type ImportRunSettings, type ImportStageRequest, type ImportStepKey, type PreflightReport, } from '../imports/imports.types'; +import { permittedStepKeys } from '../imports/imports.access'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { finishToolRun, startToolRun } from './ai-chat.tools'; +import { + isExcelAttachment, + parseConfirmedMapping, + parseConfirmedSettings, +} from './ai-chat.import-confirm'; -function isExcelAttachment(attachment: { - mimeType: string; - originalName: string; -}): boolean { - return ( - attachment.mimeType.includes('spreadsheetml') || - attachment.mimeType.includes('excel') || - attachment.mimeType.includes('csv') || - /\.(xlsx|csv)$/i.test(attachment.originalName) - ); +function parseAttachmentArgs( + parsedArgs: unknown, +): { parsedRecord: Record; attachmentId: number } { + const parsedRecord = + parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) + ? (parsedArgs as Record) + : {}; + if ( + typeof parsedRecord.attachmentId !== 'number' || + !Number.isInteger(parsedRecord.attachmentId) || + parsedRecord.attachmentId <= 0 + ) { + throw new Error('缺少附件 attachmentId'); + } + return { parsedRecord, attachmentId: parsedRecord.attachmentId }; +} + +async function beginImportToolRun( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + emit: AiSseEmitter, + toolName: string, +) { + return startToolRun(context, messageId, call, emit, { + toolName, + skillKey: null, + argumentsData: null, + }); } export async function executePreflightImport( context: AiChatServiceContext, messageId: number, call: ModelToolCall, - userId: number, + agentContext: AgentToolContext, emit: AiSseEmitter, ): Promise { - const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { - toolName: 'preflight_import', - skillKey: null, - argumentsData: null, - }); + const { run, parsedArgs, startedAt } = await beginImportToolRun( + context, + messageId, + call, + emit, + 'preflight_import', + ); try { 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; - if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { - throw new Error('缺少附件 attachmentId'); + const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); + const headerRow = + parsedRecord.headerRow === undefined ? 1 : Number(parsedRecord.headerRow); + if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) { + throw new Error('headerRow 必须是 1-1000 之间的整数'); } - const [attachment] = await context.attachmentService.requireReadyOwned(userId, [ + const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [ attachmentId as number, ]); if (!isExcelAttachment(attachment)) { @@ -61,10 +83,23 @@ export async function executePreflightImport( mimeType: attachment.mimeType, size: attachment.size, buffer, + }, headerRow); + const permittedSteps = permittedStepKeys({ + id: agentContext.userId, + permissions: [...agentContext.permissions], + isSuperAdmin: agentContext.isSuperAdmin, }); + const preflightCard: PreflightReport = { + ...preflight, + attachmentId: attachment.id, + headerRow, + permittedSteps, + resolved: false, + runId: null, + }; assistant.metadata = { ...assistant.metadata, - a2uiImportPreflight: preflight, + a2uiImportPreflight: preflightCard, }; await context.messages.save(assistant); @@ -74,12 +109,8 @@ export async function executePreflightImport( .map((stage) => `${stage.label} ${stage.total} 行`) .join('、') || '未识别到可导入阶段'}`, }, emit); - emit('ui.import_preflight', { messageId, preflight }); - return JSON.stringify({ - status: 'success', - report: preflight, - message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard', - }); + emit('ui.import_preflight', { messageId, preflight: preflightCard }); + return preflightModelPayload(preflight, permittedSteps); } catch (error) { const summary = error instanceof Error ? error.message.slice(0, 100) : '导入预检失败'; @@ -99,24 +130,18 @@ export async function executeStartImportWizard( agentContext: AgentToolContext, emit: AiSseEmitter, ): Promise { - const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { - toolName: 'start_import_wizard', - skillKey: null, - argumentsData: null, - }); + const { run, parsedArgs, startedAt } = await beginImportToolRun( + context, + messageId, + call, + emit, + 'start_import_wizard', + ); try { 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; - if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { - throw new Error('缺少附件 attachmentId'); - } + const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [ attachmentId as number, ]); @@ -132,6 +157,12 @@ export async function executeStartImportWizard( if (!stage.sheet || !String(stage.sheet).trim()) { throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); } + if ( + stage.headerRow !== undefined && + (!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000) + ) { + throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`); + } } const mapping = parseConfirmedMapping(parsedRecord.mapping); const settings = parseConfirmedSettings(parsedRecord); @@ -156,8 +187,18 @@ export async function executeStartImportWizard( settings, ); const wizard = compactImportWizard(detail); + const preflightMeta = assistant.metadata?.a2uiImportPreflight; assistant.metadata = { ...assistant.metadata, + ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) + ? { + a2uiImportPreflight: { + ...(preflightMeta as Record), + resolved: true, + runId: detail.id, + }, + } + : {}), a2uiImportWizard: wizard, }; await context.messages.save(assistant); @@ -169,6 +210,16 @@ export async function executeStartImportWizard( .map((step) => step.label) .join('、')}`, }, emit); + if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { + emit('ui.import_preflight', { + messageId, + preflight: { + ...(preflightMeta as Record), + resolved: true, + runId: detail.id, + }, + }); + } emit('ui.import_wizard', { messageId, wizard }); return JSON.stringify({ status: 'success', @@ -176,6 +227,11 @@ export async function executeStartImportWizard( steps: detail.steps .filter((step) => step.status !== 'skipped') .map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })), + permittedSteps: permittedStepKeys({ + id: agentContext.userId, + permissions: [...agentContext.permissions], + isSuperAdmin: agentContext.isSuperAdmin, + }), message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', }); } catch (error) { @@ -185,57 +241,44 @@ export async function executeStartImportWizard( } } -function parseConfirmedMapping(raw: unknown): Partial> | undefined { - if (raw === undefined || raw === null) return undefined; - if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误'); - const mapping: Partial> = {}; - for (const [stepKey, fields] of Object.entries(raw as Record)) { - if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) { - throw new Error(`mapping 包含未知业务类型:${stepKey}`); - } - if (fields === undefined || fields === null) continue; - if (typeof fields !== 'object' || Array.isArray(fields)) { - throw new Error(`mapping 中「${stepKey}」的列映射格式错误`); - } - const columnMapping: ColumnMapping = {}; - for (const [field, header] of Object.entries(fields as Record)) { - if (typeof field !== 'string' || !field.trim() || field.length > 50) continue; - if (typeof header !== 'string' || !header.trim()) continue; - columnMapping[field] = header.slice(0, 200); - } - mapping[stepKey as ImportStepKey] = columnMapping; - } - return mapping; -} - -function parseConfirmedSettings(parsedRecord: Record): ImportRunSettings { - const settings: ImportRunSettings = {}; - if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) { - if (typeof parsedRecord.organization !== 'string') { - throw new Error('organization 必须是字符串'); - } - const organization = parsedRecord.organization.trim().slice(0, 100); - if (organization) settings.organization = organization; - } - if (parsedRecord.updateExisting !== undefined) { - if (typeof parsedRecord.updateExisting !== 'boolean') { - throw new Error('updateExisting 必须是布尔值'); - } - settings.updateExisting = parsedRecord.updateExisting; - } - if (parsedRecord.duplicatePolicy !== undefined) { - if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') { - throw new Error('duplicatePolicy 只能是 error 或 skip'); - } - settings.duplicatePolicy = parsedRecord.duplicatePolicy; - } - if (parsedRecord.skipUnmatched !== undefined) { - if (typeof parsedRecord.skipUnmatched !== 'boolean') { - throw new Error('skipUnmatched 必须是布尔值'); - } - settings.skipUnmatched = parsedRecord.skipUnmatched; - } - return settings; +function preflightModelPayload( + report: PreflightReport, + permittedSteps: ImportStepKey[], +): string { + const guidance = + '预检报告已以卡片展示:请引导用户在卡内确认列映射与策略并点击「生成导入向导」;' + + '仅当用户在聊天文本中显式给出确认时才调用 start_import_wizard'; + const fullPayload = JSON.stringify({ + status: 'success', + report, + permittedSteps, + message: guidance, + }); + if (fullPayload.length <= 32 * 1024) return fullPayload; + return JSON.stringify({ + status: 'success', + truncated: true, + report: { + verdict: report.verdict, + stages: report.stages.map((stage) => ({ + stepKey: stage.stepKey, + label: stage.label, + sheetNames: stage.sheetNames, + total: stage.total, + create: stage.create, + update: stage.update, + error: stage.error, + skip: stage.skip, + mapping: stage.mapping, + missingRequired: stage.missingRequired, + })), + questions: report.questions, + errorSamples: report.errorSamples.slice(0, 10), + nextSteps: report.nextSteps, + }, + permittedSteps, + message: guidance, + }); } export function compactImportWizard(detail: any): { diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index f49c8b0..65376c0 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -5,7 +5,6 @@ import { executePreflightImport, executeRenderChart, executeRenderForm, - executeRenderReview, executeStartImportWizard, } from './ai-chat.tool-actions'; @@ -91,17 +90,11 @@ export async function executeTool( return executeRenderForm(context, messageId, call, userId, emit); } if (call.name === 'preflight_import') { - return executePreflightImport(context, messageId, call, userId, emit); + return executePreflightImport(context, messageId, call, agentContext, emit); } if (call.name === 'start_import_wizard') { return executeStartImportWizard(context, messageId, call, agentContext, emit); } - if (call.name === 'render_review') { - if (reviewSubmitted) { - return denyTool(context, messageId, call, 'render_review', '导入已确认,无需再次生成预览', '导入已确认', emit); - } - return executeRenderReview(context, messageId, call, userId, emit); - } if (call.name === 'render_chart') { return executeRenderChart(context, messageId, call, emit); } diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index a970a23..67ef99d 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; -import JSZip from 'jszip'; +import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback'; export interface ExcelSheetInfo { name: string; @@ -100,121 +100,19 @@ export class AiExcelReaderService { } private async loadWithFallback(buffer: Buffer): Promise { - const zip = await JSZip.loadAsync(buffer); - const readEntry = async (name: string): Promise => { - const entry = zip.file(name); - return entry ? entry.async('string') : null; - }; - const workbookXml = await readEntry('xl/workbook.xml'); - if (!workbookXml) throw new Error('workbook.xml missing'); - const stripPrefixes = (value: string): string => - value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); - const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? ''); - const relTargets = new Map(); - for (const match of relsXml.matchAll( - /]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g, - )) { - const target = match[2].replace(/^\/+/, ''); - relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`); - } - - const sharedStrings = await this.parseSharedStringsFallback(readEntry); - const sheets: ExcelSheetRows[] = []; - const cleanWorkbook = stripPrefixes(workbookXml); - for (const match of cleanWorkbook.matchAll(/]*\/?>/g)) { - const tag = match[0].replace(/$/, '>'); - const name = tag.match(/\bname="([^"]+)"/)?.[1]; - const rid = tag.match(/\br:id="([^"]+)"/)?.[1]; - if (!name || !rid) continue; - const target = relTargets.get(rid); - const sheetXml = target ? await readEntry(target) : null; - if (!sheetXml) continue; - sheets.push({ - name: this.unescapeXml(name), - rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings), - }); - } - return sheets; - } - - private async parseSharedStringsFallback( - readEntry: (name: string) => Promise, - ): Promise { - const xml = await readEntry('xl/sharedStrings.xml'); - if (!xml) return []; - const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); - const strings: string[] = []; - for (const match of clean.matchAll(/]*>([\s\S]*?)<\/si>/gs)) { - const texts = [...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => - this.unescapeXml(part[1]), - ); - strings.push(texts.join('')); - } - return strings; - } - - private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] { - const rows: string[][] = []; - const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); - for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/gs)) { - const cells = new Map(); - let maxColumn = -1; - for (const cellMatch of rowMatch[1].matchAll(/]*)\/?>([\s\S]*?)<\/c>/gs)) { - const attrs = cellMatch[1]; - const refMatch = attrs.match(/\br="([A-Z]+)\d+"/); - const column = refMatch ? this.columnIndex(refMatch[1]) : -1; - const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n'; - const body = cellMatch[2] ?? ''; - let value = ''; - if (type === 's') { - const index = Number(body.match(/([^<]*)<\/v>/)?.[1] ?? ''); - value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : ''; - } else if (type === 'inlineStr') { - const texts = [...body.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => - this.unescapeXml(part[1]), - ); - value = texts.join(''); - } else { - value = this.unescapeXml(body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ''); - if (type === 'b') value = value === '1' ? 'true' : 'false'; - } - if (column >= 0) { - cells.set(column, value); - maxColumn = Math.max(maxColumn, column); - } - } - if (maxColumn < 0) continue; - const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? ''); - if (values.every((value) => value === '')) continue; - rows.push(values); - } - return rows; - } - - private columnIndex(letters: string): number { - let index = 0; - for (const char of letters.toUpperCase()) { - index = index * 26 + (char.charCodeAt(0) - 64); - } - return index - 1; - } - - private unescapeXml(value: string): string { - return value - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') - .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) => - String.fromCodePoint(Number.parseInt(hex, 16)), - ) - .replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec))); + const sheets = await readXlsxSheetsFallback(buffer); + return sheets.map((sheet) => ({ name: sheet.name, rows: sheet.rows })); } private stringifyCellValue(value: unknown): string { if (value === null || value === undefined) return ''; - if (value instanceof Date) return value.toISOString(); + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return ''; + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts index 6b16241..e4a5340 100644 --- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts @@ -110,6 +110,19 @@ export class SubmitReviewDto { reasoningEffort?: string | null; } +export class ResolveImportPreflightDto { + @IsUUID() + clientRequestId: string; + + @IsOptional() + @IsObject() + mapping?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + export class MessagePageQueryDto { @IsOptional() @Type(() => Number)