#!/usr/bin/env node /** * 上传文件清理工具 * 用法: node cleanup-uploads.js [选项] * * 选项: * --help 显示帮助信息 * --dry-run 仅列出将要删除的文件,不实际删除 * --temp 仅清理临时文件目录 * --processed 仅清理处理后的文件目录 * --public 仅清理公共上传目录 * --all 清理所有上传目录 (默认) * --age=<小时> 指定文件最大保留时间(小时),默认临时文件24小时,处理文件72小时,公共上传168小时 * --verbose 显示详细输出 */ const fs = require('fs'); const path = require('path'); const { program } = require('commander'); const { cleanupDirectory } = require('../utils/cleanup'); const logger = require('../utils/logger'); const Minio = require('minio'); // 获取S3/MinIO配置 const minioEndpoint = process.env.MINIO_ENDPOINT || 'localhost'; const minioPort = parseInt(process.env.MINIO_PORT || '9000', 10); const minioUseSSL = process.env.MINIO_USE_SSL === 'true'; const minioAccessKey = process.env.MINIO_ACCESS_KEY || ''; const minioSecretKey = process.env.MINIO_SECRET_KEY || ''; const minioBucketName = process.env.MINIO_BUCKET_NAME || 'images'; const minioRegion = process.env.MINIO_REGION || ''; const useS3Storage = process.env.USE_S3_STORAGE === 'true'; // 创建S3/MinIO客户端 const minioClient = new Minio.Client({ endPoint: minioEndpoint, port: minioPort, useSSL: minioUseSSL, accessKey: minioAccessKey, secretKey: minioSecretKey, region: minioRegion, s3ForcePathStyle: false, // 使用虚拟主机样式URL pathStyle: false // 不使用路径样式 }); // 尝试加载AWS SDK let S3Client = null; let S3Commands = null; if (useS3Storage) { try { // 尝试动态加载@aws-sdk/client-s3模块 const AWS = require('@aws-sdk/client-s3'); S3Client = AWS.S3Client; S3Commands = { ListObjectsV2Command: AWS.ListObjectsV2Command, DeleteObjectsCommand: AWS.DeleteObjectsCommand }; } catch (err) { console.warn('AWS SDK未安装,将使用Minio客户端进行S3操作'); } } // 配置命令行选项 program .option('--temp', '清理临时上传目录') .option('--processed', '清理处理后的图片目录') .option('--public', '清理公共上传目录') .option('--minio', '清理存储桶中的文件') .option('--all', '清理所有上传目录') .option('--age ', '设置文件保留的最大时间(小时,默认24小时)', parseInt, 24) .option('--dry-run', '模拟运行,不实际删除文件') .option('--upload-only', '仅清理上传的图片文件夹,保留下载的图片') .option('--prefix ', '仅清理指定前缀的文件', 'uploads/') .option('--help', '显示帮助信息') .parse(process.argv); const options = program.opts(); // 显示帮助信息 if (options.help || process.argv.length <= 2) { console.log('使用方法: node cleanup-uploads.js [选项]'); console.log(''); console.log('选项:'); console.log(' --temp 清理临时上传目录'); console.log(' --processed 清理处理后的图片目录'); console.log(' --public 清理公共上传目录'); console.log(' --minio 清理存储桶中的文件'); console.log(' --all 清理所有上传目录'); console.log(' --age 设置文件保留的最大时间(小时,默认24小时)'); console.log(' --dry-run 模拟运行,不实际删除文件'); console.log(' --upload-only 仅清理上传的图片文件夹,保留下载的图片'); console.log(' --prefix 指定要清理的文件前缀(默认为uploads/)'); console.log(' --help 显示帮助信息'); console.log(''); console.log('示例:'); console.log(' node cleanup-uploads.js --temp --age 48 # 清理48小时前的临时文件'); console.log(' node cleanup-uploads.js --all --dry-run # 测试清理所有目录,但不实际删除'); console.log(' node cleanup-uploads.js --minio --age 72 # 清理存储桶中72小时前的文件'); console.log(' node cleanup-uploads.js --minio --upload-only # 仅清理上传的图片目录'); console.log(' node cleanup-uploads.js --minio --prefix uploads/ # 仅清理指定前缀的文件'); process.exit(0); } // 创建自定义日志器 class CleanupLogger { constructor(isDryRun) { this.isDryRun = isDryRun; this.deletedFiles = 0; this.totalSize = 0; } info(message) { logger.info(`[清理任务] ${this.isDryRun ? '[模拟] ' : ''}${message}`); console.log(`[INFO] ${message}`); } warn(message) { logger.warn(`[清理任务] ${this.isDryRun ? '[模拟] ' : ''}${message}`); console.warn(`[WARN] ${message}`); } error(message) { logger.error(`[清理任务] ${this.isDryRun ? '[模拟] ' : ''}${message}`); console.error(`[ERROR] ${message}`); } log(message) { logger.info(`[清理任务] ${this.isDryRun ? '[模拟] ' : ''}${message}`); console.log(message); } fileDeleted(size) { this.deletedFiles++; this.totalSize += size; } getSummary() { return { deletedFiles: this.deletedFiles, totalSize: this.totalSize }; } } /** * 清理S3/MinIO中的旧文件 * @param {number} maxAgeHours - 最大保留时间(小时) * @param {boolean} isDryRun - 是否为模拟运行 * @param {CleanupLogger} customLogger - 自定义日志器 * @returns {Promise<{deletedFiles: number, totalSize: number}>} - 删除的文件数量和总大小 */ async function cleanupMinioFiles(maxAgeHours, isDryRun, customLogger) { const storageType = useS3Storage ? "AWS S3" : "MinIO"; const filePrefix = options.prefix || 'uploads/'; const uploadOnly = options.uploadOnly || false; if (uploadOnly) { customLogger.info(`开始清理${storageType}存储桶 '${minioBucketName}' 中的上传图片文件夹,仅处理前缀为 '${filePrefix}' 的文件...`); } else { customLogger.info(`开始清理${storageType}存储桶 '${minioBucketName}' 中 ${maxAgeHours} 小时前的文件...`); } // 检查是否配置了S3/MinIO if (!minioAccessKey || !minioSecretKey) { customLogger.error(`${storageType}配置不完整,无法清理存储桶`); return { deletedFiles: 0, totalSize: 0 }; } // 计算截止时间 const cutoffTime = new Date(); cutoffTime.setHours(cutoffTime.getHours() - maxAgeHours); let deletedFiles = 0; let totalSize = 0; const objectsToDelete = []; try { // 使用AWS SDK或简化的Minio列表方法 if (useS3Storage && S3Client) { // 使用AWS SDK直接操作S3 const s3Client = new S3Client({ region: minioRegion, credentials: { accessKeyId: minioAccessKey, secretAccessKey: minioSecretKey }, endpoint: `https://${minioEndpoint}`, forcePathStyle: false }); // 获取所有符合条件的对象 let continuationToken = undefined; do { const command = new S3Commands.ListObjectsV2Command({ Bucket: minioBucketName, ContinuationToken: continuationToken, Prefix: uploadOnly ? filePrefix : undefined // 如果只清理上传文件夹,添加前缀过滤 }); const response = await s3Client.send(command); // 处理对象 if (response.Contents) { for (const obj of response.Contents) { // 检查是否是指定前缀的文件 if (uploadOnly && !obj.Key.startsWith(filePrefix)) { continue; // 跳过不符合前缀的文件 } if (obj.LastModified && obj.LastModified < cutoffTime) { objectsToDelete.push({ Key: obj.Key }); const sizeInKB = Math.round(obj.Size / 1024); const formattedDate = obj.LastModified.toISOString().replace('T', ' ').substring(0, 19); customLogger.info(`${isDryRun ? '将删除' : '删除'} ${storageType}文件: ${obj.Key} (${sizeInKB} KB, 最后修改: ${formattedDate})`); if (!isDryRun) { totalSize += obj.Size; deletedFiles++; customLogger.fileDeleted(obj.Size); } } } } continuationToken = response.NextContinuationToken; } while (continuationToken); // 删除对象 if (!isDryRun && objectsToDelete.length > 0) { const batchSize = 1000; // S3一次最多删除1000个对象 for (let i = 0; i < objectsToDelete.length; i += batchSize) { const batch = objectsToDelete.slice(i, i + batchSize); if (batch.length > 0) { try { const deleteCommand = new S3Commands.DeleteObjectsCommand({ Bucket: minioBucketName, Delete: { Objects: batch, Quiet: true } }); await s3Client.send(deleteCommand); // 添加短暂延迟 await new Promise(resolve => setTimeout(resolve, 200)); customLogger.info(`成功从${storageType}删除一批${batch.length}个文件`); } catch (err) { customLogger.error(`删除S3对象批次时出错: ${err.message}`); } } } } } else { // 退回到使用Minio客户端的方式 // 这种方式更通用,但针对AWS S3可能有兼容性问题 // 为了保护生产数据,在AWS S3上使用MinIO客户端时,默认只执行干运行模式 if (useS3Storage && !isDryRun) { customLogger.warn("警告: AWS S3模式下使用MinIO客户端可能不安全,建议手动检查文件并使用AWS CLI直接删除"); customLogger.warn("为保护数据,将自动切换为模拟运行模式"); isDryRun = true; } try { // 检查存储桶是否存在 const exists = await minioClient.bucketExists(minioBucketName); if (!exists) { customLogger.error(`${storageType}存储桶 '${minioBucketName}' 不存在`); return { deletedFiles: 0, totalSize: 0 }; } } catch (err) { customLogger.error(`检查${storageType}存储桶失败: ${err.message}`); return { deletedFiles: 0, totalSize: 0 }; } // 使用MinIO客户端列出对象 // 注意:这种方法在AWS S3上可能不可靠,因此默认设为干运行模式 const prefix = uploadOnly ? filePrefix : ''; const objectsStream = minioClient.listObjects(minioBucketName, prefix, true); // 收集要删除的对象 await new Promise((resolve, reject) => { objectsStream.on('data', (obj) => { // 检查是否是指定前缀的文件 if (uploadOnly && !obj.name.startsWith(filePrefix)) { return; // 跳过不符合前缀的文件 } // 检查最后修改时间是否早于截止时间 if (obj.lastModified && obj.lastModified < cutoffTime) { objectsToDelete.push(obj.name); const sizeInKB = Math.round(obj.size / 1024); const formattedDate = obj.lastModified.toISOString().replace('T', ' ').substring(0, 19); customLogger.info(`${isDryRun ? '将删除' : '删除'} ${storageType}文件: ${obj.name} (${sizeInKB} KB, 最后修改: ${formattedDate})`); if (!isDryRun) { totalSize += obj.size; deletedFiles++; customLogger.fileDeleted(obj.size); } } }); objectsStream.on('error', (err) => { customLogger.error(`列出${storageType}对象时出错: ${err.message}`); reject(err); }); objectsStream.on('end', () => { resolve(); }); }); // 如果不是模拟运行,删除收集到的对象 if (!isDryRun && objectsToDelete.length > 0) { try { // 分批删除对象 const batchSize = 100; for (let i = 0; i < objectsToDelete.length; i += batchSize) { const batch = objectsToDelete.slice(i, i + batchSize); if (batch.length > 0) { await minioClient.removeObjects(minioBucketName, batch); // 添加短暂延迟 await new Promise(resolve => setTimeout(resolve, 500)); } } customLogger.info(`成功从${storageType}删除 ${objectsToDelete.length} 个文件`); } catch (err) { customLogger.error(`从${storageType}删除对象时出错: ${err.message}`); } } } } catch (err) { customLogger.error(`处理${storageType}对象时出错: ${err.message}`); return { deletedFiles, totalSize }; } // 格式化总大小 const sizeInMB = (totalSize / (1024 * 1024)).toFixed(2); customLogger.info(`${storageType}清理完成, ${isDryRun ? '将删除' : '已删除'} ${deletedFiles} 个文件, 共 ${sizeInMB} MB`); return { deletedFiles, totalSize }; } /** * 执行清理操作 */ async function performCleanup() { const maxAgeHours = options.age || 24; const isDryRun = !!options.dryRun; const logPrefix = isDryRun ? '[模拟运行] ' : ''; console.log(`${logPrefix}开始清理 ${maxAgeHours} 小时前的上传文件...`); // 初始化自定义日志器 const customLogger = new CleanupLogger(isDryRun); // 确定要清理的目录 const shouldCleanTemp = options.temp || options.all; const shouldCleanProcessed = options.processed || options.all; const shouldCleanPublic = options.public || options.all; const shouldCleanMinio = options.minio || options.all; // 如果没有指定任何目录,显示帮助信息 if (!shouldCleanTemp && !shouldCleanProcessed && !shouldCleanPublic && !shouldCleanMinio) { console.log('未指定要清理的目录,请使用 --temp, --processed, --public, --minio 或 --all 选项'); console.log('使用 --help 查看帮助信息'); process.exit(1); } // 定义要清理的目录 const directories = []; if (shouldCleanTemp) { directories.push({ path: path.join(__dirname, '../uploads/temp'), name: '临时上传目录' }); } if (shouldCleanProcessed) { directories.push({ path: path.join(__dirname, '../uploads/processed'), name: '处理后的图片目录' }); } if (shouldCleanPublic) { directories.push({ path: path.join(__dirname, '../../public/uploads'), name: '公共上传目录' }); } // 清理本地目录 let totalDeletedFiles = 0; let totalSizeFreed = 0; for (const dir of directories) { if (!fs.existsSync(dir.path)) { customLogger.warn(`目录不存在: ${dir.path}`); continue; } customLogger.info(`清理${dir.name}: ${dir.path}`); try { const result = await cleanupDirectory(dir.path, maxAgeHours, customLogger, isDryRun); totalDeletedFiles += result.deletedFiles; totalSizeFreed += result.sizeFreed; } catch (err) { customLogger.error(`清理${dir.name}时出错: ${err.message}`); } } // 清理S3/MinIO存储 if (shouldCleanMinio) { try { const result = await cleanupMinioFiles(maxAgeHours, isDryRun, customLogger); totalDeletedFiles += result.deletedFiles; totalSizeFreed += result.totalSize; } catch (err) { customLogger.error(`清理${useS3Storage ? 'S3' : 'MinIO'}存储时出错: ${err.message}`); } } // 输出总结 const totalSizeMB = (totalSizeFreed / (1024 * 1024)).toFixed(2); customLogger.info(`清理完成! ${isDryRun ? '将删除' : '已删除'} ${totalDeletedFiles} 个文件,释放 ${totalSizeMB} MB 空间`); } // 开始执行清理 performCleanup();