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:
Dev Team
2026-01-20 20:41:18 +08:00
parent 14be59be19
commit 53ca5e56e8
16 changed files with 2419 additions and 1148 deletions

44
.gitignore vendored
View File

@@ -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.*

View File

@@ -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'))"

View File

@@ -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') {
// 安全修复:无论环境如何,使用默认 JWT_SECRET 都拒绝启动
console.error(errorMsg);
throw new Error('生产环境必须设置 JWT_SECRET');
} else {
console.warn(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

View File

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

View File

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

View File

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

View 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;

View File

@@ -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><!-- 管理员视图结束 -->
<!-- 忘记密码模态框 -->

View File

@@ -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允许重复上传
}
},
// ===== 调试模式管理 =====
// 切换调试模式

View File

@@ -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: 当前版本暂不支持取消,请等待队列完成
【技术支持】
如有问题请联系管理员
============================================

View File

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

View File

@@ -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 "========================================"

View File

@@ -1,11 +0,0 @@
# 玩玩云上传工具依赖
# Python 3.8+ required
# GUI 框架
PyQt5>=5.15.9
# HTTP 请求
requests>=2.31.0
# 打包工具(仅开发/打包时需要)
# pyinstaller>=6.0.0

View File

@@ -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()