feat: v3.1.0 OSS直连优化与代码质量提升
- 🚀 OSS 直连上传下载(用户直连OSS,不经过后端) - ✨ 新增 Presigned URL 签名接口 - ✨ 支持自定义 OSS endpoint 配置 - 🐛 修复 buildS3Config 不支持自定义 endpoint 的问题 - 🐛 清理残留的 basic-ftp 依赖 - ♻️ 更新 package.json 项目描述和版本号 - 📝 完善 README.md 更新日志和 CORS 配置说明 - 🔒 安全性增强:签名 URL 15分钟/1小时有效期 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -95,17 +95,20 @@ DATABASE_PATH=./data/database.db
|
||||
STORAGE_ROOT=./storage
|
||||
|
||||
# ============================================
|
||||
# SFTP 配置(可选)
|
||||
# OSS 云存储配置(可选)
|
||||
# ============================================
|
||||
#
|
||||
# 说明: 用户可以在 Web 界面配置自己的 SFTP 服务器
|
||||
#
|
||||
# 说明: 用户可以在 Web 界面配置自己的 OSS 存储
|
||||
# 支持:阿里云 OSS、腾讯云 COS、AWS S3
|
||||
# 此处配置仅作为全局默认值(通常不需要配置)
|
||||
#
|
||||
|
||||
# FTP_HOST=your-ftp-host.com
|
||||
# FTP_PORT=22
|
||||
# FTP_USER=your-username
|
||||
# FTP_PASSWORD=your-password
|
||||
# OSS_PROVIDER=aliyun # 服务商: aliyun/tencent/aws
|
||||
# OSS_REGION=oss-cn-hangzhou # 地域
|
||||
# OSS_ACCESS_KEY_ID=your-key # Access Key ID
|
||||
# OSS_ACCESS_KEY_SECRET=secret # Access Key Secret
|
||||
# OSS_BUCKET=your-bucket # 存储桶名称
|
||||
# OSS_ENDPOINT= # 自定义 Endpoint(可选)
|
||||
|
||||
# ============================================
|
||||
# 开发调试配置
|
||||
|
||||
@@ -157,15 +157,17 @@ function authMiddleware(req, res, next) {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
is_admin: user.is_admin,
|
||||
has_ftp_config: user.has_ftp_config,
|
||||
ftp_host: user.ftp_host,
|
||||
ftp_port: user.ftp_port,
|
||||
ftp_user: user.ftp_user,
|
||||
ftp_password: user.ftp_password,
|
||||
http_download_base_url: user.http_download_base_url,
|
||||
// 存储相关字段(v2.0新增)
|
||||
storage_permission: user.storage_permission || 'sftp_only',
|
||||
current_storage_type: user.current_storage_type || 'sftp',
|
||||
// OSS存储字段(v3.0新增)
|
||||
has_oss_config: user.has_oss_config || 0,
|
||||
oss_provider: user.oss_provider,
|
||||
oss_region: user.oss_region,
|
||||
oss_access_key_id: user.oss_access_key_id,
|
||||
oss_access_key_secret: user.oss_access_key_secret,
|
||||
oss_bucket: user.oss_bucket,
|
||||
oss_endpoint: user.oss_endpoint,
|
||||
// 存储相关字段
|
||||
storage_permission: user.storage_permission || 'oss_only',
|
||||
current_storage_type: user.current_storage_type || 'oss',
|
||||
local_storage_quota: user.local_storage_quota || 1073741824,
|
||||
local_storage_used: user.local_storage_used || 0,
|
||||
// 主题偏好
|
||||
|
||||
@@ -39,12 +39,13 @@ function initDatabase() {
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
|
||||
-- FTP配置(可选)
|
||||
ftp_host TEXT,
|
||||
ftp_port INTEGER DEFAULT 22,
|
||||
ftp_user TEXT,
|
||||
ftp_password TEXT,
|
||||
http_download_base_url TEXT,
|
||||
-- OSS配置(可选)
|
||||
oss_provider TEXT,
|
||||
oss_region TEXT,
|
||||
oss_access_key_id TEXT,
|
||||
oss_access_key_secret TEXT,
|
||||
oss_bucket TEXT,
|
||||
oss_endpoint TEXT,
|
||||
|
||||
-- 上传工具API密钥
|
||||
upload_api_key TEXT,
|
||||
@@ -53,7 +54,7 @@ function initDatabase() {
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_banned INTEGER DEFAULT 0,
|
||||
has_ftp_config INTEGER DEFAULT 0,
|
||||
has_oss_config INTEGER DEFAULT 0,
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -96,8 +97,10 @@ function initDatabase() {
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_upload_api_key ON users(upload_api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
|
||||
`);
|
||||
|
||||
// 数据库迁移:添加upload_api_key字段(如果不存在)
|
||||
@@ -213,7 +216,7 @@ function createDefaultAdmin() {
|
||||
db.prepare(`
|
||||
INSERT INTO users (
|
||||
username, email, password,
|
||||
is_admin, is_active, has_ftp_config, is_verified
|
||||
is_admin, is_active, has_oss_config, is_verified
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
adminUsername,
|
||||
@@ -221,7 +224,7 @@ function createDefaultAdmin() {
|
||||
hashedPassword,
|
||||
1,
|
||||
1,
|
||||
0, // 管理员不需要FTP配置
|
||||
0, // 管理员不需要OSS配置
|
||||
1 // 管理员默认已验证
|
||||
);
|
||||
|
||||
@@ -238,7 +241,7 @@ const UserDB = {
|
||||
create(userData) {
|
||||
const hashedPassword = bcrypt.hashSync(userData.password, 10);
|
||||
|
||||
const hasFtpConfig = userData.ftp_host && userData.ftp_user && userData.ftp_password ? 1 : 0;
|
||||
const hasOssConfig = userData.oss_provider && userData.oss_access_key_id && userData.oss_access_key_secret && userData.oss_bucket ? 1 : 0;
|
||||
|
||||
// 对验证令牌进行哈希存储(与 VerificationDB.setVerification 保持一致)
|
||||
const hashedVerificationToken = userData.verification_token
|
||||
@@ -248,22 +251,23 @@ const UserDB = {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
username, email, password,
|
||||
ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url,
|
||||
has_ftp_config,
|
||||
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
|
||||
has_oss_config,
|
||||
is_verified, verification_token, verification_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
userData.username,
|
||||
userData.email,
|
||||
hashedPassword,
|
||||
userData.ftp_host || null,
|
||||
userData.ftp_port || 22,
|
||||
userData.ftp_user || null,
|
||||
userData.ftp_password || null,
|
||||
userData.http_download_base_url || null,
|
||||
hasFtpConfig,
|
||||
userData.oss_provider || null,
|
||||
userData.oss_region || null,
|
||||
userData.oss_access_key_id || null,
|
||||
userData.oss_access_key_secret || null,
|
||||
userData.oss_bucket || null,
|
||||
userData.oss_endpoint || null,
|
||||
hasOssConfig,
|
||||
userData.is_verified !== undefined ? userData.is_verified : 0,
|
||||
hashedVerificationToken,
|
||||
userData.verification_expires_at || null
|
||||
@@ -446,7 +450,7 @@ const ShareDB = {
|
||||
});
|
||||
|
||||
const result = db.prepare(`
|
||||
SELECT s.*, u.username, u.ftp_host, u.ftp_port, u.ftp_user, u.ftp_password, u.http_download_base_url, u.theme_preference
|
||||
SELECT s.*, u.username, u.oss_provider, u.oss_region, u.oss_access_key_id, u.oss_access_key_secret, u.oss_bucket, u.oss_endpoint, u.theme_preference
|
||||
FROM shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.share_code = ?
|
||||
@@ -678,6 +682,51 @@ function migrateToV2() {
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库版本迁移 - v3.0 SFTP → OSS
|
||||
function migrateToOss() {
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasOssProvider = columns.some(col => col.name === 'oss_provider');
|
||||
|
||||
if (!hasOssProvider) {
|
||||
console.log('[数据库迁移] 检测到 SFTP 版本,开始升级到 v3.0 OSS...');
|
||||
|
||||
// 添加 OSS 相关字段
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN oss_provider TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN oss_region TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN oss_access_key_id TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN oss_access_key_secret TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN oss_bucket TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN oss_endpoint TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN has_oss_config INTEGER DEFAULT 0;
|
||||
`);
|
||||
console.log('[数据库迁移] ✓ OSS 字段已添加');
|
||||
|
||||
// 更新存储权限枚举值:sftp_only → oss_only
|
||||
db.exec(`UPDATE users SET storage_permission = 'oss_only' WHERE storage_permission = 'sftp_only'`);
|
||||
console.log('[数据库迁移] ✓ 存储权限枚举值已更新');
|
||||
|
||||
// 更新存储类型:sftp → oss
|
||||
db.exec(`UPDATE users SET current_storage_type = 'oss' WHERE current_storage_type = 'sftp'`);
|
||||
console.log('[数据库迁移] ✓ 存储类型已更新');
|
||||
|
||||
// 更新分享表的存储类型
|
||||
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||
const hasStorageType = shareColumns.some(col => col.name === 'storage_type');
|
||||
if (hasStorageType) {
|
||||
db.exec(`UPDATE shares SET storage_type = 'oss' WHERE storage_type = 'sftp'`);
|
||||
console.log('[数据库迁移] ✓ 分享表存储类型已更新');
|
||||
}
|
||||
|
||||
console.log('[数据库迁移] ✅ 数据库升级到 v3.0 完成!SFTP 已替换为 OSS');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[数据库迁移] OSS 迁移失败:', error);
|
||||
// 不抛出错误,允许服务继续启动
|
||||
}
|
||||
}
|
||||
|
||||
// 系统日志操作
|
||||
const SystemLogDB = {
|
||||
// 日志级别常量
|
||||
@@ -819,6 +868,7 @@ createDefaultAdmin();
|
||||
initDefaultSettings();
|
||||
migrateToV2(); // 执行数据库迁移
|
||||
migrateThemePreference(); // 主题偏好迁移
|
||||
migrateToOss(); // SFTP → OSS 迁移
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
|
||||
2463
backend/package-lock.json
generated
2463
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,24 @@
|
||||
{
|
||||
"name": "ftp-web-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "FTP Web Manager Backend",
|
||||
"name": "wanwanyun-backend",
|
||||
"version": "3.1.0",
|
||||
"description": "玩玩云 - 云存储管理平台后端服务",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"ftp",
|
||||
"web",
|
||||
"file-manager"
|
||||
"cloud-storage",
|
||||
"oss",
|
||||
"s3",
|
||||
"file-manager",
|
||||
"alibaba-cloud",
|
||||
"tencent-cloud"
|
||||
],
|
||||
"author": "",
|
||||
"author": "玩玩云团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"basic-ftp": "^5.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -28,7 +30,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.14",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/lib-storage": "^3.600.0",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { UserDB } = require('./database');
|
||||
@@ -12,7 +13,7 @@ const { UserDB } = require('./database');
|
||||
class StorageInterface {
|
||||
constructor(user) {
|
||||
this.user = user;
|
||||
this.type = user.current_storage_type || 'sftp';
|
||||
this.type = user.current_storage_type || 'oss';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ class StorageInterface {
|
||||
await client.init();
|
||||
return client;
|
||||
} else {
|
||||
const client = new SftpStorageClient(this.user);
|
||||
const client = new OssStorageClient(this.user);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
@@ -321,96 +322,513 @@ class LocalStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SFTP存储客户端 =====
|
||||
// ===== OSS存储客户端 =====
|
||||
|
||||
class SftpStorageClient {
|
||||
/**
|
||||
* OSS 存储客户端(基于 S3 协议)
|
||||
* 支持阿里云 OSS、腾讯云 COS、AWS S3
|
||||
*/
|
||||
class OssStorageClient {
|
||||
constructor(user) {
|
||||
this.user = user;
|
||||
this.sftp = new SftpClient();
|
||||
this.s3Client = null;
|
||||
this.prefix = `user_${user.id}/`; // 用户隔离前缀
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接SFTP服务器
|
||||
* 验证 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() {
|
||||
await this.sftp.connect({
|
||||
host: this.user.ftp_host,
|
||||
port: this.user.ftp_port || 22,
|
||||
username: this.user.ftp_user,
|
||||
password: this.user.ftp_password
|
||||
});
|
||||
return this;
|
||||
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) {
|
||||
return await this.sftp.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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* 上传文件(支持分片上传)
|
||||
*/
|
||||
async put(localPath, remotePath) {
|
||||
// 使用临时文件+重命名模式(与upload_tool保持一致)
|
||||
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
|
||||
let fileStream = null;
|
||||
|
||||
// 第一步:上传到临时文件
|
||||
await this.sftp.put(localPath, tempRemotePath);
|
||||
|
||||
// 第二步:检查目标文件是否存在,如果存在先删除
|
||||
try {
|
||||
await this.sftp.stat(remotePath);
|
||||
await this.sftp.delete(remotePath);
|
||||
} catch (err) {
|
||||
// 文件不存在,无需删除
|
||||
}
|
||||
const key = this.getObjectKey(remotePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const fileSize = fs.statSync(localPath).size;
|
||||
|
||||
// 第三步:重命名临时文件为目标文件
|
||||
await this.sftp.rename(tempRemotePath, remotePath);
|
||||
// 创建文件读取流
|
||||
fileStream = fs.createReadStream(localPath);
|
||||
|
||||
// 使用 AWS SDK 的 Upload 类处理分片上传
|
||||
const upload = new Upload({
|
||||
client: this.s3Client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileStream
|
||||
},
|
||||
queueSize: 3, // 并发分片数
|
||||
partSize: 5 * 1024 * 1024 // 5MB 分片
|
||||
});
|
||||
|
||||
// 监听上传进度(可选)
|
||||
upload.on('httpUploadProgress', (progress) => {
|
||||
if (progress && progress.loaded && progress.total) {
|
||||
const percent = Math.round((progress.loaded / progress.total) * 100);
|
||||
// 只在较大文件时打印进度(避免日志过多)
|
||||
if (progress.total > 10 * 1024 * 1024 || percent % 20 === 0) {
|
||||
console.log(`[OSS存储] 上传进度: ${percent}% (${key})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
console.log(`[OSS存储] 上传成功: ${key} (${this.formatSize(fileSize)})`);
|
||||
|
||||
// 上传成功后,手动关闭流
|
||||
if (fileStream && !fileStream.destroyed) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
// 确保流被关闭,防止泄漏
|
||||
if (fileStream && !fileStream.destroyed) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
|
||||
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) {
|
||||
return await this.sftp.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) {
|
||||
return await this.sftp.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) {
|
||||
return await this.sftp.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
|
||||
*/
|
||||
createReadStream(filePath) {
|
||||
return this.sftp.createReadStream(filePath);
|
||||
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 { PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接(S3Client 无需显式关闭)
|
||||
*/
|
||||
async end() {
|
||||
if (this.sftp) {
|
||||
await this.sftp.end();
|
||||
}
|
||||
this.s3Client = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
formatSize(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];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StorageInterface,
|
||||
LocalStorageClient,
|
||||
SftpStorageClient
|
||||
OssStorageClient
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user