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:
Claude Opus
2026-01-18 17:14:16 +08:00
parent 71c2c0465e
commit 0b0e5b9d7c
18 changed files with 3864 additions and 1644 deletions

View File

@@ -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
};