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 } = 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]; } // ===== 统一存储接口 ===== /** * 存储接口工厂 * 根据用户的存储类型返回对应的存储客户端 */ 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 { 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}`); } } /** * 列出目录内容 */ async list(dirPath) { const fullPath = this.getFullPath(dirPath); // 确保目录存在 if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); return []; } const items = fs.readdirSync(fullPath, { withFileTypes: true }); return items.map(item => { const itemPath = path.join(fullPath, item.name); const stats = fs.statSync(itemPath); return { name: item.name, type: item.isDirectory() ? 'd' : '-', size: stats.size, modifyTime: stats.mtimeMs }; }); } /** * 上传文件 */ 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); const stats = fs.statSync(fullPath); if (stats.isDirectory()) { // 删除文件夹 - 递归删除 // 先计算文件夹内所有文件的总大小 const folderSize = this.calculateFolderSize(fullPath); // 删除文件夹及其内容 fs.rmSync(fullPath, { recursive: true, force: true }); // 更新已使用空间 if (folderSize > 0) { this.updateUsedSpace(-folderSize); } } else { // 删除文件 fs.unlinkSync(fullPath); // 更新已使用空间 this.updateUsedSpace(-stats.size); } } /** * 计算文件夹大小 */ 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; } /** * 重命名文件 */ async rename(oldPath, newPath) { const oldFullPath = this.getFullPath(oldPath); const newFullPath = this.getFullPath(newPath); // 确保新路径的目录存在 const newDir = path.dirname(newFullPath); if (!fs.existsSync(newDir)) { fs.mkdirSync(newDir, { recursive: true }); } fs.renameSync(oldFullPath, newFullPath); } /** * 获取文件信息 */ async stat(filePath) { const fullPath = this.getFullPath(filePath); return fs.statSync(fullPath); } /** * 创建文件读取流 */ createReadStream(filePath) { const fullPath = this.getFullPath(filePath); return fs.createReadStream(fullPath); } /** * 关闭连接(本地存储无需关闭) */ 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; } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } } // ===== OSS存储客户端 ===== /** * OSS 存储客户端(基于 S3 协议) * 支持阿里云 OSS、腾讯云 COS、AWS S3 */ class OssStorageClient { constructor(user) { this.user = user; this.s3Client = null; this.prefix = `user_${user.id}/`; // 用户隔离前缀 } /** * 验证 OSS 配置是否完整 * @throws {Error} 配置不完整时抛出错误 */ validateConfig() { const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = this.user; 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 配置 */ buildConfig() { // 先验证配置 this.validateConfig(); const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = this.user; // AWS S3 默认配置 let config = { region: oss_region || 'us-east-1', credentials: { accessKeyId: oss_access_key_id, secretAccessKey: oss_access_key_secret } }; // 阿里云 OSS if (oss_provider === 'aliyun') { // 规范化 region:确保格式为 oss-cn-xxx let region = oss_region || 'oss-cn-hangzhou'; if (!region.startsWith('oss-')) { region = 'oss-' + region; } config.region = region; if (!oss_endpoint) { // 默认 endpoint 格式:https://{region}.aliyuncs.com config.endpoint = `https://${region}.aliyuncs.com`; } else { // 确保 endpoint 以 https:// 或 http:// 开头 config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; } // 阿里云 OSS 使用 virtual-hosted-style,但需要设置 forcePathStyle 为 false config.forcePathStyle = false; } // 腾讯云 COS else if (oss_provider === 'tencent') { config.region = oss_region || 'ap-guangzhou'; if (!oss_endpoint) { // 默认 endpoint 格式:https://cos.{region}.myqcloud.com config.endpoint = `https://cos.${config.region}.myqcloud.com`; } else { config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; } // 腾讯云 COS 使用 virtual-hosted-style config.forcePathStyle = false; } // AWS S3 或其他兼容服务 else { if (oss_endpoint) { config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; // 自定义 endpoint(如 MinIO)通常需要 path-style config.forcePathStyle = true; } // AWS 使用默认 endpoint,无需额外配置 } return config; } /** * 连接 OSS 服务(初始化 S3 客户端) */ async connect() { try { const config = this.buildConfig(); this.s3Client = new S3Client(config); console.log(`[OSS存储] 已连接: ${this.user.oss_provider}, bucket: ${this.user.oss_bucket}`); return this; } catch (error) { console.error(`[OSS存储] 连接失败:`, error.message); throw new Error(`OSS 连接失败: ${error.message}`); } } /** * 获取对象的完整 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.user.oss_bucket; // 确保前缀以斜杠结尾(除非是根目录) 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) { let fileStream = null; try { const key = this.getObjectKey(remotePath); const bucket = this.user.oss_bucket; // 检查本地文件是否存在 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,请使用分片上传`); } // 创建文件读取流 fileStream = fs.createReadStream(localPath); // 处理流错误 fileStream.on('error', (err) => { console.error(`[OSS存储] 文件流读取错误: ${localPath}`, err.message); }); // 直接上传 const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: fileStream, ContentLength: fileSize // 明确指定内容长度,避免某些服务端问题 }); 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}`); } finally { // 确保流被正确关闭(无论成功还是失败) if (fileStream && !fileStream.destroyed) { fileStream.destroy(); } } } /** * 删除文件或文件夹 */ async delete(filePath) { try { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; // 检查是文件还是目录(忽略不存在的文件) let statResult; try { statResult = await this.stat(filePath); } catch (statError) { if (statError.message && statError.message.includes('不存在')) { console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`); return; // 文件不存在,直接返回 } throw statError; // 其他错误继续抛出 } 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) { // 批量删除当前批次的对象 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} 个对象)`); } } else { // 删除单个文件 const command = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: key }], Quiet: false } }); await this.s3Client.send(command); console.log(`[OSS存储] 删除文件: ${key}`); } } 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 不支持直接重命名,需要复制后删除) * 注意:此方法只支持单个文件的重命名,不支持目录 */ async rename(oldPath, newPath) { const oldKey = this.getObjectKey(oldPath); const newKey = this.getObjectKey(newPath); const bucket = this.user.oss_bucket; let copySuccess = false; // 验证源和目标不同 if (oldKey === newKey) { console.log(`[OSS存储] 源路径和目标路径相同,跳过: ${oldKey}`); return; } try { // 检查源文件是否存在 const statResult = await this.stat(oldPath); if (statResult.isDirectory) { throw new Error('不支持重命名目录,请使用移动操作'); } // 使用 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; // 复制成功后删除原文件 const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: oldKey }], Quiet: true } }); 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 deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: newKey }], Quiet: true } }); 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}`); } } /** * 获取文件信息 */ async stat(filePath) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; 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.user.oss_bucket; // 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.user.oss_bucket; const region = this.s3Client.config.region; let baseUrl; if (this.user.oss_provider === 'aliyun') { // 阿里云 OSS 公开 URL 格式 const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`; baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`; } else if (this.user.oss_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}`); } } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } /** * 关闭连接(S3Client 无需显式关闭) */ async end() { this.s3Client = null; } } module.exports = { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize // 导出共享的工具函数 };