fix(files): replace MinIO presigned URLs with API proxy + base64 for Claude

MinIO presigned URLs use Docker-internal hostname (minio:9000), making
them inaccessible from both Claude API servers and user browsers.

Changes:
- file-service: add /files/:id/content and /files/:id/thumbnail proxy
  endpoints that stream file data from MinIO
- file-service: toResponseDto now returns API proxy paths instead of
  MinIO presigned URLs
- coordinator: buildAttachmentBlocks now downloads files via file-service
  internal API (http://file-service:3006) and converts to base64 for
  Claude API (images, PDFs) or embeds text content directly
- Configurable FILE_SERVICE_URL env var for service-to-service calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 04:49:39 -08:00
parent 8f7b633041
commit e867ba5529
3 changed files with 138 additions and 35 deletions

View File

@ -330,57 +330,99 @@ export class CoordinatorAgentService implements OnModuleInit {
/** 文本类文件最大嵌入大小 (超过则截断) */ /** 文本类文件最大嵌入大小 (超过则截断) */
private readonly MAX_TEXT_EMBED_SIZE = 50_000; private readonly MAX_TEXT_EMBED_SIZE = 50_000;
/** 二进制文件最大 base64 大小 (20MBClaude 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<string>('FILE_SERVICE_URL') || 'http://file-service:3006';
}
/** /**
* Claude API content blocks * Claude API content blocks
* - image { type: 'image', source: { type: 'url', url } } *
* - PDF { type: 'document', source: { type: 'url', url }, title } * file-service API base64 Claude
* - text/csv/json/md text blockClaude 访 URL * 使 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<any[]> { private async buildAttachmentBlocks(attachments: FileAttachment[]): Promise<any[]> {
const blocks: any[] = []; const blocks: any[] = [];
for (const att of attachments) { 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') { try {
blocks.push({ if (att.type === 'image') {
type: 'image', const { base64, mediaType } = await this.downloadAsBase64(contentUrl, att.mimeType);
source: { type: 'url', url: att.downloadUrl }, blocks.push({
}); type: 'image',
} else if (att.mimeType === 'application/pdf') { source: { type: 'base64', media_type: mediaType, data: base64 },
// Claude 原生支持 PDF 文档 });
blocks.push({ } else if (att.mimeType === 'application/pdf') {
type: 'document', const { base64 } = await this.downloadAsBase64(contentUrl, att.mimeType);
source: { type: 'url', url: att.downloadUrl }, blocks.push({
title: att.originalName, type: 'document',
}); source: { type: 'base64', media_type: 'application/pdf', data: base64 },
} else if (this.isTextBasedMime(att.mimeType)) { title: att.originalName,
// 文本类文件:下载内容并直接嵌入 });
try { } else if (this.isTextBasedMime(att.mimeType)) {
const textContent = await this.fetchTextContent(att.downloadUrl); const textContent = await this.fetchTextContent(contentUrl);
blocks.push({ blocks.push({
type: 'text', type: 'text',
text: `--- 附件: ${att.originalName} ---\n${textContent}`, text: `--- 附件: ${att.originalName} ---\n${textContent}`,
}); });
} catch (err) { } else {
this.logger.warn(`Failed to fetch text content for ${att.originalName}: ${err}`); // Office 文档等暂不支持
blocks.push({ blocks.push({
type: 'text', 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; 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<string> { private async fetchTextContent(url: string): Promise<string> {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status} from ${url}`);
} }
const text = await response.text(); const text = await response.text();
if (text.length > this.MAX_TEXT_EMBED_SIZE) { if (text.length > this.MAX_TEXT_EMBED_SIZE) {

View File

@ -6,6 +6,7 @@ import {
Param, Param,
Body, Body,
Query, Query,
Res,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
Headers, Headers,
@ -13,6 +14,7 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { FileService } from '../../application/services/file.service'; import { FileService } from '../../application/services/file.service';
import { import {
@ -89,6 +91,36 @@ export class FileController {
return { url }; return { url };
} }
/**
*
* 访 MinIO URL
*/
@Get(':id/content')
async getFileContent(
@Param('id', ParseUUIDPipe) fileId: string,
@Res() res: Response,
): Promise<void> {
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<void> {
const { buffer } = await this.fileService.getThumbnailContent(fileId);
res.setHeader('Content-Type', 'image/webp');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(buffer);
}
/** /**
* *
*/ */

View File

@ -243,6 +243,37 @@ export class FileService {
return this.toResponseDto(file); 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 * URL
*/ */
@ -273,7 +304,7 @@ export class FileService {
conversationId, 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 * DTO
*
* downloadUrl / thumbnailUrl 使 API /api/v1/files/:id/content
* MinIO presigned URLDocker hostname
*/ */
private async toResponseDto(file: FileEntity): Promise<FileResponseDto> { private toResponseDto(file: FileEntity): FileResponseDto {
const dto: FileResponseDto = { const dto: FileResponseDto = {
id: file.id, id: file.id,
originalName: file.originalName, originalName: file.originalName,
@ -361,16 +395,11 @@ export class FileService {
}; };
if (file.isReady()) { if (file.isReady()) {
dto.downloadUrl = await this.storageAdapter.getPresignedUrl( // 使用 API 代理路径,浏览器通过 nginx → Kong → file-service 访问
file.storagePath, dto.downloadUrl = `/api/v1/files/${file.id}/content`;
3600,
);
if (file.thumbnailPath) { if (file.thumbnailPath) {
dto.thumbnailUrl = await this.storageAdapter.getPresignedUrl( dto.thumbnailUrl = `/api/v1/files/${file.id}/thumbnail`;
file.thumbnailPath,
3600,
);
} }
} }