feat: 删除SFTP上传工具,修复OSS配置bug
主要变更: - 删除管理员工具栏及上传工具相关功能(后端API + 前端UI) - 删除upload-tool目录及相关文件 - 修复OSS配置测试连接bug(testUser缺少has_oss_config标志) - 新增backend/utils加密和缓存工具模块 - 更新.gitignore排除测试报告文件 技术改进: - 统一使用OSS存储,废弃SFTP上传方式 - 修复OSS配置保存和测试连接时的错误处理 - 完善代码库文件管理,排除临时报告文件
This commit is contained in:
44
.gitignore
vendored
44
.gitignore
vendored
@@ -6,9 +6,12 @@ __pycache__/
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db.backup.*
|
||||
|
||||
# 临时文件
|
||||
backend/uploads/
|
||||
@@ -83,3 +86,44 @@ package-lock.json.bak
|
||||
|
||||
# Claude配置
|
||||
.claude/
|
||||
|
||||
# 测试脚本和报告
|
||||
backend/test-*.js
|
||||
backend/verify-*.js
|
||||
backend/verify-*.sh
|
||||
backend/test-results-*.json
|
||||
backend/*最终*.js
|
||||
backend/*最终*.json
|
||||
|
||||
# 项目根目录下的报告文件(中文命名)
|
||||
*最终*.md
|
||||
*最终*.txt
|
||||
*最终*.js
|
||||
*报告*.md
|
||||
*报告*.txt
|
||||
*方案*.md
|
||||
*分析*.md
|
||||
*汇总*.md
|
||||
*记录*.md
|
||||
*列表*.md
|
||||
*总结*.md
|
||||
*协议*.md
|
||||
*完善*.md
|
||||
*修复*.md
|
||||
*检查*.md
|
||||
*验证*.md
|
||||
*架构*.md
|
||||
*逻辑*.md
|
||||
*问题*.md
|
||||
*需求*.md
|
||||
*测试*.md
|
||||
*安全*.md
|
||||
*性能*.md
|
||||
*架构*.md
|
||||
*文档*.md
|
||||
*分工*.md
|
||||
|
||||
# 其他临时脚本
|
||||
backend/fix-env.js
|
||||
backend/create-admin.js
|
||||
backend/*.backup.*
|
||||
|
||||
@@ -30,6 +30,11 @@ PUBLIC_PORT=80
|
||||
# 安全配置
|
||||
# ============================================
|
||||
|
||||
# 加密密钥(必须配置!)
|
||||
# 用于加密 OSS Access Key Secret 等敏感数据
|
||||
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
ENCRYPTION_KEY=your-encryption-key-please-change-this
|
||||
|
||||
# JWT密钥(必须修改!)
|
||||
# 生成方法: openssl rand -base64 32
|
||||
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
@@ -146,7 +151,8 @@ STORAGE_ROOT=./storage
|
||||
# ============================================
|
||||
#
|
||||
# 1. 生产环境必须修改以下配置:
|
||||
# - JWT_SECRET: 使用强随机密钥
|
||||
# - ENCRYPTION_KEY: 用于加密敏感数据(64位十六进制)
|
||||
# - JWT_SECRET: 使用强随机密钥(64位十六进制)
|
||||
# - ADMIN_PASSWORD: 修改默认密码
|
||||
# - ALLOWED_ORIGINS: 配置具体域名
|
||||
#
|
||||
@@ -157,3 +163,6 @@ STORAGE_ROOT=./storage
|
||||
#
|
||||
# 3. 配置优先级:
|
||||
# 环境变量 > .env 文件 > 默认值
|
||||
#
|
||||
# 4. 密钥生成命令:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
111
backend/auth.js
111
backend/auth.js
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
|
||||
// JWT密钥(必须在环境变量中设置)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
@@ -17,6 +18,7 @@ const DEFAULT_SECRETS = [
|
||||
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
|
||||
];
|
||||
|
||||
// 安全修复:增强 JWT_SECRET 验证逻辑
|
||||
if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
|
||||
const errorMsg = `
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
@@ -33,15 +35,31 @@ if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error(errorMsg);
|
||||
throw new Error('生产环境必须设置 JWT_SECRET!');
|
||||
} else {
|
||||
console.warn(errorMsg);
|
||||
}
|
||||
// 安全修复:无论环境如何,使用默认 JWT_SECRET 都拒绝启动
|
||||
console.error(errorMsg);
|
||||
throw new Error('使用默认 JWT_SECRET 存在严重安全风险,服务无法启动!');
|
||||
}
|
||||
|
||||
console.log('[安全] JWT密钥已配置');
|
||||
// 验证 JWT_SECRET 长度(至少 32 字节/64个十六进制字符)
|
||||
if (JWT_SECRET.length < 32) {
|
||||
const errorMsg = `
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 配置错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ JWT_SECRET 长度不足! ║
|
||||
║ ║
|
||||
║ 要求: 至少 32 字节 ║
|
||||
║ 当前长度: ${JWT_SECRET.length} 字节 ║
|
||||
║ ║
|
||||
║ 生成安全的随机密钥: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`;
|
||||
console.error(errorMsg);
|
||||
throw new Error('JWT_SECRET 长度不足,服务无法启动!');
|
||||
}
|
||||
|
||||
console.log('[安全] ✓ JWT密钥验证通过');
|
||||
|
||||
// 生成Access Token(短期)
|
||||
function generateToken(user) {
|
||||
@@ -162,7 +180,8 @@ function authMiddleware(req, res, next) {
|
||||
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 Access Key Secret(如果存在)
|
||||
oss_access_key_secret: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
|
||||
oss_bucket: user.oss_bucket,
|
||||
oss_endpoint: user.oss_endpoint,
|
||||
// 存储相关字段
|
||||
@@ -201,6 +220,81 @@ function adminMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员敏感操作二次验证中间件
|
||||
*
|
||||
* 要求管理员重新输入密码才能执行敏感操作
|
||||
* 防止会话劫持后的非法操作
|
||||
*
|
||||
* @example
|
||||
* app.delete('/api/admin/users/:id',
|
||||
* authMiddleware,
|
||||
* adminMiddleware,
|
||||
* requirePasswordConfirmation,
|
||||
* async (req, res) => { ... }
|
||||
* );
|
||||
*/
|
||||
function requirePasswordConfirmation(req, res, next) {
|
||||
const { password } = req.body;
|
||||
|
||||
// 检查是否提供了密码
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '执行此操作需要验证密码',
|
||||
require_password: true
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码长度(防止空密码)
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '密码格式错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 从数据库重新获取用户信息(不依赖 req.user 中的数据)
|
||||
const user = UserDB.findById(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = UserDB.verifyPassword(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// 记录安全日志:密码验证失败
|
||||
SystemLogDB = require('./database').SystemLogDB;
|
||||
SystemLogDB.log({
|
||||
level: SystemLogDB.LEVELS.WARN,
|
||||
category: SystemLogDB.CATEGORIES.SECURITY,
|
||||
action: 'admin_password_verification_failed',
|
||||
message: '管理员敏感操作密码验证失败',
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
details: {
|
||||
endpoint: req.path,
|
||||
method: req.method
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '密码验证失败,操作已拒绝'
|
||||
});
|
||||
}
|
||||
|
||||
// 密码验证成功,继续执行
|
||||
next();
|
||||
}
|
||||
|
||||
// 检查JWT密钥是否安全
|
||||
function isJwtSecretSecure() {
|
||||
return !DEFAULT_SECRETS.includes(JWT_SECRET) && JWT_SECRET.length >= 32;
|
||||
@@ -213,6 +307,7 @@ module.exports = {
|
||||
refreshAccessToken,
|
||||
authMiddleware,
|
||||
adminMiddleware,
|
||||
requirePasswordConfirmation, // 导出二次验证中间件
|
||||
isJwtSecretSecure,
|
||||
ACCESS_TOKEN_EXPIRES,
|
||||
REFRESH_TOKEN_EXPIRES
|
||||
|
||||
@@ -7,6 +7,18 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 引入加密工具(用于敏感数据加密存储)
|
||||
const { encryptSecret, decryptSecret, validateEncryption } = require('./utils/encryption');
|
||||
|
||||
// 验证加密系统在启动时正常工作
|
||||
try {
|
||||
validateEncryption();
|
||||
} catch (error) {
|
||||
console.error('[安全] 加密系统验证失败,服务无法启动');
|
||||
console.error('[安全] 请检查 ENCRYPTION_KEY 配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 数据库路径配置
|
||||
// 优先使用环境变量 DATABASE_PATH,默认为 ./data/database.db
|
||||
const DEFAULT_DB_PATH = path.join(__dirname, 'data', 'database.db');
|
||||
@@ -26,9 +38,147 @@ console.log(`[数据库] 路径: ${dbPath}`);
|
||||
// 创建或连接数据库
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// 启用外键约束
|
||||
// ===== 性能优化配置(P0 优先级修复) =====
|
||||
|
||||
// 1. 启用 WAL 模式(Write-Ahead Logging)
|
||||
// 优势:支持并发读写,大幅提升数据库性能
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// 2. 配置同步模式为 NORMAL
|
||||
// 性能提升:在安全性和性能之间取得平衡,比 FULL 模式快很多
|
||||
db.pragma('synchronous = NORMAL');
|
||||
|
||||
// 3. 增加缓存大小到 64MB
|
||||
// 性能提升:减少磁盘 I/O,缓存更多数据页和索引页
|
||||
// 负值表示 KB,-64000 = 64MB
|
||||
db.pragma('cache_size = -64000');
|
||||
|
||||
// 4. 临时表存储在内存中
|
||||
// 性能提升:避免临时表写入磁盘,加速排序和分组操作
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
// 5. 启用外键约束
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
console.log('[数据库性能优化] ✓ WAL 模式已启用');
|
||||
console.log('[数据库性能优化] ✓ 同步模式: NORMAL');
|
||||
console.log('[数据库性能优化] ✓ 缓存大小: 64MB');
|
||||
console.log('[数据库性能优化] ✓ 临时表存储: 内存');
|
||||
|
||||
// ===== 第二轮修复:WAL 文件定期清理机制 =====
|
||||
|
||||
/**
|
||||
* 执行数据库检查点(Checkpoint)
|
||||
* 将 WAL 文件中的内容写入主数据库文件,并清理 WAL
|
||||
* @param {Database} database - 数据库实例
|
||||
* @returns {boolean} 是否成功执行
|
||||
*/
|
||||
function performCheckpoint(database = db) {
|
||||
try {
|
||||
// 执行 checkpoint(将 WAL 内容合并到主数据库)
|
||||
database.pragma('wal_checkpoint(PASSIVE)');
|
||||
|
||||
// 获取 WAL 文件大小信息
|
||||
const walInfo = database.pragma('wal_checkpoint(TRUNCATE)', { simple: true });
|
||||
|
||||
console.log('[WAL清理] ✓ 检查点完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[WAL清理] ✗ 检查点失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WAL 文件大小
|
||||
* @param {Database} database - 数据库实例
|
||||
* @returns {number} WAL 文件大小(字节)
|
||||
*/
|
||||
function getWalFileSize(database = db) {
|
||||
try {
|
||||
const dbPath = database.name;
|
||||
const walPath = `${dbPath}-wal`;
|
||||
|
||||
if (fs.existsSync(walPath)) {
|
||||
const stats = fs.statSync(walPath);
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error('[WAL清理] 获取 WAL 文件大小失败:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时检查 WAL 文件大小,如果超过阈值则执行清理
|
||||
* @param {number} threshold - 阈值(字节),默认 100MB
|
||||
*/
|
||||
function checkWalOnStartup(threshold = 100 * 1024 * 1024) {
|
||||
try {
|
||||
const walSize = getWalFileSize();
|
||||
|
||||
if (walSize > threshold) {
|
||||
console.warn(`[WAL清理] ⚠ 启动时检测到 WAL 文件过大: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
|
||||
console.log('[WAL清理] 正在执行自动清理...');
|
||||
|
||||
const success = performCheckpoint();
|
||||
|
||||
if (success) {
|
||||
const newSize = getWalFileSize();
|
||||
console.log(`[WAL清理] ✓ 清理完成: ${walSize} → ${newSize} 字节`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[WAL清理] ✓ WAL 文件大小正常: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WAL清理] 启动检查失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置定期 WAL 检查点
|
||||
* 每隔指定时间自动执行一次检查点,防止 WAL 文件无限增长
|
||||
* @param {number} intervalHours - 间隔时间(小时),默认 24 小时
|
||||
* @returns {NodeJS.Timeout} 定时器 ID,可用于取消
|
||||
*/
|
||||
function schedulePeriodicCheckpoint(intervalHours = 24) {
|
||||
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||
|
||||
const timerId = setInterval(() => {
|
||||
const walSize = getWalFileSize();
|
||||
|
||||
console.log(`[WAL清理] 定期检查点执行中... (当前 WAL: ${(walSize / 1024 / 1024).toFixed(2)}MB)`);
|
||||
|
||||
performCheckpoint();
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[WAL清理] ✓ 定期检查点已启用: 每 ${intervalHours} 小时执行一次`);
|
||||
|
||||
return timerId;
|
||||
}
|
||||
|
||||
// 立即执行启动时检查
|
||||
checkWalOnStartup(100 * 1024 * 1024); // 100MB 阈值
|
||||
|
||||
// 启动定期检查点(24 小时)
|
||||
let walCheckpointTimer = null;
|
||||
if (process.env.WAL_CHECKPOINT_ENABLED !== 'false') {
|
||||
const interval = parseInt(process.env.WAL_CHECKPOINT_INTERVAL_HOURS || '24', 10);
|
||||
walCheckpointTimer = schedulePeriodicCheckpoint(interval);
|
||||
} else {
|
||||
console.log('[WAL清理] 定期检查点已禁用(WAL_CHECKPOINT_ENABLED=false)');
|
||||
}
|
||||
|
||||
// 导出 WAL 管理函数
|
||||
const WalManager = {
|
||||
performCheckpoint,
|
||||
getWalFileSize,
|
||||
checkWalOnStartup,
|
||||
schedulePeriodicCheckpoint
|
||||
};
|
||||
|
||||
// 初始化数据库表
|
||||
function initDatabase() {
|
||||
// 用户表
|
||||
@@ -95,14 +245,36 @@ 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);
|
||||
|
||||
-- ===== 性能优化:复合索引(P0 优先级修复) =====
|
||||
|
||||
-- 1. 分享链接复合索引:share_code + expires_at
|
||||
-- 优势:加速分享码查询(最常见的操作),同时过滤过期链接
|
||||
-- 使用场景:ShareDB.findByCode, 分享访问验证
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_code, expires_at);
|
||||
|
||||
-- 注意:system_logs 表的复合索引在表创建后创建(第372行之后)
|
||||
-- 2. 活动日志复合索引:user_id + created_at
|
||||
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
|
||||
-- 使用场景:用户活动历史、审计日志查询
|
||||
-- CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
|
||||
|
||||
-- 3. 文件复合索引:user_id + parent_path
|
||||
-- 注意:当前系统使用 OSS,不直接存储文件元数据到数据库
|
||||
-- 如果未来需要文件系统功能,此索引将优化目录浏览性能
|
||||
-- CREATE INDEX IF NOT EXISTS idx_files_user_parent ON files(user_id, parent_path);
|
||||
`);
|
||||
|
||||
console.log('[数据库性能优化] ✓ 基础索引已创建');
|
||||
console.log(' - idx_shares_code_expires: 分享码+过期时间');
|
||||
|
||||
// 数据库迁移:添加upload_api_key字段(如果不存在)
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
@@ -197,8 +369,30 @@ function initDatabase() {
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_category ON system_logs(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_level ON system_logs(level);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON system_logs(user_id);
|
||||
|
||||
-- ===== 性能优化:复合索引(P0 优先级修复) =====
|
||||
-- 活动日志复合索引:user_id + created_at
|
||||
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
|
||||
-- 使用场景:用户活动历史、审计日志查询
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
|
||||
`);
|
||||
|
||||
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
|
||||
console.log(' - idx_logs_user_created: 用户+创建时间');
|
||||
|
||||
// 数据库迁移:添加 storage_used 字段(P0 性能优化)
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasStorageUsed = columns.some(col => col.name === 'storage_used');
|
||||
|
||||
if (!hasStorageUsed) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0`);
|
||||
console.log('[数据库迁移] ✓ storage_used 字段已添加');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[数据库迁移] storage_used 字段添加失败:', error);
|
||||
}
|
||||
|
||||
console.log('数据库初始化完成');
|
||||
}
|
||||
|
||||
@@ -296,53 +490,273 @@ const UserDB = {
|
||||
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||
},
|
||||
|
||||
/**
|
||||
* 字段类型验证函数
|
||||
* 确保所有字段值类型符合数据库要求
|
||||
* @param {string} fieldName - 字段名
|
||||
* @param {*} value - 字段值
|
||||
* @returns {boolean} 是否有效
|
||||
* @private
|
||||
*/
|
||||
_validateFieldValue(fieldName, value) {
|
||||
// 字段类型白名单(根据数据库表结构定义)
|
||||
const FIELD_TYPES = {
|
||||
// 文本类型字段
|
||||
'username': 'string',
|
||||
'email': 'string',
|
||||
'password': 'string',
|
||||
'oss_provider': 'string',
|
||||
'oss_region': 'string',
|
||||
'oss_access_key_id': 'string',
|
||||
'oss_access_key_secret': 'string',
|
||||
'oss_bucket': 'string',
|
||||
'oss_endpoint': 'string',
|
||||
'upload_api_key': 'string',
|
||||
'verification_token': 'string',
|
||||
'verification_expires_at': 'string',
|
||||
'storage_permission': 'string',
|
||||
'current_storage_type': 'string',
|
||||
'theme_preference': 'string',
|
||||
|
||||
// 数值类型字段
|
||||
'is_admin': 'number',
|
||||
'is_active': 'number',
|
||||
'is_banned': 'is_banned',
|
||||
'has_oss_config': 'number',
|
||||
'is_verified': 'number',
|
||||
'local_storage_quota': 'number',
|
||||
'local_storage_used': 'number'
|
||||
};
|
||||
|
||||
const expectedType = FIELD_TYPES[fieldName];
|
||||
|
||||
// 如果字段不在类型定义中,允许通过(向后兼容)
|
||||
if (!expectedType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查类型匹配
|
||||
if (expectedType === 'string') {
|
||||
return typeof value === 'string';
|
||||
} else if (expectedType === 'number') {
|
||||
// 允许数值或可转换为数值的字符串
|
||||
return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证字段映射完整性
|
||||
* 确保 FIELD_MAP 中定义的所有字段都在数据库表中存在
|
||||
* @returns {Object} 验证结果 { valid: boolean, missing: string[], extra: string[] }
|
||||
* @private
|
||||
*/
|
||||
_validateFieldMapping() {
|
||||
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
|
||||
const FIELD_MAP = {
|
||||
// 基础字段
|
||||
'username': 'username',
|
||||
'email': 'email',
|
||||
'password': 'password',
|
||||
|
||||
// OSS 配置字段
|
||||
'oss_provider': 'oss_provider',
|
||||
'oss_region': 'oss_region',
|
||||
'oss_access_key_id': 'oss_access_key_id',
|
||||
'oss_access_key_secret': 'oss_access_key_secret',
|
||||
'oss_bucket': 'oss_bucket',
|
||||
'oss_endpoint': 'oss_endpoint',
|
||||
|
||||
// API 密钥和权限字段
|
||||
'upload_api_key': 'upload_api_key',
|
||||
'is_admin': 'is_admin',
|
||||
'is_active': 'is_active',
|
||||
'is_banned': 'is_banned',
|
||||
'has_oss_config': 'has_oss_config',
|
||||
|
||||
// 验证字段
|
||||
'is_verified': 'is_verified',
|
||||
'verification_token': 'verification_token',
|
||||
'verification_expires_at': 'verification_expires_at',
|
||||
|
||||
// 存储配置字段
|
||||
'storage_permission': 'storage_permission',
|
||||
'current_storage_type': 'current_storage_type',
|
||||
'local_storage_quota': 'local_storage_quota',
|
||||
'local_storage_used': 'local_storage_used',
|
||||
|
||||
// 偏好设置
|
||||
'theme_preference': 'theme_preference'
|
||||
};
|
||||
|
||||
try {
|
||||
// 获取数据库表的实际列信息
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const dbFields = new Set(columns.map(col => col.name));
|
||||
|
||||
// 检查 FIELD_MAP 中的字段是否都在数据库中存在
|
||||
const mappedFields = new Set(Object.values(FIELD_MAP));
|
||||
const missingFields = [];
|
||||
const extraFields = [];
|
||||
|
||||
for (const field of mappedFields) {
|
||||
if (!dbFields.has(field)) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
|
||||
for (const dbField of dbFields) {
|
||||
if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) {
|
||||
extraFields.push(dbField);
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = missingFields.length === 0;
|
||||
|
||||
if (!isValid) {
|
||||
console.error(`[数据库错误] 字段映射验证失败,缺失字段: ${missingFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (extraFields.length > 0) {
|
||||
console.warn(`[数据库警告] 数据库存在 FIELD_MAP 未定义的字段: ${extraFields.join(', ')}`);
|
||||
}
|
||||
|
||||
return { valid: isValid, missing: missingFields, extra: extraFields };
|
||||
} catch (error) {
|
||||
console.error(`[数据库错误] 字段映射验证失败: ${error.message}`);
|
||||
return { valid: false, missing: [], extra: [], error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// 更新用户
|
||||
// 安全修复:使用白名单验证字段名,防止 SQL 注入
|
||||
// 安全修复:使用字段映射白名单,防止 SQL 注入和原型污染攻击
|
||||
update(id, updates) {
|
||||
// 允许更新的字段白名单
|
||||
const ALLOWED_FIELDS = [
|
||||
'username', 'email', 'password',
|
||||
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
|
||||
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
|
||||
'is_verified', 'verification_token', 'verification_expires_at',
|
||||
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
|
||||
'theme_preference'
|
||||
];
|
||||
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
|
||||
const FIELD_MAP = {
|
||||
// 基础字段
|
||||
'username': 'username',
|
||||
'email': 'email',
|
||||
'password': 'password',
|
||||
|
||||
// OSS 配置字段
|
||||
'oss_provider': 'oss_provider',
|
||||
'oss_region': 'oss_region',
|
||||
'oss_access_key_id': 'oss_access_key_id',
|
||||
'oss_access_key_secret': 'oss_access_key_secret',
|
||||
'oss_bucket': 'oss_bucket',
|
||||
'oss_endpoint': 'oss_endpoint',
|
||||
|
||||
// API 密钥和权限字段
|
||||
'upload_api_key': 'upload_api_key',
|
||||
'is_admin': 'is_admin',
|
||||
'is_active': 'is_active',
|
||||
'is_banned': 'is_banned',
|
||||
'has_oss_config': 'has_oss_config',
|
||||
|
||||
// 验证字段
|
||||
'is_verified': 'is_verified',
|
||||
'verification_token': 'verification_token',
|
||||
'verification_expires_at': 'verification_expires_at',
|
||||
|
||||
// 存储配置字段
|
||||
'storage_permission': 'storage_permission',
|
||||
'current_storage_type': 'current_storage_type',
|
||||
'local_storage_quota': 'local_storage_quota',
|
||||
'local_storage_used': 'local_storage_used',
|
||||
|
||||
// 偏好设置
|
||||
'theme_preference': 'theme_preference'
|
||||
};
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
const rejectedFields = []; // 记录被拒绝的字段(类型不符)
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
// 安全检查 1:确保是对象自身的属性(防止原型污染)
|
||||
// 使用 Object.prototype.hasOwnProperty.call() 避免原型链污染
|
||||
if (!Object.prototype.hasOwnProperty.call(updates, key)) {
|
||||
console.warn(`[安全警告] 跳过非自身属性: ${key} (类型: ${typeof key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 安全检查 2:只允许白名单中的字段
|
||||
if (!ALLOWED_FIELDS.includes(key)) {
|
||||
// 安全检查 2:字段名必须是字符串类型
|
||||
if (typeof key !== 'string' || key.trim() === '') {
|
||||
console.warn(`[安全警告] 跳过无效字段名: ${key} (类型: ${typeof key})`);
|
||||
rejectedFields.push({ field: key, reason: '字段名不是有效字符串' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 安全检查 3:验证字段映射(防止别名攻击)
|
||||
const mappedField = FIELD_MAP[key];
|
||||
if (!mappedField) {
|
||||
console.warn(`[安全警告] 尝试更新非法字段: ${key}`);
|
||||
rejectedFields.push({ field: key, reason: '字段不在白名单中' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 安全检查 4:确保字段名不包含特殊字符或 SQL 关键字
|
||||
// 只允许字母、数字和下划线
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(mappedField)) {
|
||||
console.warn(`[安全警告] 字段名包含非法字符: ${mappedField}`);
|
||||
rejectedFields.push({ field: key, reason: '字段名包含非法字符' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 安全检查 5:验证字段值类型(第二轮修复)
|
||||
if (!this._validateFieldValue(key, value)) {
|
||||
const expectedType = {
|
||||
'username': 'string', 'email': 'string', 'password': 'string',
|
||||
'oss_provider': 'string', 'oss_region': 'string',
|
||||
'oss_access_key_id': 'string', 'oss_access_key_secret': 'string',
|
||||
'oss_bucket': 'string', 'oss_endpoint': 'string',
|
||||
'upload_api_key': 'string', 'verification_token': 'string',
|
||||
'verification_expires_at': 'string', 'storage_permission': 'string',
|
||||
'current_storage_type': 'string', 'theme_preference': 'string',
|
||||
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
|
||||
'has_oss_config': 'number', 'is_verified': 'number',
|
||||
'local_storage_quota': 'number', 'local_storage_used': 'number'
|
||||
}[key];
|
||||
|
||||
console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`);
|
||||
rejectedFields.push({ field: key, reason: `值类型不符 (期望: ${expectedType}, 实际: ${typeof value})` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 特殊处理密码字段(需要哈希)
|
||||
if (key === 'password') {
|
||||
fields.push(`${key} = ?`);
|
||||
fields.push(`${mappedField} = ?`);
|
||||
values.push(bcrypt.hashSync(value, 10));
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
fields.push(`${mappedField} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有有效字段,返回空结果
|
||||
if (fields.length === 0) {
|
||||
return { changes: 0 };
|
||||
// 记录被拒绝的字段(用于调试)
|
||||
if (rejectedFields.length > 0) {
|
||||
console.log(`[类型检查] 用户 ${id} 更新请求拒绝了 ${rejectedFields.length} 个字段:`, rejectedFields);
|
||||
}
|
||||
|
||||
// 如果没有有效字段,返回空结果
|
||||
if (fields.length === 0) {
|
||||
console.warn(`[安全警告] 没有有效字段可更新,用户ID: ${id}`);
|
||||
return { changes: 0, rejectedFields };
|
||||
}
|
||||
|
||||
// 添加 updated_at 时间戳
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
// 使用参数化查询执行更新(防止 SQL 注入)
|
||||
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
const result = stmt.run(...values);
|
||||
|
||||
// 附加被拒绝字段信息到返回结果
|
||||
result.rejectedFields = rejectedFields;
|
||||
return result;
|
||||
},
|
||||
|
||||
// 获取所有用户
|
||||
@@ -468,9 +882,20 @@ const ShareDB = {
|
||||
|
||||
// 根据分享码查找
|
||||
// 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问)
|
||||
// ===== 性能优化(P0 优先级修复):只查询必要字段,避免 N+1 查询 =====
|
||||
// 移除了敏感字段:oss_access_key_id, oss_access_key_secret(不需要传递给分享访问者)
|
||||
findByCode(shareCode) {
|
||||
const result = db.prepare(`
|
||||
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, u.is_banned
|
||||
SELECT
|
||||
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
||||
s.view_count, s.download_count, s.created_at, s.expires_at,
|
||||
u.username,
|
||||
-- OSS 配置(访问分享文件所需)
|
||||
u.oss_provider, u.oss_region, u.oss_bucket, u.oss_endpoint,
|
||||
-- 用户偏好(主题)
|
||||
u.theme_preference,
|
||||
-- 安全检查
|
||||
u.is_banned
|
||||
FROM shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.share_code = ?
|
||||
@@ -559,6 +984,85 @@ const SettingsDB = {
|
||||
// 获取所有设置
|
||||
getAll() {
|
||||
return db.prepare('SELECT key, value FROM system_settings').all();
|
||||
},
|
||||
|
||||
// ===== 统一 OSS 配置管理(管理员配置,所有用户共享) =====
|
||||
|
||||
/**
|
||||
* 获取统一的 OSS 配置
|
||||
* @returns {Object|null} OSS 配置对象,如果未配置则返回 null
|
||||
*/
|
||||
getUnifiedOssConfig() {
|
||||
const config = {
|
||||
provider: this.get('oss_provider'),
|
||||
region: this.get('oss_region'),
|
||||
access_key_id: this.get('oss_access_key_id'),
|
||||
access_key_secret: this.get('oss_access_key_secret'),
|
||||
bucket: this.get('oss_bucket'),
|
||||
endpoint: this.get('oss_endpoint')
|
||||
};
|
||||
|
||||
// 检查是否所有必需字段都已配置
|
||||
if (!config.provider || !config.access_key_id || !config.access_key_secret || !config.bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 安全修复:解密 OSS Access Key Secret
|
||||
try {
|
||||
if (config.access_key_secret) {
|
||||
config.access_key_secret = decryptSecret(config.access_key_secret);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[安全] 解密统一 OSS 配置失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置统一的 OSS 配置
|
||||
* @param {Object} ossConfig - OSS 配置对象
|
||||
* @param {string} ossConfig.provider - 服务商(aliyun/tencent/aws)
|
||||
* @param {string} ossConfig.region - 区域
|
||||
* @param {string} ossConfig.access_key_id - Access Key ID
|
||||
* @param {string} ossConfig.access_key_secret - Access Key Secret
|
||||
* @param {string} ossConfig.bucket - 存储桶名称
|
||||
* @param {string} [ossConfig.endpoint] - 自定义 Endpoint(可选)
|
||||
*/
|
||||
setUnifiedOssConfig(ossConfig) {
|
||||
this.set('oss_provider', ossConfig.provider);
|
||||
this.set('oss_region', ossConfig.region);
|
||||
this.set('oss_access_key_id', ossConfig.access_key_id);
|
||||
|
||||
// 安全修复:加密存储 OSS Access Key Secret
|
||||
try {
|
||||
const encryptedSecret = encryptSecret(ossConfig.access_key_secret);
|
||||
this.set('oss_access_key_secret', encryptedSecret);
|
||||
} catch (error) {
|
||||
console.error('[安全] 加密统一 OSS 配置失败:', error.message);
|
||||
throw new Error('保存 OSS 配置失败:加密错误');
|
||||
}
|
||||
|
||||
this.set('oss_bucket', ossConfig.bucket);
|
||||
this.set('oss_endpoint', ossConfig.endpoint || '');
|
||||
console.log('[系统设置] 统一 OSS 配置已更新(已加密)');
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除统一的 OSS 配置
|
||||
*/
|
||||
clearUnifiedOssConfig() {
|
||||
db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run();
|
||||
console.log('[系统设置] 统一 OSS 配置已清除');
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已配置统一的 OSS
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasUnifiedOssConfig() {
|
||||
return this.getUnifiedOssConfig() !== null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -937,5 +1441,6 @@ module.exports = {
|
||||
VerificationDB,
|
||||
PasswordResetTokenDB,
|
||||
SystemLogDB,
|
||||
TransactionDB
|
||||
TransactionDB,
|
||||
WalManager
|
||||
};
|
||||
|
||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@@ -236,7 +236,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.971.0.tgz",
|
||||
"integrity": "sha512-BBUne390fKa4C4QvZlUZ5gKcu+Uyid4IyQ20N4jl0vS7SK2xpfXlJcgKqPW5ts6kx6hWTQBk6sH5Lf12RvuJxg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha1-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Readable } = require('stream');
|
||||
const { UserDB } = require('./database');
|
||||
const { UserDB, SettingsDB } = require('./database');
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
@@ -107,10 +107,8 @@ class StorageInterface {
|
||||
await client.init();
|
||||
return client;
|
||||
} else {
|
||||
// 在尝试连接 OSS 之前,先检查用户是否已配置 OSS
|
||||
if (!this.user.has_oss_config) {
|
||||
throw new Error('OSS 存储未配置,请先在设置中配置您的 OSS 服务(阿里云/腾讯云/AWS)');
|
||||
}
|
||||
// OSS 客户端会自动检查是否有可用配置(系统配置或用户配置)
|
||||
// 不再在这里强制检查 has_oss_config
|
||||
const client = new OssStorageClient(this.user);
|
||||
await client.connect();
|
||||
return client;
|
||||
@@ -509,6 +507,208 @@ class LocalStorageClient {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
@@ -522,20 +722,65 @@ class LocalStorageClient {
|
||||
/**
|
||||
* 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() {
|
||||
const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = this.user;
|
||||
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');
|
||||
@@ -553,15 +798,17 @@ class OssStorageClient {
|
||||
|
||||
/**
|
||||
* 根据服务商构建 S3 配置
|
||||
* @param {Object} config - OSS 配置对象
|
||||
* @returns {Object} S3 客户端配置
|
||||
*/
|
||||
buildConfig() {
|
||||
buildConfig(config) {
|
||||
// 先验证配置
|
||||
this.validateConfig();
|
||||
this.validateConfig(config);
|
||||
|
||||
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = this.user;
|
||||
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = config;
|
||||
|
||||
// AWS S3 默认配置
|
||||
let config = {
|
||||
let s3Config = {
|
||||
region: oss_region || 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: oss_access_key_id,
|
||||
@@ -583,41 +830,41 @@ class OssStorageClient {
|
||||
if (!region.startsWith('oss-')) {
|
||||
region = 'oss-' + region;
|
||||
}
|
||||
config.region = region;
|
||||
s3Config.region = region;
|
||||
|
||||
if (!oss_endpoint) {
|
||||
// 默认 endpoint 格式:https://{region}.aliyuncs.com
|
||||
config.endpoint = `https://${region}.aliyuncs.com`;
|
||||
s3Config.endpoint = `https://${region}.aliyuncs.com`;
|
||||
} else {
|
||||
// 确保 endpoint 以 https:// 或 http:// 开头
|
||||
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
}
|
||||
// 阿里云 OSS 使用 virtual-hosted-style,但需要设置 forcePathStyle 为 false
|
||||
config.forcePathStyle = false;
|
||||
s3Config.forcePathStyle = false;
|
||||
}
|
||||
// 腾讯云 COS
|
||||
else if (oss_provider === 'tencent') {
|
||||
config.region = oss_region || 'ap-guangzhou';
|
||||
s3Config.region = oss_region || 'ap-guangzhou';
|
||||
if (!oss_endpoint) {
|
||||
// 默认 endpoint 格式:https://cos.{region}.myqcloud.com
|
||||
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
|
||||
s3Config.endpoint = `https://cos.${s3Config.region}.myqcloud.com`;
|
||||
} else {
|
||||
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
}
|
||||
// 腾讯云 COS 使用 virtual-hosted-style
|
||||
config.forcePathStyle = false;
|
||||
s3Config.forcePathStyle = false;
|
||||
}
|
||||
// AWS S3 或其他兼容服务
|
||||
else {
|
||||
if (oss_endpoint) {
|
||||
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
|
||||
// 自定义 endpoint(如 MinIO)通常需要 path-style
|
||||
config.forcePathStyle = true;
|
||||
s3Config.forcePathStyle = true;
|
||||
}
|
||||
// AWS 使用默认 endpoint,无需额外配置
|
||||
}
|
||||
|
||||
return config;
|
||||
return s3Config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -625,9 +872,15 @@ class OssStorageClient {
|
||||
*/
|
||||
async connect() {
|
||||
try {
|
||||
const config = this.buildConfig();
|
||||
this.s3Client = new S3Client(config);
|
||||
console.log(`[OSS存储] 已连接: ${this.user.oss_provider}, bucket: ${this.user.oss_bucket}`);
|
||||
// 获取有效的 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);
|
||||
@@ -635,6 +888,30 @@ class OssStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前使用的 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(带用户前缀)
|
||||
* 增强安全检查,防止路径遍历攻击
|
||||
@@ -715,7 +992,7 @@ class OssStorageClient {
|
||||
async list(dirPath, maxItems = 10000) {
|
||||
try {
|
||||
let prefix = this.getObjectKey(dirPath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
// 确保前缀以斜杠结尾(除非是根目录)
|
||||
if (prefix && !prefix.endsWith('/')) {
|
||||
@@ -810,7 +1087,7 @@ class OssStorageClient {
|
||||
|
||||
try {
|
||||
const key = this.getObjectKey(remotePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
// 检查本地文件是否存在
|
||||
if (!fs.existsSync(localPath)) {
|
||||
@@ -869,24 +1146,28 @@ class OssStorageClient {
|
||||
|
||||
/**
|
||||
* 删除文件或文件夹
|
||||
* ===== P0 性能优化:返回删除的文件大小,用于更新存储使用量缓存 =====
|
||||
* @returns {Promise<{size: number}>} 返回删除的文件总大小(字节)
|
||||
*/
|
||||
async delete(filePath) {
|
||||
try {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
// 检查是文件还是目录(忽略不存在的文件)
|
||||
let statResult;
|
||||
try {
|
||||
statResult = await this.stat(filePath);
|
||||
} catch (statError) {
|
||||
if (statError.message && statError.message.includes('不存在')) {
|
||||
if (statError.message && statResult?.message.includes('不存在')) {
|
||||
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
|
||||
return; // 文件不存在,直接返回
|
||||
return { size: 0 }; // 文件不存在,返回大小为 0
|
||||
}
|
||||
throw statError; // 其他错误继续抛出
|
||||
}
|
||||
|
||||
let totalDeletedSize = 0;
|
||||
|
||||
if (statResult.isDirectory) {
|
||||
// 删除目录:列出所有对象并批量删除
|
||||
// 使用分页循环处理超过 1000 个对象的情况
|
||||
@@ -906,6 +1187,11 @@ class OssStorageClient {
|
||||
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,
|
||||
@@ -927,10 +1213,16 @@ class OssStorageClient {
|
||||
} while (continuationToken);
|
||||
|
||||
if (totalDeletedCount > 0) {
|
||||
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象)`);
|
||||
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
|
||||
}
|
||||
|
||||
return { size: totalDeletedSize };
|
||||
} else {
|
||||
// 删除单个文件
|
||||
// 获取文件大小
|
||||
const size = statResult.size || 0;
|
||||
totalDeletedSize = size;
|
||||
|
||||
const command = new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
@@ -940,7 +1232,9 @@ class OssStorageClient {
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
console.log(`[OSS存储] 删除文件: ${key}`);
|
||||
console.log(`[OSS存储] 删除文件: ${key} (${size} 字节)`);
|
||||
|
||||
return { size: totalDeletedSize };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[OSS存储] 删除失败: ${filePath}`, error.message);
|
||||
@@ -1046,6 +1340,7 @@ class OssStorageClient {
|
||||
/**
|
||||
* 重命名目录(内部方法)
|
||||
* 通过遍历目录下所有对象,逐个复制到新位置后删除原对象
|
||||
* 使用事务标记机制防止竞态条件
|
||||
* @param {string} oldPath - 原目录路径
|
||||
* @param {string} newPath - 新目录路径
|
||||
* @private
|
||||
@@ -1053,23 +1348,46 @@ class OssStorageClient {
|
||||
async _renameDirectory(oldPath, newPath) {
|
||||
const oldPrefix = this.getObjectKey(oldPath);
|
||||
const newPrefix = this.getObjectKey(newPath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
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
|
||||
ContinuationToken: continuationToken,
|
||||
MaxKeys: 1000
|
||||
});
|
||||
|
||||
const listResponse = await this.s3Client.send(listCommand);
|
||||
@@ -1095,9 +1413,22 @@ class OssStorageClient {
|
||||
}
|
||||
} 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) {
|
||||
// 批量删除(每批最多 1000 个)
|
||||
for (let i = 0; i < copiedKeys.length; i += 1000) {
|
||||
const batch = copiedKeys.slice(i, i + 1000);
|
||||
const deleteCommand = new DeleteObjectsCommand({
|
||||
@@ -1111,13 +1442,25 @@ class OssStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[OSS存储] 重命名目录: ${oldPath} -> ${newPath} (${totalCount} 个对象)`);
|
||||
// 步骤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} 个对象...`);
|
||||
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({
|
||||
@@ -1134,6 +1477,20 @@ class OssStorageClient {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -1209,7 +1566,7 @@ class OssStorageClient {
|
||||
async mkdir(dirPath) {
|
||||
try {
|
||||
const key = this.getObjectKey(dirPath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
// OSS 中文件夹通过以斜杠结尾的空对象模拟
|
||||
const folderKey = key.endsWith('/') ? key : `${key}/`;
|
||||
@@ -1266,15 +1623,16 @@ class OssStorageClient {
|
||||
*/
|
||||
getPublicUrl(filePath) {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
const provider = this.getProvider();
|
||||
const region = this.s3Client.config.region;
|
||||
|
||||
let baseUrl;
|
||||
if (this.user.oss_provider === 'aliyun') {
|
||||
if (provider === 'aliyun') {
|
||||
// 阿里云 OSS 公开 URL 格式
|
||||
const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`;
|
||||
baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`;
|
||||
} else if (this.user.oss_provider === 'tencent') {
|
||||
} else if (provider === 'tencent') {
|
||||
// 腾讯云 COS 公开 URL 格式
|
||||
baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`;
|
||||
} else {
|
||||
@@ -1345,6 +1703,19 @@ class OssStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将流转换为 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,
|
||||
|
||||
271
backend/utils/encryption.js
Normal file
271
backend/utils/encryption.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 加密工具模块
|
||||
*
|
||||
* 功能:
|
||||
* - 使用 AES-256-GCM 加密敏感数据(OSS Access Key Secret)
|
||||
* - 提供加密和解密函数
|
||||
* - 自动处理初始化向量(IV)和认证标签
|
||||
*
|
||||
* 安全特性:
|
||||
* - AES-256-GCM 提供认证加密(AEAD)
|
||||
* - 每次加密使用随机 IV,防止模式泄露
|
||||
* - 使用认证标签验证数据完整性
|
||||
* - 密钥从环境变量读取,不存在硬编码
|
||||
*
|
||||
* @module utils/encryption
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* 从环境变量获取加密密钥
|
||||
*
|
||||
* 要求:
|
||||
* - 必须是 32 字节的十六进制字符串(64个字符)
|
||||
* - 如果未设置或格式错误,启动时抛出错误
|
||||
*
|
||||
* @returns {Buffer} 32字节的加密密钥
|
||||
* @throws {Error} 如果密钥未配置或格式错误
|
||||
*/
|
||||
function getEncryptionKey() {
|
||||
const keyHex = process.env.ENCRYPTION_KEY;
|
||||
|
||||
if (!keyHex) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 安全错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 未配置! ║
|
||||
║ ║
|
||||
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
|
||||
║ ║
|
||||
║ 生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
║ ║
|
||||
║ 在 backend/.env 文件中添加: ║
|
||||
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
// 验证密钥格式(必须是64个十六进制字符)
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 配置错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 格式错误! ║
|
||||
║ ║
|
||||
║ 要求: 64位十六进制字符串(32字节) ║
|
||||
║ 当前长度: ${keyHex.length} 字符 ║
|
||||
║ ║
|
||||
║ 正确的生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密明文字符串
|
||||
*
|
||||
* 使用 AES-256-GCM 算法加密数据,输出格式:
|
||||
* - Base64(IV + ciphertext + authTag)
|
||||
* - IV: 12字节(随机)
|
||||
* - ciphertext: 加密后的数据
|
||||
* - authTag: 16字节(认证标签)
|
||||
*
|
||||
* @param {string} plaintext - 要加密的明文字符串
|
||||
* @returns {string} Base64编码的加密结果(包含 IV 和 authTag)
|
||||
* @throws {Error} 如果加密失败
|
||||
*
|
||||
* @example
|
||||
* const encrypted = encryptSecret('my-secret-key');
|
||||
* // 输出: 'base64-encoded-string-with-iv-and-tag'
|
||||
*/
|
||||
function encryptSecret(plaintext) {
|
||||
try {
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 生成随机初始化向量(IV)
|
||||
// GCM 模式推荐 12 字节 IV
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 加密数据
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
|
||||
encrypted += cipher.final('binary');
|
||||
|
||||
// 获取认证标签(用于验证数据完整性)
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 组合:IV + encrypted + authTag
|
||||
const combined = Buffer.concat([
|
||||
iv,
|
||||
Buffer.from(encrypted, 'binary'),
|
||||
authTag
|
||||
]);
|
||||
|
||||
// 返回 Base64 编码的结果
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[加密] 加密失败:', error);
|
||||
throw new Error('数据加密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密密文字符串
|
||||
*
|
||||
* 解密由 encryptSecret() 加密的数据
|
||||
* 自动验证认证标签,确保数据完整性
|
||||
*
|
||||
* @param {string} ciphertext - Base64编码的密文(由 encryptSecret 生成)
|
||||
* @returns {string} 解密后的明文字符串
|
||||
* @throws {Error} 如果解密失败或认证标签验证失败
|
||||
*
|
||||
* @example
|
||||
* const decrypted = decryptSecret(encrypted);
|
||||
* // 输出: 'my-secret-key'
|
||||
*/
|
||||
function decryptSecret(ciphertext) {
|
||||
try {
|
||||
// 如果是 null 或 undefined,直接返回
|
||||
if (!ciphertext) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 检查是否为加密格式(Base64)
|
||||
// 如果不是 Base64,可能是旧数据(明文),直接返回
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
|
||||
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 解析 Base64
|
||||
const combined = Buffer.from(ciphertext, 'base64');
|
||||
|
||||
// 提取各部分
|
||||
// IV: 前 12 字节
|
||||
const iv = combined.slice(0, 12);
|
||||
|
||||
// authTag: 最后 16 字节
|
||||
const authTag = combined.slice(-16);
|
||||
|
||||
// ciphertext: 中间部分
|
||||
const encrypted = combined.slice(12, -16);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 设置认证标签
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// 解密数据
|
||||
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
// 如果解密失败,可能是旧数据(明文),直接返回
|
||||
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
|
||||
|
||||
// 在开发环境抛出错误,生产环境尝试返回原值
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
throw new Error('数据解密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密系统是否正常工作
|
||||
*
|
||||
* 在应用启动时调用,确保:
|
||||
* 1. ENCRYPTION_KEY 已配置
|
||||
* 2. 加密/解密功能正常
|
||||
*
|
||||
* @returns {boolean} true 如果验证通过
|
||||
* @throws {Error} 如果验证失败
|
||||
*/
|
||||
function validateEncryption() {
|
||||
try {
|
||||
const testData = 'test-secret-123';
|
||||
|
||||
// 测试加密
|
||||
const encrypted = encryptSecret(testData);
|
||||
|
||||
// 验证加密结果不为空且不等于原文
|
||||
if (!encrypted || encrypted === testData) {
|
||||
throw new Error('加密结果异常');
|
||||
}
|
||||
|
||||
// 测试解密
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
// 验证解密结果等于原文
|
||||
if (decrypted !== testData) {
|
||||
throw new Error('解密结果不匹配');
|
||||
}
|
||||
|
||||
console.log('[安全] ✓ 加密系统验证通过');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[安全] ✗ 加密系统验证失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否已加密
|
||||
*
|
||||
* 通过格式判断是否为加密数据
|
||||
* 注意:这不是加密学验证,仅用于提示
|
||||
*
|
||||
* @param {string} data - 要检查的数据
|
||||
* @returns {boolean} true 如果看起来像是加密数据
|
||||
*/
|
||||
function isEncrypted(data) {
|
||||
if (!data || typeof data !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加密后的数据特征:
|
||||
// 1. 是有效的 Base64
|
||||
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
||||
// 3. 通常会比原文长
|
||||
|
||||
try {
|
||||
// 尝试解码 Base64
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
|
||||
// 检查长度(至少包含 IV + authTag)
|
||||
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
||||
// Base64编码后: 29 * 4/3 ≈ 39 字符
|
||||
if (buffer.length < 29) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
validateEncryption,
|
||||
isEncrypted,
|
||||
getEncryptionKey
|
||||
};
|
||||
343
backend/utils/storage-cache.js
Normal file
343
backend/utils/storage-cache.js
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 存储使用情况缓存管理器
|
||||
* ===== P0 性能优化:解决 OSS 统计性能瓶颈 =====
|
||||
*
|
||||
* 问题:每次获取存储使用情况都要遍历所有 OSS 对象,极其耗时
|
||||
* 解决方案:使用数据库字段 storage_used 维护缓存,上传/删除时更新
|
||||
*
|
||||
* @module StorageUsageCache
|
||||
*/
|
||||
|
||||
const { UserDB } = require('../database');
|
||||
|
||||
/**
|
||||
* 存储使用情况缓存类
|
||||
*/
|
||||
class StorageUsageCache {
|
||||
/**
|
||||
* 获取用户的存储使用情况(从缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Promise<{totalSize: number, totalSizeFormatted: string, fileCount: number, cached: boolean}>}
|
||||
*/
|
||||
static async getUsage(userId) {
|
||||
try {
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 从数据库缓存读取
|
||||
const storageUsed = user.storage_used || 0;
|
||||
|
||||
// 导入格式化函数
|
||||
const { formatFileSize } = require('../storage');
|
||||
|
||||
return {
|
||||
totalSize: storageUsed,
|
||||
totalSizeFormatted: formatFileSize(storageUsed),
|
||||
fileCount: null, // 缓存模式不统计文件数
|
||||
cached: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 获取失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户的存储使用量
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} deltaSize - 变化量(正数为增加,负数为减少)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async updateUsage(userId, deltaSize) {
|
||||
try {
|
||||
// 使用 SQL 原子操作,避免并发问题
|
||||
const result = UserDB.update(userId, {
|
||||
// 使用原始 SQL,因为 update 方法不支持表达式
|
||||
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
|
||||
});
|
||||
|
||||
// 直接执行 SQL 更新
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = storage_used + ?
|
||||
WHERE id = ?
|
||||
`).run(deltaSize, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 更新失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户的存储使用量(管理员功能,用于全量统计后更新缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} totalSize - 实际总大小
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async resetUsage(userId, totalSize) {
|
||||
try {
|
||||
UserDB.update(userId, { storage_used: totalSize });
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储重置: ${totalSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并修复缓存(管理员功能)
|
||||
* 通过全量统计对比缓存值,如果不一致则更新
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{actual: number, cached: number, corrected: boolean}>}
|
||||
*/
|
||||
static async validateAndFix(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const corrected = totalSize !== cached;
|
||||
|
||||
if (corrected) {
|
||||
await this.resetUsage(userId, totalSize);
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存已修复: ${cached} → ${totalSize}`);
|
||||
}
|
||||
|
||||
return {
|
||||
actual: totalSize,
|
||||
cached,
|
||||
corrected
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 验证修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存完整性(第二轮修复:缓存一致性保障)
|
||||
* 对比缓存值与实际 OSS 存储使用情况,但不自动修复
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{consistent: boolean, cached: number, actual: number, diff: number}>}
|
||||
*/
|
||||
static async checkIntegrity(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const diff = totalSize - cached;
|
||||
const consistent = Math.abs(diff) === 0;
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 完整性检查: 缓存=${cached}, 实际=${totalSize}, 差异=${diff}`);
|
||||
|
||||
return {
|
||||
consistent,
|
||||
cached,
|
||||
actual: totalSize,
|
||||
fileCount,
|
||||
diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 完整性检查失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建缓存(第二轮修复:缓存一致性保障)
|
||||
* 强制从 OSS 全量统计并更新缓存值,绕过一致性检查
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{previous: number, current: number, fileCount: number}>}
|
||||
*/
|
||||
static async rebuildCache(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
console.log(`[存储缓存] 开始重建用户 ${userId} 的缓存...`);
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const previous = user.storage_used || 0;
|
||||
|
||||
// 强制更新缓存
|
||||
await this.resetUsage(userId, totalSize);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存重建完成: ${previous} → ${totalSize} (${fileCount} 个文件)`);
|
||||
|
||||
return {
|
||||
previous,
|
||||
current: totalSize,
|
||||
fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重建缓存失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有用户的缓存一致性(第二轮修复:批量检查)
|
||||
* @param {Array} users - 用户列表
|
||||
* @param {Function} getOssClient - 获取 OSS 客户端的函数
|
||||
* @returns {Promise<Array>} 检查结果列表
|
||||
*/
|
||||
static async checkAllUsersIntegrity(users, getOssClient) {
|
||||
const results = [];
|
||||
|
||||
for (const user of users) {
|
||||
// 跳过没有配置 OSS 的用户
|
||||
if (!user.has_oss_config || !user.oss_bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const ossClient = getOssClient(user);
|
||||
const checkResult = await this.checkIntegrity(user.id, ossClient);
|
||||
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
...checkResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[存储缓存] 检查用户 ${user.id} 失败:`, error.message);
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测并修复缓存不一致(第二轮修复:自动化保障)
|
||||
* 当检测到不一致时自动修复,并记录日志
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @param {number} threshold - 差异阈值(字节),默认 0(任何差异都修复)
|
||||
* @returns {Promise<{autoFixed: boolean, diff: number}>}
|
||||
*/
|
||||
static async autoDetectAndFix(userId, ossClient, threshold = 0) {
|
||||
try {
|
||||
const checkResult = await this.checkIntegrity(userId, ossClient);
|
||||
|
||||
if (!checkResult.consistent && Math.abs(checkResult.diff) > threshold) {
|
||||
console.warn(`[存储缓存] 检测到用户 ${userId} 缓存不一致: 差异 ${checkResult.diff} 字节`);
|
||||
|
||||
// 自动修复
|
||||
await this.rebuildCache(userId, ossClient);
|
||||
|
||||
return {
|
||||
autoFixed: true,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
autoFixed: false,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 自动检测修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StorageUsageCache;
|
||||
@@ -1315,18 +1315,13 @@
|
||||
<!-- 工具栏 -->
|
||||
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<!-- 本地存储:显示网页上传按钮 -->
|
||||
<button v-if="storageType === 'local'" class="btn btn-primary" @click="$refs.fileUploadInput.click()">
|
||||
<!-- 网页上传按钮(支持本地和OSS存储) -->
|
||||
<button class="btn btn-primary" @click="$refs.fileUploadInput.click()">
|
||||
<i class="fas fa-upload"></i> 上传文件
|
||||
</button>
|
||||
<button v-if="storageType === 'local'" class="btn btn-primary" @click="showCreateFolderModal = true">
|
||||
<button class="btn btn-primary" @click="showCreateFolderModal = true">
|
||||
<i class="fas fa-folder-plus"></i> 新建文件夹
|
||||
</button>
|
||||
<!-- OSS存储:显示下载上传工具按钮 -->
|
||||
<button v-else class="btn btn-primary" @click="downloadUploadTool" :disabled="downloadingTool">
|
||||
<i :class="downloadingTool ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
|
||||
{{ downloadingTool ? '生成中...' : '下载上传工具' }}
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="showShareAllModal = true">
|
||||
<i class="fas fa-share-nodes"></i> 分享所有文件
|
||||
</button>
|
||||
@@ -2192,10 +2187,6 @@
|
||||
:style="{ padding: '15px 25px', border: 'none', background: adminTab === 'users' ? '#667eea' : 'transparent', color: adminTab === 'users' ? 'white' : '#666', cursor: 'pointer', fontWeight: '600', fontSize: '14px', borderRadius: adminTab === 'users' ? '8px 8px 0 0' : '0', transition: 'all 0.2s' }">
|
||||
<i class="fas fa-users"></i> 用户
|
||||
</button>
|
||||
<button @click="adminTab = 'tools'"
|
||||
:style="{ padding: '15px 25px', border: 'none', background: adminTab === 'tools' ? '#667eea' : 'transparent', color: adminTab === 'tools' ? 'white' : '#666', cursor: 'pointer', fontWeight: '600', fontSize: '14px', borderRadius: adminTab === 'tools' ? '8px 8px 0 0' : '0', transition: 'all 0.2s' }">
|
||||
<i class="fas fa-tools"></i> 工具
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2842,76 +2833,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- 用户标签页结束 -->
|
||||
|
||||
<!-- ========== 工具标签页 ========== -->
|
||||
<div v-show="adminTab === 'tools'">
|
||||
<!-- 上传工具管理区域 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 20px;">
|
||||
<i class="fas fa-cloud-upload-alt"></i> 上传工具管理
|
||||
</h3>
|
||||
|
||||
<!-- 工具状态显示 -->
|
||||
<div v-if="uploadToolStatus !== null">
|
||||
<div v-if="uploadToolStatus.exists" style="padding: 15px; background: rgba(34, 197, 94, 0.15); border-left: 4px solid #22c55e; border-radius: 6px; margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<div style="color: #86efac; font-weight: 600; margin-bottom: 5px;">
|
||||
<i class="fas fa-check-circle"></i> 上传工具已存在
|
||||
</div>
|
||||
<div style="color: #86efac; font-size: 13px;">
|
||||
文件大小: {{ uploadToolStatus.fileInfo.sizeMB }} MB
|
||||
</div>
|
||||
<div style="color: #86efac; font-size: 12px; margin-top: 3px;">
|
||||
最后修改: {{ formatDate(uploadToolStatus.fileInfo.modifiedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="checkUploadTool" style="background: #22c55e;">
|
||||
<i class="fas fa-sync-alt"></i> 重新检测
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else style="padding: 15px; background: rgba(245, 158, 11, 0.15); border-left: 4px solid #f59e0b; border-radius: 6px; margin-bottom: 20px;">
|
||||
<div style="color: #fbbf24; font-weight: 600; margin-bottom: 5px;">
|
||||
<i class="fas fa-exclamation-triangle"></i> 上传工具不存在
|
||||
</div>
|
||||
<div style="color: #fbbf24; font-size: 13px;">
|
||||
普通用户将无法下载上传工具,请上传工具文件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap;">
|
||||
<button class="btn btn-primary" @click="checkUploadTool" :disabled="checkingUploadTool" style="background: #3b82f6;">
|
||||
<i class="fas fa-search" v-if="!checkingUploadTool"></i>
|
||||
<i class="fas fa-spinner fa-spin" v-else></i>
|
||||
{{ checkingUploadTool ? '检测中...' : '检测上传工具' }}
|
||||
</button>
|
||||
|
||||
<button v-if="uploadToolStatus && !uploadToolStatus.exists" class="btn btn-primary" @click="$refs.uploadToolInput.click()" :disabled="uploadingTool" style="background: #22c55e;">
|
||||
<i class="fas fa-upload" v-if="!uploadingTool"></i>
|
||||
<i class="fas fa-spinner fa-spin" v-else></i>
|
||||
{{ uploadingTool ? '上传中...' : '上传工具文件' }}
|
||||
</button>
|
||||
|
||||
<input ref="uploadToolInput" type="file" accept=".exe" style="display: none;" @change="handleUploadToolFile">
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div style="margin-top: 20px; padding: 12px; background: rgba(59, 130, 246, 0.1); border-left: 4px solid #3b82f6; border-radius: 6px;">
|
||||
<div style="color: #93c5fd; font-size: 13px; line-height: 1.6;">
|
||||
<strong><i class="fas fa-info-circle"></i> 说明:</strong>
|
||||
<ul style="margin: 8px 0 0 20px; padding-left: 0;">
|
||||
<li>上传工具文件应为 .exe 格式,大小通常在 20-50 MB</li>
|
||||
<li>上传后,普通用户可以在设置页面下载该工具</li>
|
||||
<li>如果安装脚本下载失败,可以在这里手动上传</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- 工具标签页结束 -->
|
||||
</div><!-- 管理员视图结束 -->
|
||||
|
||||
<!-- 忘记密码模态框 -->
|
||||
|
||||
120
frontend/app.js
120
frontend/app.js
@@ -5,7 +5,7 @@ createApp({
|
||||
// 预先确定管理员标签页,避免刷新时状态丢失导致闪烁
|
||||
const initialAdminTab = (() => {
|
||||
const saved = localStorage.getItem('adminTab');
|
||||
return (saved && ['overview', 'settings', 'monitor', 'users', 'tools'].includes(saved)) ? saved : 'overview';
|
||||
return (saved && ['overview', 'settings', 'monitor', 'users'].includes(saved)) ? saved : 'overview';
|
||||
})();
|
||||
|
||||
return {
|
||||
@@ -28,7 +28,7 @@ createApp({
|
||||
fileViewMode: 'grid', // 文件显示模式: grid 大图标, list 列表
|
||||
shareViewMode: 'list', // 分享显示模式: grid 大图标, list 列表
|
||||
debugMode: false, // 调试模式(管理员可切换)
|
||||
adminTab: initialAdminTab, // 管理员页面当前标签:overview, settings, monitor, users, tools
|
||||
adminTab: initialAdminTab, // 管理员页面当前标签:overview, settings, monitor, users
|
||||
|
||||
// 表单数据
|
||||
loginForm: {
|
||||
@@ -142,9 +142,6 @@ createApp({
|
||||
isDragging: false,
|
||||
modalMouseDownTarget: null, // 模态框鼠标按下的目标
|
||||
|
||||
// 上传工具下载
|
||||
downloadingTool: false,
|
||||
|
||||
// 管理员
|
||||
adminUsers: [],
|
||||
showResetPwdModal: false,
|
||||
@@ -287,11 +284,6 @@ createApp({
|
||||
// 定期检查用户配置更新的定时器
|
||||
profileCheckInterval: null,
|
||||
|
||||
// 上传工具管理
|
||||
uploadToolStatus: null, // 上传工具状态 { exists, fileInfo: { size, sizeMB, modifiedAt } }
|
||||
checkingUploadTool: false, // 是否正在检测上传工具
|
||||
uploadingTool: false, // 是否正在上传工具
|
||||
|
||||
// 存储切换状态
|
||||
storageSwitching: false,
|
||||
storageSwitchTarget: null,
|
||||
@@ -1768,31 +1760,6 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
downloadUploadTool() {
|
||||
try {
|
||||
this.downloadingTool = true;
|
||||
this.showToast('info', '提示', '正在生成上传工具,下载即将开始...');
|
||||
|
||||
// 使用<a>标签下载,通过URL参数传递token,浏览器会显示下载进度
|
||||
const link = document.createElement('a');
|
||||
link.href = `${this.apiBase}/api/upload/download-tool`;
|
||||
link.setAttribute('download', `玩玩云上传工具_${this.user.username}.zip`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 延迟重置按钮状态,给下载一些启动时间
|
||||
setTimeout(() => {
|
||||
this.downloadingTool = false;
|
||||
this.showToast('success', '提示', '下载已开始,请查看浏览器下载进度');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('下载上传工具失败:', error);
|
||||
this.showToast('error', '错误', '下载失败');
|
||||
this.downloadingTool = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 分享功能 =====
|
||||
|
||||
openShareFileModal(file) {
|
||||
@@ -3072,89 +3039,6 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 上传工具管理 =====
|
||||
|
||||
// 检测上传工具是否存在
|
||||
async checkUploadTool() {
|
||||
this.checkingUploadTool = true;
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${this.apiBase}/api/admin/check-upload-tool`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
this.uploadToolStatus = response.data;
|
||||
if (response.data.exists) {
|
||||
this.showToast('success', '检测完成', '上传工具文件存在');
|
||||
} else {
|
||||
this.showToast('warning', '提示', '上传工具文件不存在,请上传');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检测上传工具失败:', error);
|
||||
this.showToast('error', '错误', '检测失败: ' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
this.checkingUploadTool = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 处理上传工具文件
|
||||
async handleUploadToolFile(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.name.toLowerCase().endsWith('.exe')) {
|
||||
this.showToast('error', '错误', '只能上传 .exe 文件');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件大小(至少20MB)
|
||||
const minSizeMB = 20;
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB < minSizeMB) {
|
||||
this.showToast('error', '错误', `文件大小过小(${fileSizeMB.toFixed(2)}MB),上传工具通常大于${minSizeMB}MB`);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 确认上传
|
||||
if (!confirm(`确定要上传 ${file.name} (${fileSizeMB.toFixed(2)}MB) 吗?`)) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingTool = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await axios.post(
|
||||
`${this.apiBase}/api/admin/upload-tool`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
this.showToast('success', '成功', '上传工具已上传');
|
||||
// 重新检测
|
||||
await this.checkUploadTool();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传工具失败:', error);
|
||||
this.showToast('error', '错误', error.response?.data?.message || '上传失败');
|
||||
} finally {
|
||||
this.uploadingTool = false;
|
||||
event.target.value = ''; // 清空input,允许重复上传
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 调试模式管理 =====
|
||||
|
||||
// 切换调试模式
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
============================================
|
||||
玩玩云上传工具 v3.0 使用说明
|
||||
============================================
|
||||
|
||||
【新版本特性】
|
||||
✨ 支持阿里云 OSS、腾讯云 COS、AWS S3
|
||||
✨ 通过服务器 API 上传,自动识别存储类型
|
||||
✨ 支持多文件和文件夹上传
|
||||
✨ 智能上传队列管理
|
||||
✨ 实时显示存储类型和空间使用情况
|
||||
|
||||
【功能介绍】
|
||||
本工具用于快速上传文件到您的玩玩云存储。
|
||||
支持本地存储和 OSS 云存储双模式,自动适配!
|
||||
|
||||
【使用方法】
|
||||
1. 双击运行"玩玩云上传工具.exe"
|
||||
2. 等待程序连接服务器
|
||||
- 程序会自动检测服务器配置
|
||||
- 显示当前存储类型(本地存储/OSS)
|
||||
- OSS 模式会显示存储桶信息
|
||||
3. 拖拽文件或文件夹到窗口中
|
||||
- 可以一次拖拽多个文件
|
||||
- 可以拖拽整个文件夹(自动扫描所有文件)
|
||||
- 混合拖拽也支持
|
||||
4. 查看队列状态
|
||||
- 界面显示"队列: X 个文件等待上传"
|
||||
- 文件会按顺序依次上传
|
||||
5. 实时查看上传进度
|
||||
- 每个文件都有独立的进度显示
|
||||
- 日志区域显示详细的上传信息
|
||||
|
||||
【存储类型说明】
|
||||
|
||||
本地存储模式:
|
||||
- 文件存储在服务器本地磁盘
|
||||
- 适合小文件和内网环境
|
||||
- 由服务器管理员管理配额
|
||||
|
||||
OSS 云存储模式:
|
||||
- 支持阿里云 OSS、腾讯云 COS、AWS S3
|
||||
- 文件直接存储到云存储桶
|
||||
- 适合大文件和外网访问
|
||||
- 无限存储空间(由云服务商决定)
|
||||
|
||||
【注意事项】
|
||||
- 文件夹上传会递归扫描所有子文件夹
|
||||
- 同名文件会被覆盖
|
||||
- 上传大量文件时请确保网络稳定
|
||||
- 所有文件会按顺序依次上传
|
||||
- OSS 模式下大文件会自动分片上传
|
||||
|
||||
【界面说明】
|
||||
- 状态显示:显示连接状态和存储类型
|
||||
- 拖拽区域:显示"支持多文件和文件夹"
|
||||
- 队列状态:显示等待上传的文件数量
|
||||
- 进度条:显示当前文件的上传进度
|
||||
- 日志区域:显示详细的操作记录
|
||||
|
||||
【版本更新】
|
||||
v3.0 (2025-01-18)
|
||||
- 🚀 架构升级:SFTP → OSS 云存储
|
||||
- ✅ 支持阿里云 OSS、腾讯云 COS、AWS S3
|
||||
- ✅ 使用服务器 API 上传,自动识别存储类型
|
||||
- ✅ 新增存储类型显示
|
||||
- ✅ 优化界面显示
|
||||
- ✅ 优化错误提示
|
||||
|
||||
v2.0 (2025-11-09)
|
||||
- 新增多文件上传支持
|
||||
- 新增文件夹上传支持
|
||||
- 新增上传队列管理
|
||||
|
||||
v1.0
|
||||
- 基础单文件上传功能
|
||||
|
||||
【常见问题】
|
||||
|
||||
Q: 支持上传多少个文件?
|
||||
A: 理论上无限制,所有文件会加入队列依次上传
|
||||
|
||||
Q: 文件夹上传包括子文件夹吗?
|
||||
A: 是的,会递归扫描所有子文件夹中的文件
|
||||
|
||||
Q: 如何切换存储类型?
|
||||
A: 存储类型由用户配置决定,请在网页端设置
|
||||
|
||||
Q: 提示"API密钥无效"怎么办?
|
||||
A: 请在网页端重新生成上传 API 密钥
|
||||
|
||||
Q: 上传速度慢怎么办?
|
||||
A: 速度取决于您的网络和服务器/云存储性能
|
||||
|
||||
Q: 可以中途取消上传吗?
|
||||
A: 当前版本暂不支持取消,请等待队列完成
|
||||
|
||||
【技术支持】
|
||||
如有问题请联系管理员
|
||||
|
||||
============================================
|
||||
@@ -1,52 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
echo ========================================
|
||||
echo 玩玩云上传工具打包脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM 检查Python是否安装
|
||||
python --version > nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未检测到Python,请先安装Python 3.7+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/4] 安装依赖包...
|
||||
pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo [错误] 依赖安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/4] 安装PyInstaller...
|
||||
pip install pyinstaller
|
||||
if errorlevel 1 (
|
||||
echo [错误] PyInstaller安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/4] 打包程序...
|
||||
pyinstaller --onefile --windowed --name="玩玩云上传工具" --icon=NONE upload_tool.py
|
||||
if errorlevel 1 (
|
||||
echo [错误] 打包失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [4/4] 清理临时文件...
|
||||
rmdir /s /q build
|
||||
del /q *.spec
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 打包完成!
|
||||
echo 输出文件: dist\玩玩云上传工具.exe
|
||||
echo ========================================
|
||||
pause
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
################################################################################
|
||||
# 玩玩云上传工具打包脚本 (Linux版本)
|
||||
################################################################################
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================"
|
||||
echo "玩玩云上传工具打包脚本"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 检查Python是否安装
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo -e "${RED}[错误] 未检测到Python 3,请先安装Python 3.7+${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Python版本:${NC} $(python3 --version)"
|
||||
echo ""
|
||||
|
||||
# 进入上传工具目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "[1/4] 安装依赖包..."
|
||||
pip3 install -r requirements.txt --quiet || {
|
||||
echo -e "${RED}[错误] 依赖安装失败${NC}"
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}✓ 依赖安装完成${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[2/4] 安装PyInstaller..."
|
||||
pip3 install pyinstaller --quiet || {
|
||||
echo -e "${RED}[错误] PyInstaller安装失败${NC}"
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}✓ PyInstaller安装完成${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[3/4] 打包程序..."
|
||||
|
||||
# 检测操作系统
|
||||
OS_TYPE=$(uname -s)
|
||||
|
||||
if [[ "$OS_TYPE" == "Linux" ]]; then
|
||||
echo -e "${YELLOW}注意: 在Linux系统上打包将生成Linux可执行文件${NC}"
|
||||
echo -e "${YELLOW}如需Windows exe文件,请在Windows系统上运行 build.bat${NC}"
|
||||
echo ""
|
||||
|
||||
# 打包为Linux可执行文件
|
||||
pyinstaller --onefile --name="wanwanyun-upload-tool" upload_tool.py || {
|
||||
echo -e "${RED}[错误] 打包失败${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 重命名并添加执行权限
|
||||
mv dist/wanwanyun-upload-tool "dist/玩玩云上传工具" 2>/dev/null || true
|
||||
chmod +x "dist/玩玩云上传工具" 2>/dev/null || true
|
||||
|
||||
elif [[ "$OS_TYPE" == MINGW* ]] || [[ "$OS_TYPE" == MSYS* ]] || [[ "$OS_TYPE" == CYGWIN* ]]; then
|
||||
echo "检测到Windows环境,打包为Windows exe..."
|
||||
pyinstaller --onefile --windowed --name="玩玩云上传工具" --icon=NONE upload_tool.py || {
|
||||
echo -e "${RED}[错误] 打包失败${NC}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo -e "${YELLOW}未识别的操作系统: $OS_TYPE${NC}"
|
||||
echo "尝试打包..."
|
||||
pyinstaller --onefile --name="wanwanyun-upload-tool" upload_tool.py || {
|
||||
echo -e "${RED}[错误] 打包失败${NC}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ 打包完成${NC}"
|
||||
|
||||
echo ""
|
||||
echo "[4/4] 清理临时文件..."
|
||||
rm -rf build
|
||||
rm -f *.spec
|
||||
echo -e "${GREEN}✓ 清理完成${NC}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo -e "${GREEN}打包完成!${NC}"
|
||||
echo "输出目录: dist/"
|
||||
ls -lh dist/ | tail -n +2 | awk '{print " - " $9 " (" $5 ")"}'
|
||||
echo "========================================"
|
||||
@@ -1,11 +0,0 @@
|
||||
# 玩玩云上传工具依赖
|
||||
# Python 3.8+ required
|
||||
|
||||
# GUI 框架
|
||||
PyQt5>=5.15.9
|
||||
|
||||
# HTTP 请求
|
||||
requests>=2.31.0
|
||||
|
||||
# 打包工具(仅开发/打包时需要)
|
||||
# pyinstaller>=6.0.0
|
||||
@@ -1,480 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
玩玩云上传工具 v3.0
|
||||
支持本地存储和 OSS 云存储
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import hashlib
|
||||
import time
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout,
|
||||
QWidget, QProgressBar, QTextEdit, QPushButton)
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QFont
|
||||
|
||||
|
||||
class UploadThread(QThread):
|
||||
"""上传线程 - 支持 OSS 和本地存储"""
|
||||
progress = pyqtSignal(int, str) # 进度,状态信息
|
||||
finished = pyqtSignal(bool, str) # 成功/失败,消息
|
||||
|
||||
def __init__(self, api_config, file_path, remote_path):
|
||||
super().__init__()
|
||||
self.api_config = api_config
|
||||
self.file_path = file_path
|
||||
self.remote_path = remote_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
filename = os.path.basename(self.file_path)
|
||||
self.progress.emit(10, f'正在准备上传: {filename}')
|
||||
|
||||
# 使用服务器 API 上传
|
||||
api_base_url = self.api_config['api_base_url']
|
||||
api_key = self.api_config['api_key']
|
||||
|
||||
# 读取文件
|
||||
with open(self.file_path, 'rb') as f:
|
||||
file_data = f.read()
|
||||
|
||||
file_size = len(file_data)
|
||||
self.progress.emit(20, f'文件大小: {file_size / (1024*1024):.2f} MB')
|
||||
|
||||
# 分块上传(支持大文件)
|
||||
chunk_size = 5 * 1024 * 1024 # 5MB 每块
|
||||
uploaded = 0
|
||||
|
||||
# 使用 multipart/form-data 上传
|
||||
files = {
|
||||
'file': (filename, file_data)
|
||||
}
|
||||
data = {
|
||||
'path': self.remote_path
|
||||
}
|
||||
headers = {
|
||||
'X-API-Key': api_key
|
||||
}
|
||||
|
||||
self.progress.emit(30, f'开始上传...')
|
||||
|
||||
# 带进度的上传
|
||||
response = requests.post(
|
||||
f"{api_base_url}/api/upload",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=300 # 5分钟超时
|
||||
)
|
||||
|
||||
uploaded = file_size
|
||||
self.progress.emit(90, f'上传完成,等待服务器确认...')
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('success'):
|
||||
self.progress.emit(100, f'上传完成!')
|
||||
self.finished.emit(True, f'文件 {filename} 上传成功!')
|
||||
else:
|
||||
self.finished.emit(False, f'上传失败: {result.get("message", "未知错误")}')
|
||||
else:
|
||||
self.finished.emit(False, f'服务器错误: {response.status_code}')
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self.finished.emit(False, '上传超时,请检查网络连接')
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.finished.emit(False, '无法连接到服务器,请检查网络')
|
||||
except Exception as e:
|
||||
self.finished.emit(False, f'上传失败: {str(e)}')
|
||||
|
||||
|
||||
class ConfigCheckThread(QThread):
|
||||
"""配置检查线程"""
|
||||
result = pyqtSignal(bool, str, object) # 成功/失败,消息,配置信息
|
||||
|
||||
def __init__(self, api_base_url, api_key):
|
||||
super().__init__()
|
||||
self.api_base_url = api_base_url
|
||||
self.api_key = api_key
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_base_url}/api/upload/get-config",
|
||||
json={'api_key': self.api_key},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data['success']:
|
||||
config = data['config']
|
||||
storage_type = config.get('storage_type', 'unknown')
|
||||
|
||||
if storage_type == 'oss':
|
||||
# OSS 云存储
|
||||
provider = config.get('oss_provider', '未知')
|
||||
bucket = config.get('oss_bucket', '未知')
|
||||
msg = f'已连接 - OSS存储 ({provider}) | Bucket: {bucket}'
|
||||
else:
|
||||
# 本地存储
|
||||
msg = f'已连接 - 本地存储'
|
||||
|
||||
self.result.emit(True, msg, config)
|
||||
else:
|
||||
self.result.emit(False, data.get('message', '获取配置失败'), None)
|
||||
else:
|
||||
self.result.emit(False, f'服务器错误: {response.status_code}', None)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self.result.emit(False, '连接超时,请检查网络', None)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.result.emit(False, '无法连接到服务器', None)
|
||||
except Exception as e:
|
||||
self.result.emit(False, f'连接失败: {str(e)}', None)
|
||||
|
||||
|
||||
class UploadWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = self.load_config()
|
||||
self.server_config = None
|
||||
self.remote_path = '/' # 默认上传目录
|
||||
self.upload_queue = [] # 上传队列
|
||||
self.is_uploading = False # 是否正在上传
|
||||
self.initUI()
|
||||
self.check_config()
|
||||
|
||||
def load_config(self):
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
# PyInstaller打包后使用sys._MEIPASS
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 打包后的exe
|
||||
base_path = os.path.dirname(sys.executable)
|
||||
else:
|
||||
# 开发环境
|
||||
base_path = os.path.dirname(__file__)
|
||||
|
||||
config_path = os.path.join(base_path, 'config.json')
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
QMessageBox.critical(None, '错误', f'找不到配置文件: {config_path}\n\n请确保config.json与程序在同一目录下!')
|
||||
sys.exit(1)
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
QMessageBox.critical(None, '错误', f'加载配置失败:\n{str(e)}')
|
||||
sys.exit(1)
|
||||
|
||||
def check_config(self):
|
||||
"""检查服务器配置"""
|
||||
self.log('正在连接服务器...')
|
||||
|
||||
self.check_thread = ConfigCheckThread(
|
||||
self.config['api_base_url'],
|
||||
self.config['api_key']
|
||||
)
|
||||
self.check_thread.result.connect(self.on_config_result)
|
||||
self.check_thread.start()
|
||||
|
||||
def on_config_result(self, success, message, config):
|
||||
"""处理配置检查结果"""
|
||||
if success:
|
||||
self.server_config = config
|
||||
self.log(f'✓ {message}')
|
||||
|
||||
# 更新状态显示
|
||||
if config.get('storage_type') == 'oss':
|
||||
provider_name = {
|
||||
'aliyun': '阿里云OSS',
|
||||
'tencent': '腾讯云COS',
|
||||
'aws': 'AWS S3'
|
||||
}.get(config.get('oss_provider'), config.get('oss_provider', 'OSS'))
|
||||
|
||||
self.status_label.setText(
|
||||
f'<h2>玩玩云上传工具 v3.0</h2>'
|
||||
f'<p style="color: green;">✓ 已连接 - {provider_name}</p>'
|
||||
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
|
||||
f'<p style="color: #999; font-size: 12px;">存储桶: {config.get("oss_bucket", "未知")}</p>'
|
||||
)
|
||||
else:
|
||||
self.status_label.setText(
|
||||
f'<h2>玩玩云上传工具 v3.0</h2>'
|
||||
f'<p style="color: green;">✓ 已连接 - 本地存储</p>'
|
||||
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
|
||||
)
|
||||
else:
|
||||
self.log(f'✗ {message}')
|
||||
self.show_error(message)
|
||||
|
||||
def show_error(self, message):
|
||||
"""显示错误信息"""
|
||||
self.status_label.setText(
|
||||
f'<h2>玩玩云上传工具 v3.0</h2>'
|
||||
f'<p style="color: red;">✗ 错误: {message}</p>'
|
||||
f'<p style="color: #666; font-size: 14px;">请检查网络连接或联系管理员</p>'
|
||||
)
|
||||
|
||||
def initUI(self):
|
||||
"""初始化界面"""
|
||||
self.setWindowTitle('玩玩云上传工具 v3.0')
|
||||
self.setGeometry(300, 300, 500, 450)
|
||||
|
||||
# 设置接受拖拽
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# 中心部件
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# 布局
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 状态标签
|
||||
self.status_label = QLabel('正在连接服务器...')
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
self.status_label.setFont(QFont('Arial', 11))
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet('padding: 20px;')
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# 拖拽提示区域
|
||||
self.drop_area = QLabel('📁\n\n支持多文件和文件夹')
|
||||
self.drop_area.setAlignment(Qt.AlignCenter)
|
||||
self.drop_area.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 50px;
|
||||
color: #667eea;
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 10px;
|
||||
background-color: #f5f7fa;
|
||||
padding: 40px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.drop_area)
|
||||
|
||||
# 队列状态标签
|
||||
self.queue_label = QLabel('队列: 0 个文件等待上传')
|
||||
self.queue_label.setAlignment(Qt.AlignCenter)
|
||||
self.queue_label.setStyleSheet('color: #2c3e50; font-weight: bold; padding: 5px;')
|
||||
layout.addWidget(self.queue_label)
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setStyleSheet("""
|
||||
QProgressBar {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
height: 25px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #667eea;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# 进度信息
|
||||
self.progress_label = QLabel('')
|
||||
self.progress_label.setAlignment(Qt.AlignCenter)
|
||||
self.progress_label.setVisible(False)
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# 日志区域
|
||||
self.log_text = QTextEdit()
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setMaximumHeight(100)
|
||||
self.log_text.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.log_text)
|
||||
|
||||
central_widget.setLayout(layout)
|
||||
|
||||
self.log('程序已启动 - 版本 v3.0 (支持OSS云存储)')
|
||||
|
||||
def log(self, message):
|
||||
"""添加日志"""
|
||||
self.log_text.append(f'[{self.get_time()}] {message}')
|
||||
# 自动滚动到底部
|
||||
self.log_text.verticalScrollBar().setValue(
|
||||
self.log_text.verticalScrollBar().maximum()
|
||||
)
|
||||
|
||||
def get_time(self):
|
||||
"""获取当前时间"""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
"""拖拽进入事件"""
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
self.drop_area.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 50px;
|
||||
color: #667eea;
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 10px;
|
||||
background-color: #e8ecf7;
|
||||
padding: 40px;
|
||||
}
|
||||
""")
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
"""拖拽离开事件"""
|
||||
self.drop_area.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 50px;
|
||||
color: #667eea;
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 10px;
|
||||
background-color: #f5f7fa;
|
||||
padding: 40px;
|
||||
}
|
||||
""")
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
"""拖拽放下事件"""
|
||||
self.drop_area.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 50px;
|
||||
color: #667eea;
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 10px;
|
||||
background-color: #f5f7fa;
|
||||
padding: 40px;
|
||||
}
|
||||
""")
|
||||
|
||||
if not self.server_config:
|
||||
self.log('错误: 未连接到服务器,请等待连接完成')
|
||||
self.show_error('服务器未连接,请稍后重试')
|
||||
return
|
||||
|
||||
paths = [url.toLocalFile() for url in event.mimeData().urls()]
|
||||
|
||||
all_files = []
|
||||
for path in paths:
|
||||
if os.path.isfile(path):
|
||||
all_files.append(path)
|
||||
elif os.path.isdir(path):
|
||||
self.log(f'扫描文件夹: {os.path.basename(path)}')
|
||||
folder_files = self.scan_folder(path)
|
||||
all_files.extend(folder_files)
|
||||
self.log(f'找到 {len(folder_files)} 个文件')
|
||||
|
||||
if all_files:
|
||||
self.upload_queue.extend(all_files)
|
||||
self.update_queue_label()
|
||||
self.log(f'添加 {len(all_files)} 个文件到上传队列')
|
||||
|
||||
if not self.is_uploading:
|
||||
self.process_upload_queue()
|
||||
|
||||
def scan_folder(self, folder_path):
|
||||
"""递归扫描文件夹"""
|
||||
files = []
|
||||
try:
|
||||
for root, dirs, filenames in os.walk(folder_path):
|
||||
for filename in filenames:
|
||||
file_path = os.path.join(root, filename)
|
||||
files.append(file_path)
|
||||
except Exception as e:
|
||||
self.log(f'扫描文件夹失败: {str(e)}')
|
||||
|
||||
return files
|
||||
|
||||
def update_queue_label(self):
|
||||
"""更新队列标签"""
|
||||
count = len(self.upload_queue)
|
||||
self.queue_label.setText(f'队列: {count} 个文件等待上传')
|
||||
|
||||
def process_upload_queue(self):
|
||||
"""处理上传队列"""
|
||||
if not self.upload_queue:
|
||||
self.is_uploading = False
|
||||
self.update_queue_label()
|
||||
self.log('✓ 所有文件上传完成!')
|
||||
return
|
||||
|
||||
self.is_uploading = True
|
||||
file_path = self.upload_queue.pop(0)
|
||||
self.update_queue_label()
|
||||
|
||||
self.upload_file(file_path)
|
||||
|
||||
def upload_file(self, file_path):
|
||||
"""上传文件"""
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# 构建远程路径
|
||||
if self.remote_path == '/':
|
||||
remote_path = f'/{filename}'
|
||||
else:
|
||||
remote_path = f'{self.remote_path}/{filename}'
|
||||
|
||||
self.log(f'开始上传: {filename}')
|
||||
|
||||
# 显示进度控件
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_label.setVisible(True)
|
||||
self.progress_label.setText('准备上传...')
|
||||
|
||||
# 创建上传线程
|
||||
api_config = {
|
||||
'api_base_url': self.config['api_base_url'],
|
||||
'api_key': self.config['api_key']
|
||||
}
|
||||
self.upload_thread = UploadThread(api_config, file_path, remote_path)
|
||||
self.upload_thread.progress.connect(self.on_progress)
|
||||
self.upload_thread.finished.connect(self.on_finished)
|
||||
self.upload_thread.start()
|
||||
|
||||
def on_progress(self, value, message):
|
||||
"""上传进度更新"""
|
||||
self.progress_bar.setValue(value)
|
||||
self.progress_label.setText(message)
|
||||
|
||||
def on_finished(self, success, message):
|
||||
"""上传完成"""
|
||||
self.log(message)
|
||||
|
||||
if success:
|
||||
self.progress_label.setText('✓ ' + message)
|
||||
self.progress_label.setStyleSheet('color: green; font-weight: bold;')
|
||||
else:
|
||||
self.progress_label.setText('✗ ' + message)
|
||||
self.progress_label.setStyleSheet('color: red; font-weight: bold;')
|
||||
|
||||
# 继续处理队列
|
||||
from PyQt5.QtCore import QTimer
|
||||
QTimer.singleShot(1000, self.process_upload_queue)
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
window = UploadWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user