293 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
		
		
			
		
	
	
			293 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
|  | const { createMinioClient, minioConfig, getPublicUrl } = require('../config/minio'); | |||
|  | const path = require('path'); | |||
|  | const crypto = require('crypto'); | |||
|  | 
 | |||
|  | /** | |||
|  |  * MinIO 文件服务 | |||
|  |  * 提供文件上传、删除、获取等功能 | |||
|  |  */ | |||
|  | class MinioService { | |||
|  |   constructor() { | |||
|  |     this.client = createMinioClient(); | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 生成唯一文件名 | |||
|  |    * @param {string} originalName - 原始文件名 | |||
|  |    * @returns {string} 唯一文件名 | |||
|  |    */ | |||
|  |   generateUniqueFileName(originalName) { | |||
|  |     const now = new Date(); | |||
|  |     const year = now.getFullYear(); | |||
|  |     const month = String(now.getMonth() + 1).padStart(2, '0'); | |||
|  |     const day = String(now.getDate()).padStart(2, '0'); | |||
|  |     const timestamp = Date.now(); | |||
|  |     const randomString = crypto.randomBytes(8).toString('hex'); | |||
|  |     const ext = path.extname(originalName); | |||
|  |     return `${year}/${month}/${day}/${timestamp}_${randomString}${ext}`; | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 根据文件类型获取存储桶名称 | |||
|  |    * @param {string} type - 文件类型 (avatar, product, document) | |||
|  |    * @returns {string} 存储桶名称 | |||
|  |    */ | |||
|  |   getBucketName(type = 'document') { | |||
|  |     const bucketMap = { | |||
|  |       'avatar': minioConfig.buckets.avatars, | |||
|  |       'product': minioConfig.buckets.products, | |||
|  |       'document': minioConfig.buckets.documents | |||
|  |     }; | |||
|  |     return bucketMap[type] || minioConfig.buckets.documents; | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 上传单个文件 | |||
|  |    * @param {Buffer} fileBuffer - 文件缓冲区 | |||
|  |    * @param {string} originalName - 原始文件名 | |||
|  |    * @param {string} mimeType - 文件MIME类型 | |||
|  |    * @param {string} type - 文件类型 | |||
|  |    * @returns {Promise<Object>} 上传结果 | |||
|  |    */ | |||
|  |   async uploadFile(fileBuffer, originalName, mimeType, type = 'document') { | |||
|  |     try { | |||
|  |       const bucketName = this.getBucketName(type); | |||
|  |       const fileName = this.generateUniqueFileName(originalName); | |||
|  |        | |||
|  |       // 设置文件元数据
 | |||
|  |       const metaData = { | |||
|  |         'Content-Type': mimeType, | |||
|  |         'Original-Name': encodeURIComponent(originalName), | |||
|  |         'Upload-Time': new Date().toISOString() | |||
|  |       }; | |||
|  | 
 | |||
|  |       // 上传文件到MinIO
 | |||
|  |       await this.client.putObject(bucketName, fileName, fileBuffer, fileBuffer.length, metaData); | |||
|  |        | |||
|  |       // 生成访问URL
 | |||
|  |       const url = getPublicUrl(bucketName, fileName); | |||
|  |        | |||
|  |       return { | |||
|  |         success: true, | |||
|  |         data: { | |||
|  |           filename: fileName, | |||
|  |           originalname: originalName, | |||
|  |           mimetype: mimeType, | |||
|  |           size: fileBuffer.length, | |||
|  |           bucket: bucketName, | |||
|  |           path: `${bucketName}/${fileName}`, | |||
|  |           url: url | |||
|  |         } | |||
|  |       }; | |||
|  |     } catch (error) { | |||
|  |       console.error('MinIO文件上传失败:', error); | |||
|  |       throw new Error(`文件上传失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 迁移专用:上传文件到指定存储桶和路径 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} filePath - 文件路径 | |||
|  |    * @param {Buffer} fileBuffer - 文件缓冲区 | |||
|  |    * @param {string} mimeType - 文件MIME类型 | |||
|  |    * @returns {Promise<Object>} 上传结果 | |||
|  |    */ | |||
|  |   async uploadFileForMigration(bucketName, filePath, fileBuffer, mimeType) { | |||
|  |     try { | |||
|  |       // 设置文件元数据
 | |||
|  |       const metaData = { | |||
|  |         'Content-Type': mimeType, | |||
|  |         'Upload-Time': new Date().toISOString() | |||
|  |       }; | |||
|  | 
 | |||
|  |       // 上传文件到MinIO
 | |||
|  |       await this.client.putObject(bucketName, filePath, fileBuffer, fileBuffer.length, metaData); | |||
|  |        | |||
|  |       // 生成访问URL
 | |||
|  |       const url = getPublicUrl(bucketName, filePath); | |||
|  |        | |||
|  |       return { | |||
|  |         success: true, | |||
|  |         data: { | |||
|  |           filename: filePath, | |||
|  |           mimetype: mimeType, | |||
|  |           size: fileBuffer.length, | |||
|  |           bucket: bucketName, | |||
|  |           path: `${bucketName}/${filePath}`, | |||
|  |           url: url | |||
|  |         } | |||
|  |       }; | |||
|  |     } catch (error) { | |||
|  |       console.error('MinIO文件迁移上传失败:', error); | |||
|  |       throw new Error(`文件迁移上传失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 上传多个文件 | |||
|  |    * @param {Array} files - 文件数组,每个文件包含 {buffer, originalName, mimeType} | |||
|  |    * @param {string} type - 文件类型 | |||
|  |    * @returns {Promise<Array>} 上传结果数组 | |||
|  |    */ | |||
|  |   async uploadMultipleFiles(files, type = 'document') { | |||
|  |     try { | |||
|  |       const uploadPromises = files.map(file =>  | |||
|  |         this.uploadFile(file.buffer, file.originalName, file.mimeType, type) | |||
|  |       ); | |||
|  |        | |||
|  |       const results = await Promise.all(uploadPromises); | |||
|  |       const uploadedFiles = results.map(result => result.data); | |||
|  |        | |||
|  |       return { | |||
|  |         success: true, | |||
|  |         data: { | |||
|  |           files: uploadedFiles, | |||
|  |           urls: uploadedFiles.map(file => file.url), | |||
|  |           count: uploadedFiles.length | |||
|  |         } | |||
|  |       }; | |||
|  |     } catch (error) { | |||
|  |       console.error('MinIO多文件上传失败:', error); | |||
|  |       throw new Error(`多文件上传失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 删除文件 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} fileName - 文件名 | |||
|  |    * @returns {Promise<boolean>} 删除结果 | |||
|  |    */ | |||
|  |   async deleteFile(bucketName, fileName) { | |||
|  |     try { | |||
|  |       await this.client.removeObject(bucketName, fileName); | |||
|  |       console.log(`✅ 文件删除成功: ${bucketName}/${fileName}`); | |||
|  |       return true; | |||
|  |     } catch (error) { | |||
|  |       console.error('MinIO文件删除失败:', error); | |||
|  |       throw new Error(`文件删除失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 批量删除文件 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {Array<string>} fileNames - 文件名数组 | |||
|  |    * @returns {Promise<Object>} 删除结果 | |||
|  |    */ | |||
|  |   async deleteMultipleFiles(bucketName, fileNames) { | |||
|  |     try { | |||
|  |       const deletePromises = fileNames.map(fileName =>  | |||
|  |         this.deleteFile(bucketName, fileName) | |||
|  |       ); | |||
|  |        | |||
|  |       await Promise.all(deletePromises); | |||
|  |        | |||
|  |       return { | |||
|  |         success: true, | |||
|  |         deletedCount: fileNames.length, | |||
|  |         message: `成功删除${fileNames.length}个文件` | |||
|  |       }; | |||
|  |     } catch (error) { | |||
|  |       console.error('MinIO批量删除失败:', error); | |||
|  |       throw new Error(`批量删除失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 检查文件是否存在 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} fileName - 文件名 | |||
|  |    * @returns {Promise<boolean>} 文件是否存在 | |||
|  |    */ | |||
|  |   async fileExists(bucketName, fileName) { | |||
|  |     try { | |||
|  |       await this.client.statObject(bucketName, fileName); | |||
|  |       return true; | |||
|  |     } catch (error) { | |||
|  |       if (error.code === 'NotFound') { | |||
|  |         return false; | |||
|  |       } | |||
|  |       throw error; | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 获取文件信息 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} fileName - 文件名 | |||
|  |    * @returns {Promise<Object>} 文件信息 | |||
|  |    */ | |||
|  |   async getFileInfo(bucketName, fileName) { | |||
|  |     try { | |||
|  |       const stat = await this.client.statObject(bucketName, fileName); | |||
|  |       return { | |||
|  |         size: stat.size, | |||
|  |         lastModified: stat.lastModified, | |||
|  |         etag: stat.etag, | |||
|  |         contentType: stat.metaData['content-type'], | |||
|  |         originalName: decodeURIComponent(stat.metaData['original-name'] || fileName) | |||
|  |       }; | |||
|  |     } catch (error) { | |||
|  |       console.error('获取文件信息失败:', error); | |||
|  |       throw new Error(`获取文件信息失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 生成预签名URL(用于临时访问) | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} fileName - 文件名 | |||
|  |    * @param {number} expiry - 过期时间(秒),默认7天 | |||
|  |    * @returns {Promise<string>} 预签名URL | |||
|  |    */ | |||
|  |   async getPresignedUrl(bucketName, fileName, expiry = 7 * 24 * 60 * 60) { | |||
|  |     try { | |||
|  |       const url = await this.client.presignedGetObject(bucketName, fileName, expiry); | |||
|  |       return url; | |||
|  |     } catch (error) { | |||
|  |       console.error('生成预签名URL失败:', error); | |||
|  |       throw new Error(`生成预签名URL失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | 
 | |||
|  |   /** | |||
|  |    * 列出存储桶中的文件 | |||
|  |    * @param {string} bucketName - 存储桶名称 | |||
|  |    * @param {string} prefix - 文件前缀 | |||
|  |    * @param {number} limit - 限制数量 | |||
|  |    * @returns {Promise<Array>} 文件列表 | |||
|  |    */ | |||
|  |   async listFiles(bucketName, prefix = '', limit = 100) { | |||
|  |     try { | |||
|  |       const files = []; | |||
|  |       const stream = this.client.listObjects(bucketName, prefix, true); | |||
|  |        | |||
|  |       return new Promise((resolve, reject) => { | |||
|  |         stream.on('data', (obj) => { | |||
|  |           if (files.length < limit) { | |||
|  |             files.push({ | |||
|  |               name: obj.name, | |||
|  |               size: obj.size, | |||
|  |               lastModified: obj.lastModified, | |||
|  |               etag: obj.etag, | |||
|  |               url: getPublicUrl(bucketName, obj.name) | |||
|  |             }); | |||
|  |           } | |||
|  |         }); | |||
|  |          | |||
|  |         stream.on('end', () => resolve(files)); | |||
|  |         stream.on('error', reject); | |||
|  |       }); | |||
|  |     } catch (error) { | |||
|  |       console.error('列出文件失败:', error); | |||
|  |       throw new Error(`列出文件失败: ${error.message}`); | |||
|  |     } | |||
|  |   } | |||
|  | } | |||
|  | 
 | |||
|  | // 创建单例实例
 | |||
|  | const minioService = new MinioService(); | |||
|  | 
 | |||
|  | module.exports = minioService; |