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;
|