## 修复内容 ### 后端API修复(server.js) - 添加oss_config_source字段到登录响应,用于前端判断OSS直连上传 - 修复6个API未检查系统级统一OSS配置的问题: * upload-signature: 使用effectiveBucket支持系统配置 * upload-complete: 添加OSS配置安全检查 * oss-usage/oss-usage-full: 检查系统级配置 * switch-storage: 改进OSS配置检查逻辑 * 5个管理员API: storage-cache检查/重建/修复功能 ### 存储客户端修复(storage.js) - rename方法: 使用getBucket()支持系统级统一配置 - stat方法: 使用getBucket()替代user.oss_bucket - 重命名操作: 改用DeleteObjectCommand替代DeleteObjectsCommand * 修复阿里云OSS"Missing Some Required Arguments"错误 * 解决重命名后旧文件无法删除的问题 - put方法: 改用Buffer上传替代流式上传 * 避免AWS SDK的aws-chunked编码问题 * 提升阿里云OSS兼容性 - 添加阿里云OSS特定配置: * disableNormalizeBucketName: true * checksumValidation: false ### 存储缓存修复(utils/storage-cache.js) - resetUsage方法: 改用直接SQL更新,绕过UserDB字段白名单限制 * 修复缓存重建失败的问题 - 3个方法改用ossClient.getBucket(): * validateAndFix * checkIntegrity * rebuildCache - checkAllUsersIntegrity: 添加系统级配置检查 ### 前端修复(app.js) - 上传路由: 使用oss_config_source判断而非has_oss_config - 下载/预览: 统一使用oss_config_source - 确保系统级统一OSS用户可以直连上传/下载 ### 安装脚本优化(install.sh) - 清理并优化安装流程 ## 影响范围 **关键修复:** - ✅ 系统级统一OSS配置现在完全可用 - ✅ 文件重命名功能正常工作(旧文件会被正确删除) - ✅ 存储使用量缓存正确显示和更新 - ✅ 所有管理员功能支持系统级统一OSS - ✅ 上传完成API不再有安全漏洞 **修复的Bug数量:** 12个核心bug **修改的文件:** 6个 **代码行数:** +154 -264 ## 测试验证 - ✅ 用户2存储使用量: 143.79 MB(已重建缓存) - ✅ 文件重命名: 旧文件正确删除 - ✅ 管理员功能: 缓存检查/重建/修复正常 - ✅ 上传功能: 直连OSS,缓存正确更新 - ✅ 多用户: 用户3已激活并可正常使用
1720 lines
56 KiB
JavaScript
1720 lines
56 KiB
JavaScript
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<Array>} 文件列表
|
||
*/
|
||
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<Object>} 文件状态信息,包含 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<boolean>}
|
||
*/
|
||
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 {
|
||
// 删除单个文件
|
||
// 获取文件大小
|
||
const size = statResult.size || 0;
|
||
totalDeletedSize = size;
|
||
|
||
const command = new DeleteObjectsCommand({
|
||
Bucket: bucket,
|
||
Delete: {
|
||
Objects: [{ Key: key }],
|
||
Quiet: false
|
||
}
|
||
});
|
||
|
||
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<Readable>} 返回可读流 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<string>} 签名 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<string>} 签名 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<boolean>}
|
||
*/
|
||
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 错误格式化函数
|
||
};
|