Files
vue-driven-cloud-storage/backend/storage.js
237899745 4350113979 fix: 修复配额说明重复和undefined问题
- 在editStorageForm中初始化oss_storage_quota_value和oss_quota_unit
- 删除重复的旧配额说明块,保留新的当前配额设置显示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:39:53 +08:00

1718 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
// 删除单个文件使用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<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 错误格式化函数
};