feat(ai-chat): AI 导入确认流程完善并清理代码质量

- 新增导入确认/映射解析辅助,支持预检后生成导入向导
- 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑
- 双重类型断言改为运行时守卫,消除 aislop 告警
This commit is contained in:
2026-08-06 11:59:28 +08:00
parent 026a9f35d8
commit 6a18fd264d
13 changed files with 1241 additions and 785 deletions

View File

@@ -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', () => {

View File

@@ -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',

View File

@@ -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仅字母数字下划线≤50type 为业务类型。',
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、transferDateYYYY-MM-DD入住记录 name/phone 或 studentNo、roomNumber、checkInDateYYYY-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业务类型 stepKeystudents 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名并把确认结果一并传入mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard必须传入 attachmentId 和 stages业务类型 stepKeystudents 学生 / 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。`;

View File

@@ -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<void> {
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,

View File

@@ -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,

View File

@@ -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<Record<ImportStepKey, string[]>>;
}
/**
* 解析并校验用户确认的列映射。
* 与 start_import_wizard 工具、预检卡 resolve 端点共用,保证两条入口口径一致。
*/
export function parseConfirmedMapping(
raw: unknown,
options: ConfirmedMappingOptions = {},
): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new BadRequestException('mapping 参数格式错误');
}
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
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<string, unknown>)) {
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<string, unknown>): 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<string, unknown>);
}
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)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executePreflightImport(this, messageId, call, userId, emit);
return executePreflightImport(this, messageId, call, context, emit);
}
executeGeneration(input: GenerationInput): Promise<void> {
@@ -407,6 +409,10 @@ export class AiChatService implements AiChatServiceContext {
return resolveReviewConversationId(this, userId, reviewId);
}
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
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<string, unknown>;
settings?: Record<string, unknown>;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
}
confirmReviewStep(
user: AuthenticatedUser,
reviewId: string,

View File

@@ -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<number> {
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<string, unknown> | 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<string, unknown>;
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<AiReview> {
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<Record<string, unknown>> {
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<void> {
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<string, unknown>;
settings?: Record<string, unknown>;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
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<Record<ImportStepKey, string[]>> = {};
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<Record<string, unknown>> {
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(

View File

@@ -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<string, unknown>; attachmentId: number } {
const parsedRecord =
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
? (parsedArgs as Record<string, unknown>)
: {};
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<string> {
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<string, unknown>)
: {};
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<string> {
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<string, unknown>)
: {};
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<string, unknown>),
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<string, unknown>),
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<Record<ImportStepKey, ColumnMapping>> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误');
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
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<string, unknown>)) {
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<string, unknown>): 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): {

View File

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

View File

@@ -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<ExcelSheetRows[]> {
const zip = await JSZip.loadAsync(buffer);
const readEntry = async (name: string): Promise<string | null> => {
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<string, string>();
for (const match of relsXml.matchAll(
/<Relationship[^>]*\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(/<sheet\b[^>]*\/?>/g)) {
const tag = match[0].replace(/<sheet\b/, '<sheet').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<string | null>,
): Promise<string[]> {
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(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
const texts = [...match[1].matchAll(/<t\b[^>]*>([\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(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
const cells = new Map<number, string>();
let maxColumn = -1;
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\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>([^<]*)<\/v>/)?.[1] ?? '');
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
} else if (type === 'inlineStr') {
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
this.unescapeXml(part[1]),
);
value = texts.join('');
} else {
value = this.unescapeXml(body.match(/<v>([\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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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);
}

View File

@@ -110,6 +110,19 @@ export class SubmitReviewDto {
reasoningEffort?: string | null;
}
export class ResolveImportPreflightDto {
@IsUUID()
clientRequestId: string;
@IsOptional()
@IsObject()
mapping?: Record<string, unknown>;
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}
export class MessagePageQueryDto {
@IsOptional()
@Type(() => Number)