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;
|
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
|
* 将附件转换为 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 block(Claude 无法自行访问 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`;
|
||||||
|
|
||||||
|
try {
|
||||||
if (att.type === 'image') {
|
if (att.type === 'image') {
|
||||||
|
const { base64, mediaType } = await this.downloadAsBase64(contentUrl, att.mimeType);
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
source: { type: 'url', url: att.downloadUrl },
|
source: { type: 'base64', media_type: mediaType, data: base64 },
|
||||||
});
|
});
|
||||||
} else if (att.mimeType === 'application/pdf') {
|
} else if (att.mimeType === 'application/pdf') {
|
||||||
// Claude 原生支持 PDF 文档
|
const { base64 } = await this.downloadAsBase64(contentUrl, att.mimeType);
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
source: { type: 'url', url: att.downloadUrl },
|
source: { type: 'base64', media_type: 'application/pdf', data: base64 },
|
||||||
title: att.originalName,
|
title: att.originalName,
|
||||||
});
|
});
|
||||||
} else if (this.isTextBasedMime(att.mimeType)) {
|
} else if (this.isTextBasedMime(att.mimeType)) {
|
||||||
// 文本类文件:下载内容并直接嵌入
|
const textContent = await this.fetchTextContent(contentUrl);
|
||||||
try {
|
|
||||||
const textContent = await this.fetchTextContent(att.downloadUrl);
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `--- 附件: ${att.originalName} ---\n${textContent}`,
|
text: `--- 附件: ${att.originalName} ---\n${textContent}`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Office 文档等暂不支持
|
||||||
|
blocks.push({
|
||||||
|
type: 'text',
|
||||||
|
text: `[附件: ${att.originalName} (${att.mimeType}) — 该格式暂不支持AI解析,请转为PDF后重新上传]`,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch text content for ${att.originalName}: ${err}`);
|
this.logger.warn(`Failed to process attachment ${att.originalName}: ${err}`);
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `[附件: ${att.originalName} — 内容加载失败]`,
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户的所有文件
|
* 获取用户的所有文件
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 URL(Docker 内部 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue