diff --git a/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts b/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts index 6a37a09..507ff16 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts @@ -330,57 +330,99 @@ export class CoordinatorAgentService implements OnModuleInit { /** 文本类文件最大嵌入大小 (超过则截断) */ private readonly MAX_TEXT_EMBED_SIZE = 50_000; + /** 二进制文件最大 base64 大小 (20MB,Claude API 限制) */ + private readonly MAX_BINARY_SIZE = 20 * 1024 * 1024; + + /** + * 获取 file-service 内部地址 + * conversation-service 与 file-service 同在 Docker 网络内, + * 通过 service name + port 直连。 + */ + private get fileServiceBaseUrl(): string { + return this.configService.get('FILE_SERVICE_URL') || 'http://file-service:3006'; + } /** * 将附件转换为 Claude API content blocks - * - image → { type: 'image', source: { type: 'url', url } } - * - PDF → { type: 'document', source: { type: 'url', url }, title } - * - text/csv/json/md → 下载内容嵌入为 text block(Claude 无法自行访问 URL) + * + * 核心策略:通过 file-service 内部 API 下载文件后转 base64 发给 Claude。 + * 使用 att.id 构建内部 URL(/files/:id/content),绕过 MinIO presigned URL + * 的 Docker 内部主机名问题(minio:9000 在外网不可达)。 + * + * - image → 下载 → base64 image block + * - PDF → 下载 → base64 document block + * - text/csv/json/md → 下载 → 嵌入文本 text block + * - Office (docx/xlsx) → 暂不支持,提示用户 */ private async buildAttachmentBlocks(attachments: FileAttachment[]): Promise { const blocks: any[] = []; for (const att of attachments) { - if (!att.downloadUrl) continue; + // 通过 file-service 内部 API 下载,不依赖 downloadUrl + // file-service 使用 globalPrefix 'api/v1',完整路径: /api/v1/files/:id/content + const contentUrl = `${this.fileServiceBaseUrl}/api/v1/files/${att.id}/content`; - if (att.type === 'image') { - blocks.push({ - type: 'image', - source: { type: 'url', url: att.downloadUrl }, - }); - } else if (att.mimeType === 'application/pdf') { - // Claude 原生支持 PDF 文档 - blocks.push({ - type: 'document', - source: { type: 'url', url: att.downloadUrl }, - title: att.originalName, - }); - } else if (this.isTextBasedMime(att.mimeType)) { - // 文本类文件:下载内容并直接嵌入 - try { - const textContent = await this.fetchTextContent(att.downloadUrl); + try { + if (att.type === 'image') { + const { base64, mediaType } = await this.downloadAsBase64(contentUrl, att.mimeType); + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: mediaType, data: base64 }, + }); + } else if (att.mimeType === 'application/pdf') { + const { base64 } = await this.downloadAsBase64(contentUrl, att.mimeType); + blocks.push({ + type: 'document', + source: { type: 'base64', media_type: 'application/pdf', data: base64 }, + title: att.originalName, + }); + } else if (this.isTextBasedMime(att.mimeType)) { + const textContent = await this.fetchTextContent(contentUrl); blocks.push({ type: 'text', text: `--- 附件: ${att.originalName} ---\n${textContent}`, }); - } catch (err) { - this.logger.warn(`Failed to fetch text content for ${att.originalName}: ${err}`); + } else { + // Office 文档等暂不支持 blocks.push({ type: 'text', - text: `[附件: ${att.originalName} — 内容加载失败]`, + text: `[附件: ${att.originalName} (${att.mimeType}) — 该格式暂不支持AI解析,请转为PDF后重新上传]`, }); } + } catch (err) { + this.logger.warn(`Failed to process attachment ${att.originalName}: ${err}`); + blocks.push({ + type: 'text', + text: `[附件: ${att.originalName} — 内容加载失败]`, + }); } } return blocks; } - /** 通过预签名 URL 获取文本文件内容 */ + /** + * 从内部 URL 下载文件并转为 base64 + * conversation-service 与 file-service 同在 Docker 网络内 + */ + private async downloadAsBase64(url: string, mimeType: string): Promise<{ base64: string; mediaType: string }> { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${url}`); + } + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > this.MAX_BINARY_SIZE) { + throw new Error(`文件过大 (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(1)}MB),超过 ${this.MAX_BINARY_SIZE / 1024 / 1024}MB 限制`); + } + const base64 = Buffer.from(arrayBuffer).toString('base64'); + return { base64, mediaType: mimeType }; + } + + /** 从内部 URL 获取文本文件内容 */ private async fetchTextContent(url: string): Promise { const response = await fetch(url); if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status} from ${url}`); } const text = await response.text(); if (text.length > this.MAX_TEXT_EMBED_SIZE) { diff --git a/packages/services/file-service/src/adapters/inbound/file.controller.ts b/packages/services/file-service/src/adapters/inbound/file.controller.ts index 3a2459d..d3279c0 100644 --- a/packages/services/file-service/src/adapters/inbound/file.controller.ts +++ b/packages/services/file-service/src/adapters/inbound/file.controller.ts @@ -6,6 +6,7 @@ import { Param, Body, Query, + Res, UploadedFile, UseInterceptors, Headers, @@ -13,6 +14,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import type { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileService } from '../../application/services/file.service'; import { @@ -89,6 +91,36 @@ export class FileController { return { url }; } + /** + * 直接获取文件内容(流式代理) + * 前端通过此端点访问文件,避免暴露 MinIO 内部 URL + */ + @Get(':id/content') + async getFileContent( + @Param('id', ParseUUIDPipe) fileId: string, + @Res() res: Response, + ): Promise { + const { buffer, mimeType, originalName } = await this.fileService.getFileContent(fileId); + res.setHeader('Content-Type', mimeType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(originalName)}"`); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(buffer); + } + + /** + * 获取缩略图内容 + */ + @Get(':id/thumbnail') + async getThumbnail( + @Param('id', ParseUUIDPipe) fileId: string, + @Res() res: Response, + ): Promise { + const { buffer } = await this.fileService.getThumbnailContent(fileId); + res.setHeader('Content-Type', 'image/webp'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.send(buffer); + } + /** * 获取用户的所有文件 */ diff --git a/packages/services/file-service/src/application/services/file.service.ts b/packages/services/file-service/src/application/services/file.service.ts index 6548dc5..9092b05 100644 --- a/packages/services/file-service/src/application/services/file.service.ts +++ b/packages/services/file-service/src/application/services/file.service.ts @@ -243,6 +243,37 @@ export class FileService { return this.toResponseDto(file); } + /** + * 直接获取文件内容(流式代理,不依赖预签名 URL) + * 无需 userId 验证 — 通过 fileId (UUID) 本身作为不可猜测的访问令牌 + */ + async getFileContent(fileId: string): Promise<{ buffer: Buffer; mimeType: string; originalName: string }> { + const file = await this.fileRepo.findById(fileId); + if (!file || !file.isReady()) { + throw new NotFoundException('File not found'); + } + + const buffer = await this.storageAdapter.downloadFile(file.storagePath); + return { + buffer, + mimeType: file.mimeType, + originalName: file.originalName, + }; + } + + /** + * 获取缩略图内容 + */ + async getThumbnailContent(fileId: string): Promise<{ buffer: Buffer }> { + const file = await this.fileRepo.findById(fileId); + if (!file || !file.isReady() || !file.thumbnailPath) { + throw new NotFoundException('Thumbnail not found'); + } + + const buffer = await this.storageAdapter.downloadFile(file.thumbnailPath); + return { buffer }; + } + /** * 获取文件下载 URL */ @@ -273,7 +304,7 @@ export class FileService { conversationId, ); - return Promise.all(files.map((f) => this.toResponseDto(f))); + return files.map((f) => this.toResponseDto(f)); } /** @@ -348,8 +379,11 @@ export class FileService { /** * 转换为响应 DTO + * + * downloadUrl / thumbnailUrl 使用 API 代理路径 /api/v1/files/:id/content + * 而非 MinIO presigned URL(Docker 内部 hostname,外网不可达) */ - private async toResponseDto(file: FileEntity): Promise { + private toResponseDto(file: FileEntity): FileResponseDto { const dto: FileResponseDto = { id: file.id, originalName: file.originalName, @@ -361,16 +395,11 @@ export class FileService { }; if (file.isReady()) { - dto.downloadUrl = await this.storageAdapter.getPresignedUrl( - file.storagePath, - 3600, - ); + // 使用 API 代理路径,浏览器通过 nginx → Kong → file-service 访问 + dto.downloadUrl = `/api/v1/files/${file.id}/content`; if (file.thumbnailPath) { - dto.thumbnailUrl = await this.storageAdapter.getPresignedUrl( - file.thumbnailPath, - 3600, - ); + dto.thumbnailUrl = `/api/v1/files/${file.id}/thumbnail`; } }