Files
gongxue-base/apps/server/src/ai-chat/ai-attachment.service.ts
wangziqi 6a18fd264d feat(ai-chat): AI 导入确认流程完善并清理代码质量
- 新增导入确认/映射解析辅助,支持预检后生成导入向导
- 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑
- 双重类型断言改为运行时守卫,消除 aislop 告警
2026-08-06 11:59:28 +08:00

337 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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