import { GenericCrudService } from '../../utils/generic-crud.service'; import { DataSource } from 'typeorm'; import { File } from './file.entity'; import { MinioService } from './minio.service'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../utils/logger'; export class FileService extends GenericCrudService { private readonly minioService: MinioService; constructor(dataSource: DataSource) { super(dataSource, File); this.minioService = new MinioService(); } /** * 创建文件记录并生成预签名上传URL */ async createFile(data: Partial) { try { // 生成唯一文件存储路径 const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`; // 生成MinIO上传策略 const uploadPolicy = await this.minioService.generateUploadPolicy(fileKey); // 准备文件记录数据 const fileData = { ...data, path: fileKey, uploadTime: new Date(), createdAt: new Date(), updatedAt: new Date() }; // 保存文件记录到数据库 const savedFile = await this.create(fileData as File); // 返回文件记录和上传策略 return { file: savedFile, uploadPolicy }; } catch (error) { logger.error('Failed to create file:', error); throw new Error('文件创建失败'); } } /** * 删除文件记录及对应的MinIO文件 */ async deleteFile(id: number) { // 获取文件记录 const file = await this.getById(id); if (!file) { throw new Error('文件不存在'); } try { // 验证文件是否存在于MinIO const fileExists = await this.minioService.objectExists(this.minioService.bucketName, file.path); if (!fileExists) { logger.error(`File not found in MinIO: ${this.minioService.bucketName}/${file.path}`); // 仍然继续删除数据库记录,但记录警告日志 } else { // 从MinIO删除文件 await this.minioService.deleteObject(this.minioService.bucketName, file.path); } // 从数据库删除记录 await this.delete(id); return true; } catch (error) { logger.error('Failed to delete file:', error); throw new Error('文件删除失败'); } } /** * 获取文件访问URL */ async getFileUrl(id: number) { const file = await this.getById(id); if (!file) { throw new Error('文件不存在'); } return this.minioService.getPresignedFileUrl(this.minioService.bucketName, file.path); } /** * 获取文件下载URL(带Content-Disposition头) */ async getFileDownloadUrl(id: number) { const file = await this.getById(id); if (!file) { throw new Error('文件不存在'); } const url = await this.minioService.getPresignedFileDownloadUrl( this.minioService.bucketName, file.path, file.name ); return { url, filename: file.name }; } /** * 创建多部分上传策略 */ async createMultipartUploadPolicy(data: Partial, partCount: number) { try { // 生成唯一文件存储路径 const fileKey = `${data.uploadUserId}/${uuidv4()}-${data.name}`; // 初始化多部分上传 const uploadId = await this.minioService.createMultipartUpload( this.minioService.bucketName, fileKey ); // 生成各部分上传URL const uploadUrls = await this.minioService.generateMultipartUploadUrls( this.minioService.bucketName, fileKey, uploadId, partCount ); // 准备文件记录数据 const fileData = { ...data, path: fileKey, uploadTime: new Date(), createdAt: new Date(), updatedAt: new Date() }; // 保存文件记录到数据库 const savedFile = await this.create(fileData as File); // 返回文件记录和上传策略 return { file: savedFile, uploadId, uploadUrls, bucket: this.minioService.bucketName, key: fileKey }; } catch (error) { logger.error('Failed to create multipart upload policy:', error); throw new Error('创建多部分上传策略失败'); } } /** * 完成分片上传 */ async completeMultipartUpload(data: { uploadId: string; bucket: string; key: string; parts: Array<{ partNumber: number; etag: string }>; }) { logger.db('Starting multipart upload completion:', { uploadId: data.uploadId, bucket: data.bucket, key: data.key, partsCount: data.parts.length }); // 查找文件记录 const file = await this.repository.findOneBy({ path: data.key }); if (!file) { throw new Error('文件记录不存在'); } try { // 完成MinIO分片上传 - 注意格式转换 const result = await this.minioService.completeMultipartUpload( data.bucket, data.key, data.uploadId, data.parts.map(part => ({ PartNumber: part.partNumber, ETag: part.etag })) ); // 更新文件大小等信息 file.size = result.size; file.updatedAt = new Date(); await this.repository.save(file); // 生成文件访问URL const url = this.minioService.getFileUrl(data.bucket, data.key); logger.db('Multipart upload completed successfully:', { fileId: file.id, size: result.size, key: data.key }); return { fileId: file.id, url, key: data.key, size: result.size }; } catch (error) { logger.error('Failed to complete multipart upload:', error); throw new Error('完成分片上传失败'); } } /** * 保存文件记录并将文件内容直接上传到MinIO * @param fileData - 文件基础信息 * @param fileContent - 文件内容(Buffer) * @param contentType - 文件MIME类型 * @returns 保存的文件记录和文件访问URL */ async saveFile( fileData: { name: string; size: number; mimeType: string; uploadUserId: number; [key: string]: any; }, fileContent: Buffer, contentType?: string ) { try { logger.db('Starting saveFile process:', { filename: fileData.name, size: fileData.size, mimeType: fileData.mimeType, uploadUserId: fileData.uploadUserId }); // 生成唯一文件存储路径 const fileKey = `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`; // 确保存储桶存在 await this.minioService.ensureBucketExists(); // 直接上传文件内容到MinIO const fileUrl = await this.minioService.createObject( this.minioService.bucketName, fileKey, fileContent, contentType || fileData.mimeType ); // 准备文件记录数据 const completeFileData = { ...fileData, path: fileKey, uploadTime: new Date(), }; // 保存文件记录到数据库 const savedFile = await this.create(completeFileData as any); logger.db('File saved successfully:', { fileId: savedFile.id, filename: savedFile.name, size: savedFile.size, url: fileUrl }); return { file: savedFile, url: fileUrl }; } catch (error) { logger.error('Failed to save file:', error); throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`); } } /** * 保存文件记录并将文件内容直接上传到MinIO(支持自定义存储路径) * @param fileData - 文件基础信息 * @param fileContent - 文件内容(Buffer) * @param customPath - 自定义存储路径(可选) * @param contentType - 文件MIME类型 * @returns 保存的文件记录和文件访问URL */ async saveFileWithCustomPath( fileData: { name: string; size: number; mimeType: string; uploadUserId: number; [key: string]: any; }, fileContent: Buffer, customPath?: string, contentType?: string ) { try { logger.db('Starting saveFileWithCustomPath process:', { filename: fileData.name, size: fileData.size, mimeType: fileData.mimeType, uploadUserId: fileData.uploadUserId, customPath: customPath || 'auto-generated' }); // 使用自定义路径或生成唯一文件存储路径 const fileKey = customPath || `${fileData.uploadUserId}/${uuidv4()}-${fileData.name}`; // 确保存储桶存在 await this.minioService.ensureBucketExists(); // 直接上传文件内容到MinIO const fileUrl = await this.minioService.createObject( this.minioService.bucketName, fileKey, fileContent, contentType || fileData.mimeType ); // 准备文件记录数据 const completeFileData = { ...fileData, path: fileKey, uploadTime: new Date(), // createdAt: new Date(), // updatedAt: new Date() }; // 保存文件记录到数据库 const savedFile = await this.create(completeFileData as any); logger.db('File saved with custom path successfully:', { fileId: savedFile.id, filename: savedFile.name, size: savedFile.size, path: fileKey, url: fileUrl }); return { file: savedFile, url: fileUrl }; } catch (error) { logger.error('Failed to save file with custom path:', error); throw new Error(`文件保存失败: ${error instanceof Error ? error.message : '未知错误'}`); } } /** * 从URL下载文件并保存到MinIO * @param url - 文件URL * @param fileData - 文件基础信息(不含name和size,将自动获取) * @param options - 可选配置 * @returns 保存的文件记录和文件访问URL */ async downloadAndSaveFromUrl( url: string, fileData: { uploadUserId: number; mimeType?: string; customFileName?: string; customPath?: string; [key: string]: any; }, options?: { timeout?: number; retries?: number; } ) { try { const axios = require('axios'); logger.db('Starting downloadAndSaveFromUrl process:', { url, uploadUserId: fileData.uploadUserId, customFileName: fileData.customFileName, customPath: fileData.customPath }); // 下载文件 const response = await axios.get(url, { responseType: 'arraybuffer', timeout: options?.timeout || 30000, maxRedirects: 5, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FileDownloader/1.0)' } }); const buffer = Buffer.from(response.data); // 从URL或响应头中获取文件名 let fileName = fileData.customFileName; if (!fileName) { // 尝试从Content-Disposition头获取文件名 const contentDisposition = response.headers['content-disposition']; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[*]?=(?:utf-8'')?(.+)/i); if (filenameMatch) { fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')); } } // 从URL路径获取文件名 if (!fileName) { const urlPath = new URL(url).pathname; fileName = urlPath.split('/').pop() || `file_${Date.now()}`; } } // 确保文件有扩展名 if (!fileName.includes('.') && fileData.mimeType) { const ext = this.getExtensionFromMimeType(fileData.mimeType); if (ext) { fileName += `.${ext}`; } } // 确定MIME类型 let mimeType = fileData.mimeType || response.headers['content-type']; if (!mimeType || mimeType === 'application/octet-stream') { mimeType = this.inferMimeType(fileName); } // 保存文件 const saveResult = await this.saveFileWithCustomPath( { ...fileData, name: fileName, size: buffer.length, mimeType, fileType: this.getFileTypeFromMimeType(mimeType) }, buffer, fileData.customPath, mimeType ); logger.db('Download and save completed successfully:', { fileId: saveResult.file.id, fileName, size: buffer.length, url: saveResult.url }); return saveResult; } catch (error) { logger.error('Failed to download and save file from URL:', { url, error: error instanceof Error ? error.message : '未知错误', stack: error instanceof Error ? error.stack : undefined }); throw new Error(`从URL下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`); } } /** * 根据MIME类型获取文件扩展名 */ private getExtensionFromMimeType(mimeType: string): string | null { const mimeMap: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'application/pdf': 'pdf', 'text/plain': 'txt', 'application/json': 'json', 'application/xml': 'xml', 'video/mp4': 'mp4', 'audio/mp3': 'mp3' }; return mimeMap[mimeType] || null; } /** * 根据文件名推断MIME类型 */ private inferMimeType(fileName: string): string { const ext = fileName.toLowerCase().split('.').pop(); const extMap: Record = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'pdf': 'application/pdf', 'txt': 'text/plain', 'json': 'application/json', 'xml': 'application/xml', 'mp4': 'video/mp4', 'mp3': 'audio/mp3', 'wav': 'audio/wav' }; return extMap[ext || ''] || 'application/octet-stream'; } /** * 根据MIME类型获取文件类型 */ private getFileTypeFromMimeType(mimeType: string): string { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType === 'application/pdf') return 'document'; if (mimeType.startsWith('text/')) return 'document'; return 'other'; } }