- LocalStorageClient 和 OssStorageClient 添加 formatSize() 方法 - 删除 mkdir() 中重复的 PutObjectCommand 导入 - 统一使用共享的 formatFileSize() 函数 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
818 lines
23 KiB
JavaScript
818 lines
23 KiB
JavaScript
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
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
|
||
},
|
||
// 设置超时时间
|
||
requestHandler: {
|
||
requestTimeout: 30000, // 30秒
|
||
httpsAgent: undefined // 可后续添加 keep-alive agent
|
||
}
|
||
};
|
||
|
||
// 阿里云 OSS
|
||
if (oss_provider === 'aliyun') {
|
||
config.region = oss_region || 'oss-cn-hangzhou';
|
||
if (!oss_endpoint) {
|
||
// 默认 endpoint 格式:https://oss-{region}.aliyuncs.com
|
||
config.endpoint = `https://oss-${config.region.replace('oss-', '')}.aliyuncs.com`;
|
||
} else {
|
||
config.endpoint = oss_endpoint;
|
||
}
|
||
}
|
||
// 腾讯云 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;
|
||
}
|
||
}
|
||
// AWS S3 或其他兼容服务
|
||
else {
|
||
if (oss_endpoint) {
|
||
config.endpoint = oss_endpoint;
|
||
}
|
||
// 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) {
|
||
// 规范化路径
|
||
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
|
||
|
||
// 移除开头的斜杠
|
||
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
||
normalized = normalized.substring(1);
|
||
}
|
||
|
||
// 空路径表示根目录
|
||
if (normalized === '' || normalized === '.') {
|
||
normalized = '';
|
||
}
|
||
|
||
// 拼接用户前缀
|
||
return normalized ? this.prefix + normalized : this.prefix;
|
||
}
|
||
|
||
/**
|
||
* 列出目录内容
|
||
*/
|
||
async list(dirPath) {
|
||
try {
|
||
const prefix = this.getObjectKey(dirPath);
|
||
const bucket = this.user.oss_bucket;
|
||
|
||
const command = new ListObjectsV2Command({
|
||
Bucket: bucket,
|
||
Prefix: prefix,
|
||
Delimiter: '/', // 使用分隔符模拟目录结构
|
||
MaxKeys: 1000
|
||
});
|
||
|
||
const response = await this.s3Client.send(command);
|
||
const items = [];
|
||
|
||
// 处理"子目录"(CommonPrefixes)
|
||
if (response.CommonPrefixes) {
|
||
for (const prefixObj of response.CommonPrefixes) {
|
||
const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, '');
|
||
if (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) {
|
||
items.push({
|
||
name: fileName,
|
||
type: '-',
|
||
size: obj.Size || 0,
|
||
modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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.user.oss_bucket;
|
||
const fileSize = fs.statSync(localPath).size;
|
||
|
||
// 创建文件读取流
|
||
const fileStream = fs.createReadStream(localPath);
|
||
|
||
// 直接上传(AWS S3 支持最大 5GB 的单文件上传)
|
||
const command = new PutObjectCommand({
|
||
Bucket: bucket,
|
||
Key: key,
|
||
Body: fileStream
|
||
});
|
||
|
||
await this.s3Client.send(command);
|
||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||
|
||
// 关闭流
|
||
if (!fileStream.destroyed) {
|
||
fileStream.destroy();
|
||
}
|
||
} 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 允许的最大大小');
|
||
}
|
||
throw new Error(`文件上传失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除文件或文件夹
|
||
*/
|
||
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) {
|
||
// 删除目录:列出所有对象并批量删除
|
||
const listCommand = new ListObjectsV2Command({
|
||
Bucket: bucket,
|
||
Prefix: key
|
||
});
|
||
|
||
const listResponse = await this.s3Client.send(listCommand);
|
||
|
||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||
// 分批删除(AWS S3 单次最多删除 1000 个对象)
|
||
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);
|
||
}
|
||
|
||
console.log(`[OSS存储] 删除目录: ${key} (${listResponse.Contents.length} 个对象)`);
|
||
}
|
||
} 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) {
|
||
try {
|
||
const oldKey = this.getObjectKey(oldPath);
|
||
const newKey = this.getObjectKey(newPath);
|
||
const bucket = this.user.oss_bucket;
|
||
|
||
// 先复制
|
||
const copyCommand = new PutObjectCommand({
|
||
Bucket: bucket,
|
||
Key: newKey,
|
||
CopySource: `${bucket}/${oldKey}`
|
||
});
|
||
|
||
await this.s3Client.send(copyCommand);
|
||
|
||
// 再删除原文件
|
||
await this.delete(oldPath);
|
||
|
||
console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`);
|
||
} catch (error) {
|
||
console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message);
|
||
|
||
// 判断错误类型并给出友好的错误信息
|
||
if (error.name === 'NoSuchBucket') {
|
||
throw new Error('OSS 存储桶不存在,请检查配置');
|
||
} else if (error.name === 'AccessDenied') {
|
||
throw new Error('OSS 访问被拒绝,请检查权限配置');
|
||
}
|
||
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<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.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(用于分享链接)
|
||
*/
|
||
getSignedUrl(filePath, expiresIn = 3600) {
|
||
const key = this.getObjectKey(filePath);
|
||
const bucket = this.user.oss_bucket;
|
||
|
||
// 简单的公开 URL 拼接(如果 bucket 是公共读)
|
||
const endpoint = this.s3Client.config.endpoint;
|
||
const region = this.s3Client.config.region;
|
||
|
||
let baseUrl;
|
||
if (endpoint) {
|
||
baseUrl = endpoint.href || endpoint.toString();
|
||
} else if (this.user.oss_provider === 'aliyun') {
|
||
baseUrl = `https://${bucket}.${this.user.oss_region || 'oss-cn-hangzhou'}.aliyuncs.com`;
|
||
} else if (this.user.oss_provider === 'tencent') {
|
||
baseUrl = `https://${bucket}.cos.${this.user.oss_region || 'ap-guangzhou'}.myqcloud.com`;
|
||
} else {
|
||
baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`;
|
||
}
|
||
|
||
return `${baseUrl}/${key}`;
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
*/
|
||
formatSize(bytes) {
|
||
return formatFileSize(bytes);
|
||
}
|
||
|
||
/**
|
||
* 关闭连接(S3Client 无需显式关闭)
|
||
*/
|
||
async end() {
|
||
this.s3Client = null;
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
StorageInterface,
|
||
LocalStorageClient,
|
||
OssStorageClient,
|
||
formatFileSize // 导出共享的工具函数
|
||
};
|