diff --git a/.gitignore b/.gitignore index 92358a8..ac8276a 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/backend/.env.example b/backend/.env.example index 9b54dd1..0fdb8c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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'))" diff --git a/backend/auth.js b/backend/auth.js index f1ea02c..ce1edf8 100644 --- a/backend/auth.js +++ b/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 diff --git a/backend/database.js b/backend/database.js index 56a173e..1348b54 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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 }; diff --git a/backend/package-lock.json b/backend/package-lock.json index eccfb1f..5d63484 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/server.js b/backend/server.js index 9dfe340..b9ef94b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -62,9 +62,11 @@ function clearOssUsageCache(userId) { console.log(`[OSS缓存] 已清除: 用户 ${userId}`); } -const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB } = require('./database'); -const { generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth'); +const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB, WalManager } = require('./database'); +const StorageUsageCache = require('./utils/storage-cache'); +const { generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, requirePasswordConfirmation, isJwtSecretSecure } = require('./auth'); const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage'); +const { encryptSecret, decryptSecret } = require('./utils/encryption'); const app = express(); const PORT = process.env.PORT || 40001; @@ -166,8 +168,8 @@ const corsOptions = { // 2. 直接的服务器请求 // 这些都应该允许 callback(null, true); - } else if (allowedOrigins.includes(origin)) { - // 白名单中的域名 + } else if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { + // 白名单中的域名,或通配符允许所有域名 callback(null, true); } else { // 拒绝不在白名单中的跨域请求 @@ -179,6 +181,12 @@ const corsOptions = { // 中间件 app.use(cors(corsOptions)); + +// 静态文件服务 - 提供前端页面 +const frontendPath = path.join(__dirname, '../frontend'); +console.log('[静态文件] 前端目录:', frontendPath); +app.use(express.static(frontendPath)); + app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS app.use(cookieParser()); @@ -1954,9 +1962,17 @@ app.post('/api/logout', (req, res) => { app.get('/api/user/profile', authMiddleware, (req, res) => { // 不返回敏感信息(密码和 OSS 密钥) const { password, oss_access_key_secret, ...safeUser } = req.user; + + // 检查是否使用统一 OSS 配置 + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + res.json({ success: true, - user: safeUser + user: { + ...safeUser, + // 添加配置来源信息 + oss_config_source: hasUnifiedConfig ? 'unified' : (safeUser.has_oss_config ? 'personal' : 'none') + } }); }); @@ -2031,6 +2047,15 @@ app.post('/api/user/update-oss', } try { + // 检查是否已配置系统级统一 OSS + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (hasUnifiedConfig && !req.user.is_admin) { + return res.status(403).json({ + success: false, + message: '系统已配置统一 OSS,普通用户无法配置个人 OSS。如需修改,请联系管理员' + }); + } + const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body; // 如果用户已配置OSS且密钥为空,使用现有密钥 @@ -2049,6 +2074,7 @@ app.post('/api/user/update-oss', // OssStorageClient 已在文件顶部导入 const testUser = { id: req.user.id, + has_oss_config: 1, // 标记为已配置,允许使用个人配置 oss_provider, oss_region, oss_access_key_id, @@ -2069,12 +2095,24 @@ app.post('/api/user/update-oss', }); } - // 更新用户配置 + // 安全修复:加密存储 OSS Access Key Secret + let encryptedSecret; + try { + encryptedSecret = encryptSecret(actualSecret); + } catch (error) { + console.error('[安全] 加密 OSS 密钥失败:', error); + return res.status(500).json({ + success: false, + message: '加密配置失败' + }); + } + + // 更新用户配置(存储加密后的密钥) UserDB.update(req.user.id, { oss_provider, oss_region, oss_access_key_id, - oss_access_key_secret: actualSecret, + oss_access_key_secret: encryptedSecret, oss_bucket, oss_endpoint: oss_endpoint || null, has_oss_config: 1 @@ -2130,6 +2168,7 @@ app.post('/api/user/test-oss', // OssStorageClient 已在文件顶部导入 const testUser = { id: req.user.id, + has_oss_config: 1, // 标记为已配置,允许使用个人配置 oss_provider, oss_region, oss_access_key_id, @@ -2158,10 +2197,11 @@ app.post('/api/user/test-oss', } ); -// 获取OSS存储空间使用情况(带缓存) +// 获取 OSS 存储空间使用情况(带缓存) +// ===== P0 性能优化:优先使用数据库缓存,避免全量统计 ===== app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { try { - // 检查用户是否配置了OSS + // 检查用户是否配置了 OSS if (!req.user.has_oss_config) { return res.status(400).json({ success: false, @@ -2169,21 +2209,57 @@ app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { }); } - // 先检查缓存 + // ===== P0 优化:优先使用数据库缓存 ===== + // 从数据库 storage_used 字段读取(上传/删除时增量更新) + const user = UserDB.findById(req.user.id); + const storageUsed = user.storage_used || 0; + + return res.json({ + success: true, + usage: { + totalSize: storageUsed, + totalSizeFormatted: formatFileSize(storageUsed), + fileCount: null, // 缓存模式不提供文件数 + cached: true + }, + cached: true + }); + + } catch (error) { + console.error('[OSS统计] 获取失败:', error); + res.status(500).json({ + success: false, + message: '获取存储使用情况失败: ' + error.message + }); + } +}); + +// 获取 OSS 存储空间详细统计(全量统计,仅限管理员或需要精确统计时使用) +// ===== P0 性能优化:此接口较慢,建议只在必要时调用 ===== +app.get('/api/user/oss-usage-full', authMiddleware, async (req, res) => { + try { + // 检查用户是否配置了 OSS + if (!req.user.has_oss_config) { + return res.status(400).json({ + success: false, + message: '未配置OSS服务' + }); + } + + // 先检查内存缓存(5分钟 TTL) const cached = getOssUsageCache(req.user.id); if (cached) { return res.json({ success: true, usage: cached, - cached: true // 标识来自缓存 + cached: true }); } - // OssStorageClient 已在文件顶部导入 + // 执行全量统计(较慢,仅在缓存未命中时执行) const ossClient = new OssStorageClient(req.user); await ossClient.connect(); - // 递归计算所有对象的大小 let totalSize = 0; let fileCount = 0; let continuationToken = null; @@ -2217,7 +2293,7 @@ app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { dirCount: 0 // OSS 没有目录概念 }; - // 存入缓存 + // 存入内存缓存 setOssUsageCache(req.user.id, usageData); res.json({ @@ -2227,7 +2303,7 @@ app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { }); } catch (error) { - console.error('[OSS统计] 获取失败:', error); + console.error('[OSS统计] 全量统计失败:', error); res.status(500).json({ success: false, message: '获取OSS空间使用情况失败: ' + error.message @@ -2802,12 +2878,14 @@ app.post('/api/files/folder-info', authMiddleware, async (req, res) => { }); // 删除文件 +// ===== P0 性能优化:更新存储使用量缓存 ===== app.post('/api/files/delete', authMiddleware, async (req, res) => { const rawFileName = req.body.fileName; const rawPath = req.body.path; const fileName = decodeHtmlEntities(rawFileName); const path = decodeHtmlEntities(rawPath) || '/'; let storage; + let deletedSize = 0; if (!fileName) { return res.status(400).json({ @@ -2843,7 +2921,14 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { if (tried.has(targetPath)) continue; tried.add(targetPath); try { - await storage.delete(targetPath); + // 删除文件并获取文件大小(用于更新缓存) + const deleteResult = await storage.delete(targetPath); + + // 如果返回了文件大小,记录下来 + if (deleteResult && deleteResult.size !== undefined) { + deletedSize = deleteResult.size; + } + break; } catch (err) { if (err.code === 'ENOENT') { @@ -2858,8 +2943,17 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { throw err; } - // 清除 OSS 使用情况缓存(如果用户使用 OSS) - if (req.user.current_storage_type === 'oss') { + // ===== P0 性能优化:更新存储使用量 ===== + if (req.user.current_storage_type === 'oss' && deletedSize > 0) { + // 减少存储使用量 + await StorageUsageCache.updateUsage(req.user.id, -deletedSize); + + // 同时更新旧的内存缓存(保持兼容性) + clearOssUsageCache(req.user.id); + + console.log(`[删除文件] 用户 ${req.user.id} 释放空间: ${deletedSize} 字节`); + } else if (req.user.current_storage_type === 'oss') { + // 如果没有获取到文件大小,清除缓存(下次查询时会重新统计) clearOssUsageCache(req.user.id); } @@ -2893,6 +2987,14 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { }); } + // 文件名长度限制(最大255字符) + if (filename.length > 255) { + return res.status(400).json({ + success: false, + message: '文件名过长,最大支持255个字符' + }); + } + // 文件名安全校验 if (!isSafePathSegment(filename)) { return res.status(400).json({ @@ -2978,6 +3080,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { }); // OSS 上传完成通知(用于更新缓存和数据库) +// ===== P0 性能优化:使用增量更新替代全量统计 ===== app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { const { objectKey, size, path } = req.body; @@ -2988,10 +3091,21 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { }); } + if (!size || size < 0) { + return res.status(400).json({ + success: false, + message: '文件大小参数无效' + }); + } + try { - // 清除 OSS 使用情况缓存 + // 更新存储使用量缓存(增量更新) + await StorageUsageCache.updateUsage(req.user.id, size); + + // 同时更新旧的内存缓存(保持兼容性) clearOssUsageCache(req.user.id); + console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${size} 字节`); res.json({ success: true, message: '上传完成已记录' @@ -3152,6 +3266,15 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + // 文件名长度限制(最大255字符) + if (originalFilename.length > 255) { + safeDeleteFile(req.file.path); + return res.status(400).json({ + success: false, + message: '文件名过长,最大支持255个字符' + }); + } + // 文件名安全校验 if (!isSafePathSegment(originalFilename)) { safeDeleteFile(req.file.path); @@ -4398,7 +4521,11 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { }); // 更新系统设置 -app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { +app.post('/api/admin/settings', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(系统设置影响全局) + (req, res) => { try { const { max_upload_size, smtp, global_theme } = req.body; @@ -4473,6 +4600,217 @@ app.post('/api/admin/settings/test-smtp', authMiddleware, adminMiddleware, async } }); +// ===== 统一 OSS 配置管理(管理员专用) ===== + +// 获取统一 OSS 配置 +app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req, res) => { + try { + const config = SettingsDB.getUnifiedOssConfig(); + + if (!config) { + return res.json({ + success: true, + configured: false, + config: null + }); + } + + // 返回配置(隐藏 Secret Key) + res.json({ + success: true, + configured: true, + config: { + provider: config.provider, + region: config.region, + access_key_id: config.access_key_id, + bucket: config.bucket, + endpoint: config.endpoint, + has_secret: !!config.access_key_secret + } + }); + } catch (error) { + console.error('获取统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '获取配置失败: ' + error.message + }); + } +}); + +// 设置/更新统一 OSS 配置 +app.post('/api/admin/unified-oss-config', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + [ + body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('region').notEmpty().withMessage('地域不能为空'), + body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('access_key_secret').notEmpty().withMessage('Access Key Secret不能为空'), + body('bucket').notEmpty().withMessage('存储桶名称不能为空'), + body('endpoint').optional({ checkFalsy: true }).isURL().withMessage('Endpoint必须是有效的URL') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; + + // 验证 OSS 连接 + try { + const testUser = { + id: 0, // 系统测试用户 + oss_provider: provider, + oss_region: region, + oss_access_key_id: access_key_id, + oss_access_key_secret: access_key_secret, + oss_bucket: bucket, + oss_endpoint: endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容(验证配置是否正确) + await ossClient.list('/'); + await ossClient.end(); + } catch (error) { + return res.status(400).json({ + success: false, + message: 'OSS连接失败,请检查配置: ' + error.message + }); + } + + // 保存统一 OSS 配置 + SettingsDB.setUnifiedOssConfig({ + provider, + region, + access_key_id, + access_key_secret, + bucket, + endpoint: endpoint || '' + }); + + // 记录系统日志 + SystemLogDB.log({ + level: SystemLogDB.LEVELS.INFO, + category: SystemLogDB.CATEGORIES.SYSTEM, + action: 'update_unified_oss_config', + message: '管理员更新了统一 OSS 配置', + userId: req.user.id, + username: req.user.username, + details: { + provider, + region, + bucket, + endpoint: endpoint || '' + } + }); + + res.json({ + success: true, + message: '统一 OSS 配置已更新,所有用户将使用此配置' + }); + } catch (error) { + console.error('更新统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '更新配置失败: ' + error.message + }); + } + } +); + +// 测试统一 OSS 配置(不保存) +app.post('/api/admin/unified-oss-config/test', + authMiddleware, + adminMiddleware, + [ + body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('region').notEmpty().withMessage('地域不能为空'), + body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('bucket').notEmpty().withMessage('存储桶名称不能为空') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; + + // 验证 OSS 连接 + const testUser = { + id: 0, + oss_provider: provider, + oss_region: region, + oss_access_key_id: access_key_id, + oss_access_key_secret: access_key_secret, + oss_bucket: bucket, + oss_endpoint: endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容 + await ossClient.list('/'); + await ossClient.end(); + + res.json({ + success: true, + message: 'OSS 连接测试成功' + }); + } catch (error) { + console.error('[OSS测试] 连接失败:', error); + res.status(400).json({ + success: false, + message: 'OSS 连接失败: ' + error.message + }); + } + } +); + +// 删除统一 OSS 配置 +app.delete('/api/admin/unified-oss-config', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + (req, res) => { + try { + SettingsDB.clearUnifiedOssConfig(); + + // 记录系统日志 + SystemLogDB.log({ + level: SystemLogDB.LEVELS.INFO, + category: SystemLogDB.CATEGORIES.SYSTEM, + action: 'delete_unified_oss_config', + message: '管理员删除了统一 OSS 配置', + userId: req.user.id, + username: req.user.username + }); + + res.json({ + success: true, + message: '统一 OSS 配置已删除,用户将使用个人配置' + }); + } catch (error) { + console.error('删除统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '删除配置失败: ' + error.message + }); + } +}); + // 系统健康检测API app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, res) => { try { @@ -4694,6 +5032,81 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, } }); +// ===== 第二轮修复:WAL 文件管理接口 ===== + +/** + * 获取 WAL 文件信息 + * GET /api/admin/wal-info + */ +app.get('/api/admin/wal-info', authMiddleware, adminMiddleware, (req, res) => { + try { + const walSize = WalManager.getWalFileSize(); + const walSizeMB = (walSize / 1024 / 1024).toFixed(2); + + res.json({ + success: true, + data: { + walSize, + walSizeMB: parseFloat(walSizeMB), + status: walSize > 100 * 1024 * 1024 ? 'warning' : 'normal' + } + }); + } catch (error) { + console.error('获取 WAL 信息失败:', error); + res.status(500).json({ + success: false, + message: '获取 WAL 信息失败: ' + error.message + }); + } +}); + +/** + * 手动执行 WAL 检查点(清理 WAL 文件) + * POST /api/admin/wal-checkpoint + */ +app.post('/api/admin/wal-checkpoint', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:WAL 检查点是敏感操作 + (req, res) => { + try { + const beforeSize = WalManager.getWalFileSize(); + const success = WalManager.performCheckpoint(); + const afterSize = WalManager.getWalFileSize(); + + if (!success) { + return res.status(500).json({ + success: false, + message: 'WAL 检查点执行失败' + }); + } + + // 记录 WAL 清理操作 + logSystem(req, 'wal_checkpoint', `管理员手动执行 WAL 检查点`, { + beforeSize, + afterSize, + freed: beforeSize - afterSize + }); + + res.json({ + success: true, + message: `WAL 检查点完成: ${(beforeSize / 1024 / 1024).toFixed(2)}MB → ${(afterSize / 1024 / 1024).toFixed(2)}MB`, + data: { + beforeSize, + afterSize, + freed: beforeSize - afterSize + } + }); + } catch (error) { + console.error('执行 WAL 检查点失败:', error); + res.status(500).json({ + success: false, + message: '执行 WAL 检查点失败: ' + error.message + }); + } + } +); + // 获取服务器存储统计信息 app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { try { @@ -4880,7 +5293,11 @@ app.get('/api/admin/logs/stats', authMiddleware, adminMiddleware, (req, res) => }); // 清理旧日志 -app.post('/api/admin/logs/cleanup', authMiddleware, adminMiddleware, (req, res) => { +app.post('/api/admin/logs/cleanup', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(日志清理影响审计追踪) + (req, res) => { try { const { keepDays = 90 } = req.body; const days = Math.max(7, Math.min(parseInt(keepDays) || 90, 365)); // 最少保留7天,最多365天 @@ -4904,8 +5321,260 @@ app.post('/api/admin/logs/cleanup', authMiddleware, adminMiddleware, (req, res) } }); +// ===== 第二轮修复:存储缓存一致性检查和修复接口 ===== + +/** + * 检查单个用户的存储缓存完整性 + * GET /api/admin/storage-cache/check/:userId + */ +app.get('/api/admin/storage-cache/check/:userId', + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { userId } = req.params; + const user = UserDB.findById(userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (!user.has_oss_config || !user.oss_bucket) { + return res.json({ + success: true, + message: '用户未配置 OSS,无需检查', + data: { + userId: user.id, + username: user.username, + hasOssConfig: false, + consistent: true + } + }); + } + + // 创建 OSS 客户端 + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.checkIntegrity(userId, ossClient); + + // 记录检查操作 + logSystem(req, 'storage_cache_check', `管理员检查用户 ${user.username} 的存储缓存`, { + userId, + username: user.username, + ...result + }); + + res.json({ + success: true, + message: result.consistent ? '缓存一致' : '缓存不一致', + data: { + userId: user.id, + username: user.username, + ...result + } + }); + } catch (error) { + console.error('检查存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '检查存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 重建单个用户的存储缓存 + * POST /api/admin/storage-cache/rebuild/:userId + */ +app.post('/api/admin/storage-cache/rebuild/:userId', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:重建缓存是敏感操作 + async (req, res) => { + try { + const { userId } = req.params; + const user = UserDB.findById(userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (!user.has_oss_config || !user.oss_bucket) { + return res.status(400).json({ + success: false, + message: '用户未配置 OSS' + }); + } + + // 创建 OSS 客户端 + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.rebuildCache(userId, ossClient); + + // 记录修复操作 + logSystem(req, 'storage_cache_rebuild', `管理员重建用户 ${user.username} 的存储缓存`, { + userId, + username: user.username, + ...result + }); + + res.json({ + success: true, + message: `缓存已重建: ${formatFileSize(result.previous)} → ${formatFileSize(result.current)} (${result.fileCount} 个文件)`, + data: { + userId: user.id, + username: user.username, + ...result + } + }); + } catch (error) { + console.error('重建存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '重建存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 批量检查所有用户的存储缓存一致性 + * GET /api/admin/storage-cache/check-all + */ +app.get('/api/admin/storage-cache/check-all', + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const users = UserDB.getAll(); + + // 创建获取 OSS 客户端的函数 + const getOssClient = (user) => createOssClientForUser(user); + + const results = await StorageUsageCache.checkAllUsersIntegrity(users, getOssClient); + + // 统计 + const total = results.length; + const inconsistent = results.filter(r => !r.consistent && !r.error).length; + const errors = results.filter(r => r.error).length; + + // 记录批量检查操作 + logSystem(req, 'storage_cache_check_all', `管理员批量检查存储缓存`, { + total, + inconsistent, + errors + }); + + res.json({ + success: true, + message: `检查完成: ${total} 个用户,${inconsistent} 个不一致,${errors} 个错误`, + data: { + summary: { + total, + consistent: total - inconsistent - errors, + inconsistent, + errors + }, + results + } + }); + } catch (error) { + console.error('批量检查存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '批量检查存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 自动检测并修复所有用户的缓存不一致 + * POST /api/admin/storage-cache/auto-fix + */ +app.post('/api/admin/storage-cache/auto-fix', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:批量修复是敏感操作 + async (req, res) => { + try { + const { threshold = 0 } = req.body; // 差异阈值(字节) + + const users = UserDB.getAll(); + const fixResults = []; + + for (const user of users) { + // 跳过没有配置 OSS 的用户 + if (!user.has_oss_config || !user.oss_bucket) { + continue; + } + + try { + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.autoDetectAndFix(user.id, ossClient, threshold); + + fixResults.push({ + userId: user.id, + username: user.username, + ...result + }); + } catch (error) { + console.error(`自动修复用户 ${user.id} 失败:`, error.message); + fixResults.push({ + userId: user.id, + username: user.username, + error: error.message + }); + } + } + + // 统计 + const total = fixResults.length; + const fixed = fixResults.filter(r => r.autoFixed).length; + const errors = fixResults.filter(r => r.error).length; + + // 记录批量修复操作 + logSystem(req, 'storage_cache_auto_fix', `管理员自动修复存储缓存`, { + total, + fixed, + errors, + threshold + }); + + res.json({ + success: true, + message: `自动修复完成: ${total} 个用户,${fixed} 个已修复,${errors} 个错误`, + data: { + summary: { + total, + fixed, + skipped: total - fixed - errors, + errors + }, + results: fixResults + } + }); + } catch (error) { + console.error('自动修复存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '自动修复存储缓存失败: ' + error.message + }); + } + } +); + // 封禁/解封用户 -app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res) => { +app.post('/api/admin/users/:id/ban', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(封禁用户是敏感操作) + (req, res) => { try { const { id } = req.params; const { banned } = req.body; @@ -4974,7 +5643,11 @@ app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res) }); // 删除用户(级联删除文件和分享) -app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req, res) => { +app.delete('/api/admin/users/:id', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + async (req, res) => { try { const { id } = req.params; @@ -5127,6 +5800,7 @@ function getUserDirectorySize(dirPath) { app.post('/api/admin/users/:id/storage-permission', authMiddleware, adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(修改存储权限影响用户数据访问) [ body('storage_permission').isIn(['local_only', 'oss_only', 'user_choice']).withMessage('无效的存储权限') ], @@ -5284,7 +5958,11 @@ app.get('/api/admin/shares', authMiddleware, adminMiddleware, (req, res) => { }); // 删除分享(管理员) -app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res) => { +app.delete('/api/admin/shares/:id', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(删除用户分享是敏感操作) + (req, res) => { try { // 参数验证:验证 ID 格式 const shareId = parseInt(req.params.id, 10); @@ -5333,115 +6011,6 @@ app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res) } }); -// ============================================ -// 管理员:上传工具管理 -// ============================================ - -// 检查上传工具是否存在 -app.get('/api/admin/check-upload-tool', authMiddleware, adminMiddleware, (req, res) => { - try { - const toolPath = path.join(__dirname, '..', 'upload-tool', 'dist', '玩玩云上传工具.exe'); - - if (fs.existsSync(toolPath)) { - const stats = fs.statSync(toolPath); - const sizeMB = (stats.size / (1024 * 1024)).toFixed(2); - - res.json({ - success: true, - exists: true, - fileInfo: { - path: toolPath, - size: stats.size, - sizeMB: sizeMB, - modifiedAt: stats.mtime - } - }); - } else { - res.json({ - success: true, - exists: false, - message: '上传工具不存在' - }); - } - } catch (error) { - console.error('检查上传工具失败:', error); - res.status(500).json({ - success: false, - message: '检查失败: ' + error.message - }); - } -}); - -// 上传工具文件 -const uploadToolStorage = multer.diskStorage({ - destination: (req, file, cb) => { - const uploadDir = path.join(__dirname, '..', 'upload-tool', 'dist'); - // 确保目录存在 - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } - cb(null, uploadDir); - }, - filename: (req, file, cb) => { - // 固定文件名 - cb(null, '玩玩云上传工具.exe'); - } -}); - -const uploadTool = multer({ - storage: uploadToolStorage, - limits: { - fileSize: 100 * 1024 * 1024 // 限制100MB - }, - fileFilter: (req, file, cb) => { - // 只允许.exe文件 - if (!file.originalname.toLowerCase().endsWith('.exe')) { - return cb(new Error('只允许上传.exe文件')); - } - cb(null, true); - } -}); - -app.post('/api/admin/upload-tool', authMiddleware, adminMiddleware, uploadTool.single('file'), (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ - success: false, - message: '请选择要上传的文件' - }); - } - - const fileSizeMB = (req.file.size / (1024 * 1024)).toFixed(2); - - // 验证文件大小(至少20MB,上传工具通常很大) - if (req.file.size < 20 * 1024 * 1024) { - // 删除上传的文件 - fs.unlinkSync(req.file.path); - return res.status(400).json({ - success: false, - message: '文件大小异常,上传工具通常大于20MB' - }); - } - - console.log(`[上传工具] 管理员上传成功: ${fileSizeMB}MB`); - - res.json({ - success: true, - message: '上传工具已上传', - fileInfo: { - size: req.file.size, - sizeMB: fileSizeMB - } - }); - } catch (error) { - console.error('上传工具失败:', error); - res.status(500).json({ - success: false, - message: '上传失败: ' + error.message - }); - } -}); - // 分享页面访问路由 app.get("/s/:code", (req, res) => { const shareCode = req.params.code; diff --git a/backend/storage.js b/backend/storage.js index 4d849ac..bea2779 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -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, diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js new file mode 100644 index 0000000..b34d122 --- /dev/null +++ b/backend/utils/encryption.js @@ -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 +}; diff --git a/backend/utils/storage-cache.js b/backend/utils/storage-cache.js new file mode 100644 index 0000000..bf45dd4 --- /dev/null +++ b/backend/utils/storage-cache.js @@ -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} + */ + 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} + */ + 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} 检查结果列表 + */ + 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; diff --git a/frontend/app.html b/frontend/app.html index 722ee9c..791a921 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -1315,18 +1315,13 @@
- - - - - @@ -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' }"> 用户 -
@@ -2842,76 +2833,6 @@ - - -
- -
-

- 上传工具管理 -

- - -
-
-
-
-
- 上传工具已存在 -
-
- 文件大小: {{ uploadToolStatus.fileInfo.sizeMB }} MB -
-
- 最后修改: {{ formatDate(uploadToolStatus.fileInfo.modifiedAt) }} -
-
- -
-
- -
-
- 上传工具不存在 -
-
- 普通用户将无法下载上传工具,请上传工具文件 -
-
-
- - -
- - - - - -
- - -
-
- 说明: -
    -
  • 上传工具文件应为 .exe 格式,大小通常在 20-50 MB
  • -
  • 上传后,普通用户可以在设置页面下载该工具
  • -
  • 如果安装脚本下载失败,可以在这里手动上传
  • -
-
-
-
-
diff --git a/frontend/app.js b/frontend/app.js index 748c87d..2a312aa 100644 --- a/frontend/app.js +++ b/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', '提示', '正在生成上传工具,下载即将开始...'); - - // 使用标签下载,通过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,允许重复上传 - } - }, - // ===== 调试模式管理 ===== // 切换调试模式 diff --git a/upload-tool/README.txt b/upload-tool/README.txt deleted file mode 100644 index 95a3dbf..0000000 --- a/upload-tool/README.txt +++ /dev/null @@ -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: 当前版本暂不支持取消,请等待队列完成 - -【技术支持】 -如有问题请联系管理员 - -============================================ diff --git a/upload-tool/build.bat b/upload-tool/build.bat deleted file mode 100644 index e4fa176..0000000 --- a/upload-tool/build.bat +++ /dev/null @@ -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 diff --git a/upload-tool/build.sh b/upload-tool/build.sh deleted file mode 100644 index 8726709..0000000 --- a/upload-tool/build.sh +++ /dev/null @@ -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 "========================================" diff --git a/upload-tool/requirements.txt b/upload-tool/requirements.txt deleted file mode 100644 index 7d6d30b..0000000 --- a/upload-tool/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -# 玩玩云上传工具依赖 -# Python 3.8+ required - -# GUI 框架 -PyQt5>=5.15.9 - -# HTTP 请求 -requests>=2.31.0 - -# 打包工具(仅开发/打包时需要) -# pyinstaller>=6.0.0 diff --git a/upload-tool/upload_tool.py b/upload-tool/upload_tool.py deleted file mode 100644 index 64728a9..0000000 --- a/upload-tool/upload_tool.py +++ /dev/null @@ -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'

玩玩云上传工具 v3.0

' - f'

✓ 已连接 - {provider_name}

' - f'

拖拽文件到此处上传

' - f'

存储桶: {config.get("oss_bucket", "未知")}

' - ) - else: - self.status_label.setText( - f'

玩玩云上传工具 v3.0

' - f'

✓ 已连接 - 本地存储

' - f'

拖拽文件到此处上传

' - ) - else: - self.log(f'✗ {message}') - self.show_error(message) - - def show_error(self, message): - """显示错误信息""" - self.status_label.setText( - f'

玩玩云上传工具 v3.0

' - f'

✗ 错误: {message}

' - f'

请检查网络连接或联系管理员

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