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;
/** 二进制文件最大 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
* - image { type: 'image', source: { type: 'url', url } }
* - PDF { type: 'document', source: { type: 'url', url }, title }
* - text/csv/json/md text blockClaude 访 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<any[]> {
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<string> {
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) {

View File

@ -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<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);
}
/**
* 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 URLDocker hostname
*/
private async toResponseDto(file: FileEntity): Promise<FileResponseDto> {
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`;
}
}