- 新增导入确认/映射解析辅助,支持预检后生成导入向导 - 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑 - 双重类型断言改为运行时守卫,消除 aislop 告警
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { createReadStream } from 'node:fs';
|
||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { PDFParse } from 'pdf-parse';
|
||
import { In, Repository } from 'typeorm';
|
||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||
import { AiAttachment } from './entities';
|
||
|
||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||
const MAX_MODEL_IMAGE_BYTES = 20 * 1024 * 1024;
|
||
const ACCEPTED_MIME_TYPES = new Set([
|
||
'image/jpeg',
|
||
'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',
|
||
]);
|
||
|
||
export interface AiAttachmentModelPart {
|
||
attachment: AiAttachment;
|
||
text?: string;
|
||
imageDataUrl?: string;
|
||
}
|
||
|
||
@Injectable()
|
||
export class AiAttachmentService {
|
||
private readonly storageRoot =
|
||
resolve(process.env.AI_ATTACHMENT_DIR || join(process.cwd(), 'data', 'ai-attachments'));
|
||
|
||
constructor(
|
||
@InjectRepository(AiAttachment)
|
||
private readonly attachments: Repository<AiAttachment>,
|
||
private readonly excelReader: AiExcelReaderService,
|
||
) {}
|
||
|
||
async upload(userId: number, file: Express.Multer.File): Promise<AiAttachment> {
|
||
if (!file?.buffer?.length) throw new BadRequestException('请选择附件');
|
||
if (file.size > MAX_FILE_BYTES) throw new BadRequestException('单个附件不能超过 10MB');
|
||
|
||
const mimeType = this.detectMimeType(file.buffer, file.mimetype);
|
||
if (!ACCEPTED_MIME_TYPES.has(mimeType)) {
|
||
throw new BadRequestException('仅支持图片、PDF、Word 和 Excel 文件');
|
||
}
|
||
this.assertDeclaredType(file.mimetype, mimeType);
|
||
this.assertFileExtension(file.originalname, mimeType);
|
||
|
||
await mkdir(this.storageRoot, { recursive: true });
|
||
const extension = this.extensionForMime(mimeType);
|
||
const storageKey = `${userId}/${randomUUID()}.${extension}`;
|
||
const absolutePath = this.resolveStoragePath(storageKey);
|
||
await mkdir(join(this.storageRoot, String(userId)), { recursive: true });
|
||
await writeFile(absolutePath, file.buffer, { flag: 'wx' });
|
||
|
||
let entity: AiAttachment;
|
||
try {
|
||
entity = await this.attachments.save(
|
||
this.attachments.create({
|
||
userId,
|
||
originalName: this.decodeFilename(basename(file.originalname)).slice(0, 255),
|
||
mimeType,
|
||
size: file.size,
|
||
storageKey,
|
||
processingStatus: 'processing',
|
||
extractedText: null,
|
||
processingError: null,
|
||
imageWidth: null,
|
||
imageHeight: null,
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
await unlink(absolutePath).catch(() => undefined);
|
||
throw error;
|
||
}
|
||
|
||
try {
|
||
entity.extractedText = await this.extractText(file.buffer, mimeType);
|
||
entity.processingStatus = 'ready';
|
||
} catch {
|
||
entity.processingStatus = 'failed';
|
||
entity.processingError = '文件内容解析失败';
|
||
}
|
||
entity = await this.attachments.save(entity);
|
||
return entity;
|
||
}
|
||
|
||
async removeUnbound(userId: number, id: number): Promise<void> {
|
||
const attachment = await this.requireOwned(userId, id, true);
|
||
if (attachment.messages?.length) throw new BadRequestException('已发送的附件不能单独删除');
|
||
await this.attachments.remove(attachment);
|
||
await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);
|
||
}
|
||
|
||
async removeOrphans(userId: number, ids: number[]): Promise<void> {
|
||
const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
|
||
if (!uniqueIds.length) return;
|
||
const attachments = await this.attachments.find({
|
||
where: { id: In(uniqueIds), userId },
|
||
relations: { messages: true },
|
||
});
|
||
for (const attachment of attachments) {
|
||
if (attachment.messages?.length) continue;
|
||
await this.attachments.remove(attachment);
|
||
await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
async open(userId: number, id: number): Promise<{
|
||
attachment: AiAttachment;
|
||
stream: ReturnType<typeof createReadStream>;
|
||
}> {
|
||
const attachment = await this.requireOwned(userId, id);
|
||
return {
|
||
attachment,
|
||
stream: createReadStream(this.resolveStoragePath(attachment.storageKey)),
|
||
};
|
||
}
|
||
|
||
async requireReadyOwned(userId: number, ids: number[]): Promise<AiAttachment[]> {
|
||
const uniqueIds = [...new Set(ids)];
|
||
if (uniqueIds.length > 5) throw new BadRequestException('每条消息最多添加 5 个附件');
|
||
if (!uniqueIds.length) return [];
|
||
const attachments = await this.attachments.findByIds(uniqueIds);
|
||
if (attachments.length !== uniqueIds.length || attachments.some((item) => item.userId !== userId)) {
|
||
throw new BadRequestException('附件不存在或无权访问');
|
||
}
|
||
if (attachments.some((item) => item.processingStatus !== 'ready')) {
|
||
throw new BadRequestException('附件仍在处理或处理失败');
|
||
}
|
||
return uniqueIds.map((id) => attachments.find((item) => item.id === id)!);
|
||
}
|
||
|
||
async toModelParts(
|
||
attachments: AiAttachment[],
|
||
supportsVision: boolean,
|
||
): Promise<AiAttachmentModelPart[]> {
|
||
const imageAttachments = attachments.filter((attachment) => attachment.mimeType.startsWith('image/'));
|
||
if (imageAttachments.length && !supportsVision) {
|
||
throw new BadRequestException('当前模型未启用图片理解能力');
|
||
}
|
||
const imageBytes = imageAttachments.reduce((total, attachment) => total + attachment.size, 0);
|
||
if (imageBytes > MAX_MODEL_IMAGE_BYTES) {
|
||
throw new BadRequestException('单次消息图片总大小不能超过 20MB');
|
||
}
|
||
const parts: AiAttachmentModelPart[] = [];
|
||
for (const attachment of attachments) {
|
||
if (attachment.mimeType.startsWith('image/')) {
|
||
const buffer = await readFile(this.resolveStoragePath(attachment.storageKey));
|
||
parts.push({
|
||
attachment,
|
||
imageDataUrl: `data:${attachment.mimeType};base64,${buffer.toString('base64')}`,
|
||
});
|
||
} else {
|
||
parts.push({
|
||
attachment,
|
||
text: attachment.extractedText || '',
|
||
});
|
||
}
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
serialize(attachment: AiAttachment): Record<string, unknown> {
|
||
return {
|
||
id: attachment.id,
|
||
name: attachment.originalName,
|
||
mimeType: attachment.mimeType,
|
||
size: attachment.size,
|
||
status: attachment.processingStatus,
|
||
error: attachment.processingError,
|
||
url: `/api/ai/chat/attachments/${attachment.id}`,
|
||
createdAt: attachment.createdAt,
|
||
};
|
||
}
|
||
|
||
private async requireOwned(
|
||
userId: number,
|
||
id: number,
|
||
includeMessages = false,
|
||
): Promise<AiAttachment> {
|
||
const attachment = await this.attachments.findOne({
|
||
where: { id, userId },
|
||
...(includeMessages ? { relations: { messages: true } } : {}),
|
||
});
|
||
if (!attachment) throw new NotFoundException('附件不存在');
|
||
return attachment;
|
||
}
|
||
|
||
private async extractText(buffer: Buffer, mimeType: string): Promise<string | null> {
|
||
if (mimeType.startsWith('image/')) return null;
|
||
if (mimeType === 'application/pdf') {
|
||
const parser = new PDFParse({ data: buffer });
|
||
try {
|
||
const result = await parser.getText();
|
||
return this.normalizeExtractedText(result.text);
|
||
} finally {
|
||
await parser.destroy();
|
||
}
|
||
}
|
||
if (mimeType.includes('wordprocessingml')) {
|
||
const mammoth = await import('mammoth');
|
||
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));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Read the stored file content of an already-owned attachment so the AI
|
||
* chat agent can page through large workbooks on demand.
|
||
*/
|
||
async readStoredBuffer(attachment: AiAttachment): Promise<Buffer> {
|
||
return readFile(this.resolveStoragePath(attachment.storageKey));
|
||
}
|
||
|
||
/** Resolved absolute path of a stored attachment (for OfficeCli). */
|
||
storagePathFor(attachment: AiAttachment): string {
|
||
return this.resolveStoragePath(attachment.storageKey);
|
||
}
|
||
|
||
private normalizeExtractedText(value: string): string {
|
||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim();
|
||
}
|
||
|
||
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('附件类型与文件内容不一致');
|
||
}
|
||
|
||
private assertFileExtension(filename: string, mimeType: string): void {
|
||
const extension = basename(filename).toLowerCase().split('.').pop();
|
||
const expected: Record<string, string[]> = {
|
||
'image/jpeg': ['jpg', 'jpeg'],
|
||
'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'],
|
||
};
|
||
if (!extension || !expected[mimeType]?.includes(extension)) {
|
||
throw new BadRequestException('附件扩展名与文件内容不一致');
|
||
}
|
||
}
|
||
|
||
private detectMimeType(buffer: Buffer, declaredMimeType: string): string {
|
||
if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return 'image/jpeg';
|
||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||
return 'image/png';
|
||
}
|
||
if (
|
||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||
) {
|
||
return 'image/webp';
|
||
}
|
||
if (buffer.subarray(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
|
||
const isZip =
|
||
buffer.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])) ||
|
||
buffer.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x05, 0x06])) ||
|
||
buffer.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x07, 0x08]));
|
||
if (
|
||
isZip &&
|
||
(declaredMimeType.includes('wordprocessingml') ||
|
||
declaredMimeType.includes('spreadsheetml') ||
|
||
declaredMimeType.includes('presentationml'))
|
||
) {
|
||
return declaredMimeType;
|
||
}
|
||
if (/csv/i.test(declaredMimeType)) return 'text/csv';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* Browsers send UTF-8 filenames in the multipart header, which multer
|
||
* decodes as Latin-1 — the stored name then looks like mojibake
|
||
* (e.g. `26ææ...`). Re-decode when the bytes are valid UTF-8 and
|
||
* contain CJK; otherwise keep the original name untouched.
|
||
*/
|
||
private decodeFilename(name: string): string {
|
||
if (!/[\u00c0-\u00ff]/.test(name)) return name;
|
||
try {
|
||
const decoded = Buffer.from(name, 'latin1').toString('utf8');
|
||
if (decoded.includes('\uFFFD')) return name;
|
||
if (!/[\u4e00-\u9fff]/.test(decoded)) return name;
|
||
return decoded;
|
||
} catch {
|
||
return name;
|
||
}
|
||
}
|
||
|
||
private extensionForMime(mimeType: string): string {
|
||
const extensions: Record<string, string> = {
|
||
'image/jpeg': 'jpg',
|
||
'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',
|
||
};
|
||
return extensions[mimeType] || 'bin';
|
||
}
|
||
|
||
private resolveStoragePath(storageKey: string): string {
|
||
const safeKey = storageKey.replace(/[^a-zA-Z0-9/_.-]/g, '');
|
||
if (safeKey !== storageKey) throw new BadRequestException('附件路径无效');
|
||
const absolutePath = resolve(this.storageRoot, safeKey);
|
||
const relativePath = relative(this.storageRoot, absolutePath);
|
||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||
throw new BadRequestException('附件路径无效');
|
||
}
|
||
return absolutePath;
|
||
}
|
||
}
|