const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const fs = require('fs'); const path = require('path'); const { Readable } = require('stream'); const { UserDB, SettingsDB } = require('./database'); // ===== 工具函数 ===== /** * 格式化文件大小 */ function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } /** * 将 OSS/网络错误转换为友好的错误信息 * @param {Error} error - 原始错误 * @param {string} operation - 操作描述 * @returns {Error} 带有友好消息的错误 */ function formatOssError(error, operation = '操作') { // 常见的 AWS S3 / OSS 错误 const errorMessages = { 'NoSuchBucket': 'OSS 存储桶不存在,请检查配置', 'AccessDenied': 'OSS 访问被拒绝,请检查权限配置', 'InvalidAccessKeyId': 'OSS Access Key 无效,请重新配置', 'SignatureDoesNotMatch': 'OSS 签名验证失败,请检查 Secret Key', 'NoSuchKey': '文件或目录不存在', 'EntityTooLarge': '文件过大,超过了 OSS 允许的最大大小', 'RequestTimeout': 'OSS 请求超时,请稍后重试', 'SlowDown': 'OSS 请求过于频繁,请稍后重试', 'ServiceUnavailable': 'OSS 服务暂时不可用,请稍后重试', 'InternalError': 'OSS 内部错误,请稍后重试', 'BucketNotEmpty': '存储桶不为空', 'InvalidBucketName': '无效的存储桶名称', 'InvalidObjectName': '无效的对象名称', 'TooManyBuckets': '存储桶数量超过限制' }; // 网络错误 const networkErrors = { 'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络', 'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置', 'ETIMEDOUT': '连接 OSS 服务超时,请检查网络', 'ECONNRESET': '与 OSS 服务的连接被重置,请重试', 'EPIPE': '与 OSS 服务的连接中断,请重试', 'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络' }; // 检查 AWS SDK 错误名称 if (error.name && errorMessages[error.name]) { return new Error(`${operation}失败: ${errorMessages[error.name]}`); } // 检查网络错误代码 if (error.code && networkErrors[error.code]) { return new Error(`${operation}失败: ${networkErrors[error.code]}`); } // HTTP 状态码错误 if (error.$metadata?.httpStatusCode) { const statusCode = error.$metadata.httpStatusCode; const statusMessages = { 400: '请求参数错误', 401: '认证失败,请检查 Access Key', 403: '没有权限执行此操作', 404: '资源不存在', 409: '资源冲突', 429: '请求过于频繁,请稍后重试', 500: 'OSS 服务内部错误', 502: 'OSS 网关错误', 503: 'OSS 服务暂时不可用' }; if (statusMessages[statusCode]) { return new Error(`${operation}失败: ${statusMessages[statusCode]}`); } } // 返回原始错误信息 return new Error(`${operation}失败: ${error.message}`); } // ===== 统一存储接口 ===== /** * 存储接口工厂 * 根据用户的存储类型返回对应的存储客户端 */ class StorageInterface { constructor(user) { this.user = user; this.type = user.current_storage_type || 'oss'; } /** * 创建并返回存储客户端 */ async connect() { if (this.type === 'local') { const client = new LocalStorageClient(this.user); await client.init(); return client; } else { // OSS 客户端会自动检查是否有可用配置(系统配置或用户配置) // 不再在这里强制检查 has_oss_config const client = new OssStorageClient(this.user); await client.connect(); return client; } } } // ===== 本地存储客户端 ===== class LocalStorageClient { constructor(user) { this.user = user; // 使用环境变量或默认路径(不硬编码) const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); this.basePath = path.join(storageRoot, `user_${user.id}`); } /** * 初始化用户存储目录 */ async init() { if (!fs.existsSync(this.basePath)) { fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 }); console.log(`[本地存储] 创建用户目录: ${this.basePath}`); } } /** * 列出目录内容 * @param {string} dirPath - 目录路径 * @returns {Promise} 文件列表 */ async list(dirPath) { const fullPath = this.getFullPath(dirPath); // 确保目录存在 if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); return []; } // 检查是否是目录 const pathStats = fs.statSync(fullPath); if (!pathStats.isDirectory()) { throw new Error('指定路径不是目录'); } const items = fs.readdirSync(fullPath, { withFileTypes: true }); const result = []; for (const item of items) { try { const itemPath = path.join(fullPath, item.name); const stats = fs.statSync(itemPath); result.push({ name: item.name, type: item.isDirectory() ? 'd' : '-', size: stats.size, modifyTime: stats.mtimeMs }); } catch (error) { // 跳过无法访问的文件(权限问题或符号链接断裂等) console.warn(`[本地存储] 无法获取文件信息,跳过: ${item.name}`, error.message); } } return result; } /** * 上传文件 */ async put(localPath, remotePath) { const destPath = this.getFullPath(remotePath); // 获取新文件大小 const newFileSize = fs.statSync(localPath).size; // 如果目标文件存在,计算实际需要的额外空间 let oldFileSize = 0; if (fs.existsSync(destPath)) { try { oldFileSize = fs.statSync(destPath).size; } catch (err) { // 文件可能已被删除,忽略错误 } } // 检查配额:只检查净增量(新文件大小 - 旧文件大小) const netIncrease = newFileSize - oldFileSize; if (netIncrease > 0) { this.checkQuota(netIncrease); } // 确保目标目录存在 const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // 使用临时文件+重命名模式,避免文件被占用问题 const tempPath = `${destPath}.uploading_${Date.now()}`; try { // 复制到临时文件 fs.copyFileSync(localPath, tempPath); // 如果目标文件存在,先删除 if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } // 重命名临时文件为目标文件 fs.renameSync(tempPath, destPath); // 更新已使用空间(使用净增量) if (netIncrease !== 0) { this.updateUsedSpace(netIncrease); } } catch (error) { // 清理临时文件 if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } throw error; } } /** * 删除文件或文件夹 */ async delete(filePath) { const fullPath = this.getFullPath(filePath); // 检查文件是否存在 if (!fs.existsSync(fullPath)) { console.warn(`[本地存储] 删除目标不存在,跳过: ${filePath}`); return; // 文件不存在,直接返回(幂等操作) } let stats; try { stats = fs.statSync(fullPath); } catch (error) { if (error.code === 'ENOENT') { // 文件在检查后被删除,直接返回 return; } throw error; } if (stats.isDirectory()) { // 删除文件夹 - 递归删除 // 先计算文件夹内所有文件的总大小 const folderSize = this.calculateFolderSize(fullPath); // 删除文件夹及其内容 fs.rmSync(fullPath, { recursive: true, force: true }); // 更新已使用空间 if (folderSize > 0) { this.updateUsedSpace(-folderSize); } console.log(`[本地存储] 删除文件夹: ${filePath} (释放 ${this.formatSize(folderSize)})`); } else { const fileSize = stats.size; // 删除文件 fs.unlinkSync(fullPath); // 更新已使用空间 this.updateUsedSpace(-fileSize); console.log(`[本地存储] 删除文件: ${filePath} (释放 ${this.formatSize(fileSize)})`); } } /** * 计算文件夹大小 */ calculateFolderSize(folderPath) { let totalSize = 0; const items = fs.readdirSync(folderPath, { withFileTypes: true }); for (const item of items) { const itemPath = path.join(folderPath, item.name); if (item.isDirectory()) { // 递归计算子文件夹 totalSize += this.calculateFolderSize(itemPath); } else { // 累加文件大小 const stats = fs.statSync(itemPath); totalSize += stats.size; } } return totalSize; } /** * 重命名文件或目录 * @param {string} oldPath - 原路径 * @param {string} newPath - 新路径 */ async rename(oldPath, newPath) { const oldFullPath = this.getFullPath(oldPath); const newFullPath = this.getFullPath(newPath); // 检查源和目标是否相同 if (oldFullPath === newFullPath) { console.log(`[本地存储] 源路径和目标路径相同,跳过: ${oldPath}`); return; } // 检查源文件是否存在 if (!fs.existsSync(oldFullPath)) { throw new Error('源文件或目录不存在'); } // 检查目标是否已存在(防止覆盖) if (fs.existsSync(newFullPath)) { throw new Error('目标位置已存在同名文件或目录'); } // 确保新路径的目录存在 const newDir = path.dirname(newFullPath); if (!fs.existsSync(newDir)) { fs.mkdirSync(newDir, { recursive: true }); } fs.renameSync(oldFullPath, newFullPath); console.log(`[本地存储] 重命名: ${oldPath} -> ${newPath}`); } /** * 获取文件信息 * @param {string} filePath - 文件路径 * @returns {Promise} 文件状态信息,包含 isDirectory 属性 */ async stat(filePath) { const fullPath = this.getFullPath(filePath); if (!fs.existsSync(fullPath)) { throw new Error(`文件或目录不存在: ${filePath}`); } const stats = fs.statSync(fullPath); // 返回与 OssStorageClient.stat 一致的格式 return { size: stats.size, modifyTime: stats.mtimeMs, isDirectory: stats.isDirectory(), // 保留原始 stats 对象的方法兼容性 isFile: () => stats.isFile(), _raw: stats }; } /** * 创建文件读取流 * @param {string} filePath - 文件路径 * @returns {ReadStream} 文件读取流 */ createReadStream(filePath) { const fullPath = this.getFullPath(filePath); if (!fs.existsSync(fullPath)) { throw new Error(`文件不存在: ${filePath}`); } return fs.createReadStream(fullPath); } /** * 创建文件夹 * @param {string} dirPath - 目录路径 */ async mkdir(dirPath) { const fullPath = this.getFullPath(dirPath); // 检查是否已存在 if (fs.existsSync(fullPath)) { const stats = fs.statSync(fullPath); if (stats.isDirectory()) { // 目录已存在,直接返回 return; } throw new Error('同名文件已存在'); } // 创建目录 fs.mkdirSync(fullPath, { recursive: true, mode: 0o755 }); console.log(`[本地存储] 创建文件夹: ${dirPath}`); } /** * 检查文件或目录是否存在 * @param {string} filePath - 文件路径 * @returns {Promise} */ async exists(filePath) { try { const fullPath = this.getFullPath(filePath); return fs.existsSync(fullPath); } catch (error) { return false; } } /** * 关闭连接(本地存储无需关闭) */ async end() { // 本地存储无需关闭连接 } // ===== 辅助方法 ===== /** * 获取完整路径(带安全检查) * 增强的路径遍历防护 */ getFullPath(relativePath) { // 0. 输入验证:检查空字节注入和其他危险字符 if (typeof relativePath !== 'string') { throw new Error('无效的路径类型'); } // 检查空字节注入(%00, \x00) if (relativePath.includes('\x00') || relativePath.includes('%00')) { console.warn('[安全] 检测到空字节注入尝试:', relativePath); throw new Error('路径包含非法字符'); } // 1. 规范化路径,移除 ../ 等危险路径 let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, ''); // 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过) // 解析后的路径不应包含 .. if (normalized.includes('..')) { console.warn('[安全] 检测到目录遍历尝试:', relativePath); throw new Error('路径包含非法字符'); } // 3. 将绝对路径转换为相对路径(解决Linux环境下的问题) if (path.isAbsolute(normalized)) { // 移除开头的 / 或 Windows 盘符,转为相对路径 normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, ''); } // 4. 空字符串或 . 表示根目录 if (normalized === '' || normalized === '.') { return this.basePath; } // 5. 拼接完整路径 const fullPath = path.join(this.basePath, normalized); // 6. 解析真实路径(处理符号链接)后再次验证 const resolvedBasePath = path.resolve(this.basePath); const resolvedFullPath = path.resolve(fullPath); // 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击) if (!resolvedFullPath.startsWith(resolvedBasePath)) { console.warn('[安全] 检测到路径遍历攻击:', { input: relativePath, resolved: resolvedFullPath, base: resolvedBasePath }); throw new Error('非法路径访问'); } return fullPath; } /** * 检查配额 */ checkQuota(additionalSize) { const newUsed = (this.user.local_storage_used || 0) + additionalSize; if (newUsed > this.user.local_storage_quota) { const used = this.formatSize(this.user.local_storage_used); const quota = this.formatSize(this.user.local_storage_quota); const need = this.formatSize(additionalSize); throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`); } } /** * 更新已使用空间 */ updateUsedSpace(delta) { const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta); UserDB.update(this.user.id, { local_storage_used: newUsed }); // 更新内存中的值 this.user.local_storage_used = newUsed; } /** * 恢复未完成的重命名操作(启动时调用) * 扫描OSS存储中的待处理重命名标记文件,执行回滚或完成操作 * * **重命名操作的两个阶段:** * 1. copying 阶段:正在复制文件到新位置 * - 恢复策略:删除已复制的目标文件,保留原文件 * 2. deleting 阶段:正在删除原文件 * - 恢复策略:确保原文件被完全删除(补充删除逻辑) * * @private */ async recoverPendingRenames() { try { console.log('[OSS存储] 检查未完成的重命名操作...'); const bucket = this.getBucket(); const markerPrefix = this.prefix + '.rename_pending_'; // 列出所有待处理的标记文件 const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: markerPrefix, MaxKeys: 100 }); const response = await this.s3Client.send(listCommand); if (!response.Contents || response.Contents.length === 0) { console.log('[OSS存储] 没有未完成的重命名操作'); return; } console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`); for (const marker of response.Contents) { try { // 从标记文件名中解析元数据 // 格式: .rename_pending_{timestamp}_{oldKeyHash}.json const markerKey = marker.Key; // 读取标记文件内容 const getMarkerCommand = new GetObjectCommand({ Bucket: bucket, Key: markerKey }); const markerResponse = await this.s3Client.send(getMarkerCommand); const markerContent = await streamToBuffer(markerResponse.Body); const metadata = JSON.parse(markerContent.toString()); const { oldPrefix, newPrefix, timestamp, phase } = metadata; // 检查标记是否过期(超过1小时视为失败,需要恢复) const age = Date.now() - timestamp; const TIMEOUT = 60 * 60 * 1000; // 1小时 if (age > TIMEOUT) { console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`); // 根据不同阶段执行不同的恢复策略 if (phase === 'copying') { // ===== 第一阶段:复制阶段超时 ===== // 策略:删除已复制的目标文件,保留原文件 console.log(`[OSS存储] [copying阶段] 执行回滚: 删除已复制的文件 ${newPrefix}`); await this._rollbackRename(oldPrefix, newPrefix); } else if (phase === 'deleting') { // ===== 第二阶段:删除阶段超时(第二轮修复) ===== // 策略:补充完整的删除逻辑,确保原文件被清理干净 console.log(`[OSS存储] [deleting阶段] 执行补充删除: 清理剩余原文件 ${oldPrefix}`); try { // 步骤1:列出原位置的所有剩余文件 let continuationToken = null; let remainingCount = 0; const MAX_KEYS_PER_REQUEST = 1000; do { const listOldCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: oldPrefix, MaxKeys: MAX_KEYS_PER_REQUEST, ContinuationToken: continuationToken }); const listOldResponse = await this.s3Client.send(listOldCommand); continuationToken = listOldResponse.NextContinuationToken; if (listOldResponse.Contents && listOldResponse.Contents.length > 0) { // 步骤2:批量删除剩余的原文件 const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: listOldResponse.Contents.map(obj => ({ Key: obj.Key })), Quiet: true } }); const deleteResult = await this.s3Client.send(deleteCommand); remainingCount += listOldResponse.Contents.length; console.log(`[OSS存储] [deleting阶段] 已删除 ${listOldResponse.Contents.length} 个剩余原文件`); // 检查删除结果 if (deleteResult.Errors && deleteResult.Errors.length > 0) { console.warn(`[OSS存储] [deleting阶段] 部分文件删除失败:`, deleteResult.Errors); } } } while (continuationToken); if (remainingCount > 0) { console.log(`[OSS存储] [deleting阶段] 补充删除完成: 清理了 ${remainingCount} 个原文件`); } else { console.log(`[OSS存储] [deleting阶段] 原位置 ${oldPrefix} 已是空的,无需清理`); } } catch (cleanupError) { console.error(`[OSS存储] [deleting阶段] 补充删除失败: ${cleanupError.message}`); // 继续执行,不中断流程 } } else { // 未知阶段,记录警告 console.warn(`[OSS存储] 未知阶段 ${phase},跳过恢复`); } // 删除标记文件(完成恢复后清理) await this.s3Client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: markerKey }], Quiet: true } })); console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`); } else { console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase}, 剩余: ${Math.floor((TIMEOUT - age) / 1000)}秒)`); } } catch (error) { console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message); // 继续处理下一个标记文件 } } console.log('[OSS存储] 重命名操作恢复完成'); } catch (error) { console.error('[OSS存储] 恢复重命名操作时出错:', error.message); } } /** * 回滚重命名操作(删除已复制的目标文件) * @param {string} oldPrefix - 原前缀 * @param {string} newPrefix - 新前缀 * @private */ async _rollbackRename(oldPrefix, newPrefix) { const bucket = this.getBucket(); const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`; try { // 列出所有已复制的对象 let continuationToken = null; let deletedCount = 0; do { const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: newPrefixWithSlash, ContinuationToken: continuationToken, MaxKeys: 1000 }); const listResponse = await this.s3Client.send(listCommand); continuationToken = listResponse.NextContinuationToken; if (listResponse.Contents && listResponse.Contents.length > 0) { // 批量删除 const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })), Quiet: true } }); await this.s3Client.send(deleteCommand); deletedCount += listResponse.Contents.length; } } while (continuationToken); if (deletedCount > 0) { console.log(`[OSS存储] 回滚完成: 删除了 ${deletedCount} 个对象`); } } catch (error) { console.error(`[OSS存储] 回滚失败: ${error.message}`); throw error; } } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } } // ===== OSS存储客户端 ===== /** * OSS 存储客户端(基于 S3 协议) * 支持阿里云 OSS、腾讯云 COS、AWS S3 * * **优先级规则:** * 1. 如果系统配置了统一 OSS(管理员配置),优先使用系统配置 * 2. 否则使用用户自己的 OSS 配置(如果有的话) * 3. 用户文件通过 `user_{userId}/` 前缀完全隔离 */ class OssStorageClient { constructor(user) { this.user = user; this.s3Client = null; this.prefix = `user_${user.id}/`; // 用户隔离前缀 this.useUnifiedConfig = false; // 标记是否使用统一配置 } /** * 获取有效的 OSS 配置(优先使用系统配置) * @returns {Object} OSS 配置对象 * @throws {Error} 如果没有可用的配置 */ getEffectiveConfig() { // 1. 优先检查系统级统一配置 const unifiedConfig = SettingsDB.getUnifiedOssConfig(); if (unifiedConfig) { console.log(`[OSS存储] 用户 ${this.user.id} 使用系统级统一 OSS 配置`); this.useUnifiedConfig = true; return { oss_provider: unifiedConfig.provider, oss_region: unifiedConfig.region, oss_access_key_id: unifiedConfig.access_key_id, oss_access_key_secret: unifiedConfig.access_key_secret, oss_bucket: unifiedConfig.bucket, oss_endpoint: unifiedConfig.endpoint }; } // 2. 回退到用户自己的配置 if (this.user.has_oss_config) { console.log(`[OSS存储] 用户 ${this.user.id} 使用个人 OSS 配置`); this.useUnifiedConfig = false; return { oss_provider: this.user.oss_provider, oss_region: this.user.oss_region, oss_access_key_id: this.user.oss_access_key_id, oss_access_key_secret: this.user.oss_access_key_secret, oss_bucket: this.user.oss_bucket, oss_endpoint: this.user.oss_endpoint }; } // 3. 没有可用配置 throw new Error('OSS 存储未配置,请联系管理员配置系统级 OSS 服务'); } /** * 验证 OSS 配置是否完整 * @throws {Error} 配置不完整时抛出错误 */ validateConfig(config) { const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = config; if (!oss_provider || !['aliyun', 'tencent', 'aws'].includes(oss_provider)) { throw new Error('无效的 OSS 服务商,必须是 aliyun、tencent 或 aws'); } if (!oss_access_key_id || oss_access_key_id.trim() === '') { throw new Error('OSS Access Key ID 不能为空'); } if (!oss_access_key_secret || oss_access_key_secret.trim() === '') { throw new Error('OSS Access Key Secret 不能为空'); } if (!oss_bucket || oss_bucket.trim() === '') { throw new Error('OSS 存储桶名称不能为空'); } } /** * 根据服务商构建 S3 配置 * @param {Object} config - OSS 配置对象 * @returns {Object} S3 客户端配置 */ buildConfig(config) { // 先验证配置 this.validateConfig(config); const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = config; // AWS S3 默认配置 let s3Config = { region: oss_region || 'us-east-1', credentials: { accessKeyId: oss_access_key_id, secretAccessKey: oss_access_key_secret }, // 请求超时配置 requestHandler: { requestTimeout: 30000, // 30秒超时 httpsAgent: { timeout: 30000 } }, // 重试配置 maxAttempts: 3, // 禁用AWS特定的计算功能,提升阿里云OSS兼容性 disableNormalizeBucketName: true, // 禁用MD5校验和计算(阿里云OSS不完全支持AWS的checksum特性) checksumValidation: false }; // 阿里云 OSS if (oss_provider === 'aliyun') { // 规范化 region:确保格式为 oss-cn-xxx let region = oss_region || 'oss-cn-hangzhou'; if (!region.startsWith('oss-')) { region = 'oss-' + region; } s3Config.region = region; if (!oss_endpoint) { // 默认 endpoint 格式:https://{region}.aliyuncs.com s3Config.endpoint = `https://${region}.aliyuncs.com`; } else { // 确保 endpoint 以 https:// 或 http:// 开头 s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; } // 阿里云 OSS 使用 virtual-hosted-style,但需要设置 forcePathStyle 为 false s3Config.forcePathStyle = false; // 阿里云OSS特定配置:禁用AWS特定的计算功能 s3Config.disableNormalizeBucketName = true; s3Config.checksumValidation = false; } // 腾讯云 COS else if (oss_provider === 'tencent') { s3Config.region = oss_region || 'ap-guangzhou'; if (!oss_endpoint) { // 默认 endpoint 格式:https://cos.{region}.myqcloud.com s3Config.endpoint = `https://cos.${s3Config.region}.myqcloud.com`; } else { s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; } // 腾讯云 COS 使用 virtual-hosted-style s3Config.forcePathStyle = false; } // AWS S3 或其他兼容服务 else { if (oss_endpoint) { s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; // 自定义 endpoint(如 MinIO)通常需要 path-style s3Config.forcePathStyle = true; } // AWS 使用默认 endpoint,无需额外配置 } return s3Config; } /** * 连接 OSS 服务(初始化 S3 客户端) */ async connect() { try { // 获取有效的 OSS 配置(系统配置优先) const ossConfig = this.getEffectiveConfig(); const s3Config = this.buildConfig(ossConfig); // 保存当前使用的配置(供其他方法使用) this.currentConfig = ossConfig; this.s3Client = new S3Client(s3Config); console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`); return this; } catch (error) { console.error(`[OSS存储] 连接失败:`, error.message); throw new Error(`OSS 连接失败: ${error.message}`); } } /** * 获取当前使用的 bucket 名称 * @returns {string} */ getBucket() { if (this.currentConfig && this.currentConfig.oss_bucket) { return this.currentConfig.oss_bucket; } // 回退到用户配置(向后兼容) return this.user.oss_bucket; } /** * 获取当前使用的 provider * @returns {string} */ getProvider() { if (this.currentConfig && this.currentConfig.oss_provider) { return this.currentConfig.oss_provider; } // 回退到用户配置(向后兼容) return this.user.oss_provider; } /** * 获取对象的完整 Key(带用户前缀) * 增强安全检查,防止路径遍历攻击 */ getObjectKey(relativePath) { // 0. 输入类型验证 if (relativePath === null || relativePath === undefined) { return this.prefix; // null/undefined 返回根目录 } if (typeof relativePath !== 'string') { throw new Error('无效的路径类型'); } // 1. 检查空字节注入(%00, \x00)和其他危险字符 if (relativePath.includes('\x00') || relativePath.includes('%00')) { console.warn('[OSS安全] 检测到空字节注入尝试:', relativePath); throw new Error('路径包含非法字符'); } // 2. 先进行 URL 解码(防止双重编码绕过) let decoded = relativePath; try { decoded = decodeURIComponent(relativePath); } catch (e) { // 解码失败使用原始值 } // 3. 检查解码后的空字节 if (decoded.includes('\x00')) { console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath); throw new Error('路径包含非法字符'); } // 4. 规范化路径:统一使用正斜杠(OSS 使用正斜杠作为分隔符) let normalized = decoded .replace(/\\/g, '/') // 将反斜杠转换为正斜杠 .replace(/\/+/g, '/'); // 合并多个连续斜杠 // 5. 严格检查:路径中不允许包含 ..(防止目录遍历) // 检查各种变体:../, /../, /.. if (normalized.includes('..')) { console.warn('[OSS安全] 检测到目录遍历尝试:', relativePath); throw new Error('路径包含非法字符'); } // 6. 移除开头的斜杠 normalized = normalized.replace(/^\/+/, ''); // 7. 移除结尾的斜杠(除非是根目录) if (normalized.length > 0 && normalized !== '/') { normalized = normalized.replace(/\/+$/, ''); } // 8. 空路径或 . 表示根目录 if (normalized === '' || normalized === '.') { return this.prefix; } // 9. 拼接用户前缀(确保不会产生双斜杠) const objectKey = this.prefix + normalized; // 10. 最终验证:确保生成的 key 以用户前缀开头(双重保险) if (!objectKey.startsWith(this.prefix)) { console.warn('[OSS安全] Key 前缀验证失败:', { input: relativePath, key: objectKey, prefix: this.prefix }); throw new Error('非法路径访问'); } return objectKey; } /** * 列出目录内容 * 支持分页,可列出超过 1000 个文件的目录 * @param {string} dirPath - 目录路径 * @param {number} maxItems - 最大返回数量,默认 10000,设为 0 表示不限制 */ async list(dirPath, maxItems = 10000) { try { let prefix = this.getObjectKey(dirPath); const bucket = this.getBucket(); // 确保前缀以斜杠结尾(除非是根目录) if (prefix && !prefix.endsWith('/')) { prefix = prefix + '/'; } const items = []; const dirSet = new Set(); // 用于去重目录 let continuationToken = undefined; const MAX_KEYS_PER_REQUEST = 1000; do { const command = new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/', // 使用分隔符模拟目录结构 MaxKeys: MAX_KEYS_PER_REQUEST, ContinuationToken: continuationToken }); const response = await this.s3Client.send(command); continuationToken = response.NextContinuationToken; // 处理"子目录"(CommonPrefixes) if (response.CommonPrefixes) { for (const prefixObj of response.CommonPrefixes) { const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, ''); if (dirName && !dirSet.has(dirName)) { dirSet.add(dirName); items.push({ name: dirName, type: 'd', size: 0, modifyTime: Date.now() }); } } } // 处理文件(Contents) if (response.Contents) { for (const obj of response.Contents) { const key = obj.Key; // 跳过目录标记本身(以斜杠结尾的空对象) if (key === prefix || key.endsWith('/')) { continue; } const fileName = key.substring(prefix.length); // 跳过包含子路径的文件(不应该出现,但以防万一) if (fileName && !fileName.includes('/')) { items.push({ name: fileName, type: '-', size: obj.Size || 0, modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now() }); } } } // 检查是否达到最大数量限制 if (maxItems > 0 && items.length >= maxItems) { console.log(`[OSS存储] 列出目录达到限制: ${dirPath} (${items.length}/${maxItems})`); break; } } while (continuationToken); return items; } catch (error) { console.error(`[OSS存储] 列出目录失败: ${dirPath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } else if (error.name === 'InvalidAccessKeyId') { throw new Error('OSS Access Key 无效,请重新配置'); } throw new Error(`列出目录失败: ${error.message}`); } } /** * 上传文件(直接上传,简单高效) * @param {string} localPath - 本地文件路径 * @param {string} remotePath - 远程文件路径 */ async put(localPath, remotePath) { try { const key = this.getObjectKey(remotePath); const bucket = this.getBucket(); // 检查本地文件是否存在 if (!fs.existsSync(localPath)) { throw new Error(`本地文件不存在: ${localPath}`); } const fileStats = fs.statSync(localPath); const fileSize = fileStats.size; // 检查文件大小(AWS S3 单次上传最大 5GB) const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB if (fileSize > MAX_SINGLE_UPLOAD_SIZE) { throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`); } // 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码 // 这样可以确保与阿里云OSS的兼容性 const fileContent = fs.readFileSync(localPath); // 直接上传 const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: fileContent, ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题 // 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头) ChecksumAlgorithm: undefined }); await this.s3Client.send(command); console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`); } catch (error) { console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } else if (error.name === 'EntityTooLarge') { throw new Error('文件过大,超过了 OSS 允许的最大大小'); } else if (error.code === 'ENOENT') { throw new Error(`本地文件不存在: ${localPath}`); } throw new Error(`文件上传失败: ${error.message}`); } } /** * 删除文件或文件夹 * ===== P0 性能优化:返回删除的文件大小,用于更新存储使用量缓存 ===== * @returns {Promise<{size: number}>} 返回删除的文件总大小(字节) */ async delete(filePath) { try { const key = this.getObjectKey(filePath); const bucket = this.getBucket(); // 检查是文件还是目录(忽略不存在的文件) let statResult; try { statResult = await this.stat(filePath); } catch (statError) { if (statError.message && statResult?.message.includes('不存在')) { console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`); return { size: 0 }; // 文件不存在,返回大小为 0 } throw statError; // 其他错误继续抛出 } let totalDeletedSize = 0; if (statResult.isDirectory) { // 删除目录:列出所有对象并批量删除 // 使用分页循环处理超过 1000 个对象的情况 let continuationToken = null; let totalDeletedCount = 0; const MAX_DELETE_BATCH = 1000; // AWS S3 单次最多删除 1000 个对象 do { const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: key, MaxKeys: MAX_DELETE_BATCH, ContinuationToken: continuationToken }); const listResponse = await this.s3Client.send(listCommand); continuationToken = listResponse.NextContinuationToken; if (listResponse.Contents && listResponse.Contents.length > 0) { // 累加删除的文件大小 for (const obj of listResponse.Contents) { totalDeletedSize += obj.Size || 0; } // 批量删除当前批次的对象 const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })), Quiet: false } }); const deleteResult = await this.s3Client.send(deleteCommand); // 检查删除结果 if (deleteResult.Errors && deleteResult.Errors.length > 0) { console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors); } totalDeletedCount += listResponse.Contents.length; } } while (continuationToken); if (totalDeletedCount > 0) { console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`); } return { size: totalDeletedSize }; } else { // 删除单个文件(使用DeleteObjectCommand) // 获取文件大小 const size = statResult.size || 0; totalDeletedSize = size; const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); const command = new DeleteObjectCommand({ Bucket: bucket, Key: key }); await this.s3Client.send(command); console.log(`[OSS存储] 删除文件: ${key} (${size} 字节)`); return { size: totalDeletedSize }; } } catch (error) { console.error(`[OSS存储] 删除失败: ${filePath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } throw new Error(`删除文件失败: ${error.message}`); } } /** * 重命名文件或目录(OSS 不支持直接重命名,需要复制后删除) * 支持文件和目录的重命名 * @param {string} oldPath - 原路径 * @param {string} newPath - 新路径 */ async rename(oldPath, newPath) { const oldKey = this.getObjectKey(oldPath); const newKey = this.getObjectKey(newPath); const bucket = this.getBucket(); // 使用getBucket()方法以支持系统级统一OSS配置 // 验证源和目标不同 if (oldKey === newKey) { console.log(`[OSS存储] 源路径和目标路径相同,跳过: ${oldKey}`); return; } let copySuccess = false; try { // 检查源文件是否存在 const statResult = await this.stat(oldPath); // 如果是目录,执行目录重命名 if (statResult.isDirectory) { await this._renameDirectory(oldPath, newPath); return; } // 使用 CopyObjectCommand 复制文件 // CopySource 格式:bucket/key,需要对 key 中的特殊字符进行编码 // 但保留路径分隔符(/)不编码 const encodedOldKey = oldKey.split('/').map(segment => encodeURIComponent(segment)).join('/'); const copySource = `${bucket}/${encodedOldKey}`; const copyCommand = new CopyObjectCommand({ Bucket: bucket, CopySource: copySource, Key: newKey }); await this.s3Client.send(copyCommand); copySuccess = true; // 复制成功后删除原文件(使用DeleteObjectCommand删除单个文件) const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); const deleteCommand = new DeleteObjectCommand({ Bucket: bucket, Key: oldKey }); await this.s3Client.send(deleteCommand); console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`); } catch (error) { console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message); // 如果复制成功但删除失败,尝试回滚(删除新复制的文件) if (copySuccess) { try { console.log(`[OSS存储] 尝试回滚:删除已复制的文件 ${newKey}`); const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); const deleteCommand = new DeleteObjectCommand({ Bucket: bucket, Key: newKey }); await this.s3Client.send(deleteCommand); console.log(`[OSS存储] 回滚成功:已删除 ${newKey}`); } catch (rollbackError) { console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`); } } // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } else if (error.name === 'NoSuchKey') { throw new Error('源文件不存在'); } throw new Error(`重命名文件失败: ${error.message}`); } } /** * 重命名目录(内部方法) * 通过遍历目录下所有对象,逐个复制到新位置后删除原对象 * 使用事务标记机制防止竞态条件 * @param {string} oldPath - 原目录路径 * @param {string} newPath - 新目录路径 * @private */ async _renameDirectory(oldPath, newPath) { const oldPrefix = this.getObjectKey(oldPath); const newPrefix = this.getObjectKey(newPath); const bucket = this.getBucket(); // 确保前缀以斜杠结尾 const oldPrefixWithSlash = oldPrefix.endsWith('/') ? oldPrefix : `${oldPrefix}/`; const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`; // 生成事务标记文件 const timestamp = Date.now(); const markerKey = `${this.prefix}.rename_pending_${timestamp}.json`; // 标记文件内容(用于恢复) const markerContent = JSON.stringify({ oldPrefix: oldPrefixWithSlash, newPrefix: newPrefixWithSlash, timestamp: timestamp, phase: 'copying' // 标记当前阶段:copying(复制中)、deleting(删除中) }); let continuationToken = null; let copiedKeys = []; let totalCount = 0; try { // 步骤1:创建事务标记文件(标识重命名操作开始) console.log(`[OSS存储] 创建重命名事务标记: ${markerKey}`); const putMarkerCommand = new PutObjectCommand({ Bucket: bucket, Key: markerKey, Body: markerContent, ContentType: 'application/json' }); await this.s3Client.send(putMarkerCommand); // 步骤2:复制所有对象到新位置 do { const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: oldPrefixWithSlash, ContinuationToken: continuationToken, MaxKeys: 1000 }); const listResponse = await this.s3Client.send(listCommand); continuationToken = listResponse.NextContinuationToken; if (listResponse.Contents && listResponse.Contents.length > 0) { for (const obj of listResponse.Contents) { // 计算新的 key(替换前缀) const newKey = newPrefixWithSlash + obj.Key.substring(oldPrefixWithSlash.length); // 复制对象 const encodedOldKey = obj.Key.split('/').map(segment => encodeURIComponent(segment)).join('/'); const copyCommand = new CopyObjectCommand({ Bucket: bucket, CopySource: `${bucket}/${encodedOldKey}`, Key: newKey }); await this.s3Client.send(copyCommand); copiedKeys.push({ oldKey: obj.Key, newKey }); totalCount++; } } } while (continuationToken); // 步骤3:更新标记文件状态为 deleting(复制完成,开始删除) const updatedMarkerContent = JSON.stringify({ oldPrefix: oldPrefixWithSlash, newPrefix: newPrefixWithSlash, timestamp: timestamp, phase: 'deleting' }); await this.s3Client.send(new PutObjectCommand({ Bucket: bucket, Key: markerKey, Body: updatedMarkerContent, ContentType: 'application/json' })); // 步骤4:删除所有原对象(批量删除) if (copiedKeys.length > 0) { for (let i = 0; i < copiedKeys.length; i += 1000) { const batch = copiedKeys.slice(i, i + 1000); const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: batch.map(item => ({ Key: item.oldKey })), Quiet: true } }); await this.s3Client.send(deleteCommand); } } // 步骤5:删除事务标记文件(操作完成) await this.s3Client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: markerKey }], Quiet: true } })); console.log(`[OSS存储] 重命名目录完成: ${oldPath} -> ${newPath} (${totalCount} 个对象)`); } catch (error) { // 如果出错,尝试回滚 console.error(`[OSS存储] 目录重命名失败: ${error.message}`); if (copiedKeys.length > 0) { console.warn(`[OSS存储] 尝试回滚已复制的 ${copiedKeys.length} 个对象...`); try { // 回滚:删除已复制的新对象 for (let i = 0; i < copiedKeys.length; i += 1000) { const batch = copiedKeys.slice(i, i + 1000); const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: batch.map(item => ({ Key: item.newKey })), Quiet: true } }); await this.s3Client.send(deleteCommand); } console.log(`[OSS存储] 回滚成功`); } catch (rollbackError) { console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`); } } // 清理事务标记文件(如果还存在) try { await this.s3Client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: markerKey }], Quiet: true } })); } catch (markerError) { // 忽略标记文件删除错误 } throw new Error(`重命名目录失败: ${error.message}`); } } /** * 获取文件信息 */ async stat(filePath) { const key = this.getObjectKey(filePath); const bucket = this.getBucket(); // 使用getBucket()方法以支持系统级统一OSS配置 try { const command = new HeadObjectCommand({ Bucket: bucket, Key: key }); const response = await this.s3Client.send(command); return { size: response.ContentLength || 0, modifyTime: response.LastModified ? response.LastModified.getTime() : Date.now(), isDirectory: false }; } catch (error) { if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { // 可能是目录,尝试列出前缀 const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: key.endsWith('/') ? key : key + '/', MaxKeys: 1 }); try { const listResponse = await this.s3Client.send(listCommand); if (listResponse.Contents && listResponse.Contents.length > 0) { return { isDirectory: true, size: 0, modifyTime: Date.now() }; } } catch (listError) { // 忽略列表错误 } } throw new Error(`对象不存在: ${key}`); } } /** * 创建文件读取流(异步方法) * @returns {Promise} 返回可读流 Promise */ async createReadStream(filePath) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; const command = new GetObjectCommand({ Bucket: bucket, Key: key }); try { const response = await this.s3Client.send(command); // AWS SDK v3 返回的 Body 是一个 IncomingMessage 类型的流 return response.Body; } catch (error) { console.error(`[OSS存储] 创建读取流失败: ${key}`, error.message); throw error; } } /** * 创建文件夹(通过创建空对象模拟) * OSS 中文件夹实际上是一个以斜杠结尾的空对象 */ async mkdir(dirPath) { try { const key = this.getObjectKey(dirPath); const bucket = this.getBucket(); // OSS 中文件夹通过以斜杠结尾的空对象模拟 const folderKey = key.endsWith('/') ? key : `${key}/`; const command = new PutObjectCommand({ Bucket: bucket, Key: folderKey, Body: '', // 空内容 ContentType: 'application/x-directory' }); await this.s3Client.send(command); console.log(`[OSS存储] 创建文件夹: ${folderKey}`); } catch (error) { console.error(`[OSS存储] 创建文件夹失败: ${dirPath}`, error.message); if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } throw new Error(`创建文件夹失败: ${error.message}`); } } /** * 获取签名下载 URL(用于分享链接,支持私有 bucket) * @param {string} filePath - 文件路径 * @param {number} expiresIn - 过期时间(秒),默认 3600 秒(1小时) * @returns {Promise} 签名 URL */ async getPresignedUrl(filePath, expiresIn = 3600) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; try { const command = new GetObjectCommand({ Bucket: bucket, Key: key }); // 使用 AWS SDK 的 getSignedUrl 生成真正的签名 URL const signedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: Math.min(expiresIn, 604800) // 最大 7 天 }); return signedUrl; } catch (error) { console.error(`[OSS存储] 生成签名 URL 失败: ${filePath}`, error.message); throw new Error(`生成签名 URL 失败: ${error.message}`); } } /** * 获取公开 URL(仅适用于公共读的 bucket) * @deprecated 建议使用 getPresignedUrl 代替 */ getPublicUrl(filePath) { const key = this.getObjectKey(filePath); const bucket = this.getBucket(); const provider = this.getProvider(); const region = this.s3Client.config.region; let baseUrl; if (provider === 'aliyun') { // 阿里云 OSS 公开 URL 格式 const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`; baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`; } else if (provider === 'tencent') { // 腾讯云 COS 公开 URL 格式 baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`; } else { // AWS S3 公开 URL 格式 baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`; } // 对 key 中的特殊字符进行 URL 编码,但保留路径分隔符 const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/'); return `${baseUrl}/${encodedKey}`; } /** * 获取上传签名 URL(用于前端直传) * @param {string} filePath - 文件路径 * @param {number} expiresIn - 过期时间(秒),默认 900 秒(15分钟) * @param {string} contentType - 文件 MIME 类型 * @returns {Promise} 签名 URL */ async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; try { const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }); const signedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: Math.min(expiresIn, 3600) // 上传 URL 最大 1 小时 }); return signedUrl; } catch (error) { console.error(`[OSS存储] 生成上传签名 URL 失败: ${filePath}`, error.message); throw new Error(`生成上传签名 URL 失败: ${error.message}`); } } /** * 检查文件或目录是否存在 * @param {string} filePath - 文件路径 * @returns {Promise} */ async exists(filePath) { try { await this.stat(filePath); return true; } catch (error) { return false; } } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } /** * 关闭连接(S3Client 无需显式关闭) */ async end() { this.s3Client = null; } } /** * 将流转换为 Buffer(辅助函数) */ async function streamToBuffer(stream) { return new Promise((resolve, reject) => { const chunks = []; stream.on('data', (chunk) => chunks.push(chunk)); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }); } module.exports = { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, // 导出共享的工具函数 formatOssError // 导出 OSS 错误格式化函数 };