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:
parent
8f7b633041
commit
e867ba5529
|
|
@ -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<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 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<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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有文件
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue