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, private readonly excelReader: AiExcelReaderService, ) {} async upload(userId: number, file: Express.Multer.File): Promise { 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 { 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 { 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; }> { const attachment = await this.requireOwned(userId, id); return { attachment, stream: createReadStream(this.resolveStoragePath(attachment.storageKey)), }; } async requireReadyOwned(userId: number, ids: number[]): Promise { 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 { 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 { 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 { 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 { 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 { 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 = { '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 = { '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; } }