import { getDb } from '../database/database'; import { getSystemConfig, updateSystemConfig } from '../admin/system-config.service'; import { formatLocalDate, formatLocalDateTime } from '../utils/time'; import { QuarkDriver } from './drivers/quark.driver'; import { CloudConfig, getActiveCloudConfigs } from './credential.service'; import { BaiduDriver } from './drivers/baidu.driver'; // ═══════════════════════════════════════════════════════════════════════════ // CloudCleanupDriver — contract that each cloud driver must fulfill // to participate in the cleanup cycle. // // To add a new cloud type (e.g. Baidu, Aliyun), implement these three // methods in the driver and register it in getDriverForCleanup() below. // The controller (this file) handles WHEN and WITH WHAT parameters; // the driver handles HOW. // ═══════════════════════════════════════════════════════════════════════════ /** Each cleanup operation returns { trashed: number; errors: string[] } */ interface CleanupOpResult { trashed: number; errors: string[] } interface CloudCleanupDriver { /** Trash date folders (YYYY-MM-DD) older than `days`. */ cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise; /** * If used space exceeds thresholdPercent% of TOTAL capacity, * delete oldest date folders until totalBytes * deletePercent/100 * of TOTAL capacity is freed. * @param thresholdPercent — trigger when usage >= this % of total * @param deletePercent — free this % of total capacity */ cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise; /** Permanently empty the recycle bin. */ emptyTrash(): Promise; } // ═══════════════════════════════════════════════════════════════════════════ // Driver factory — create the right driver for a given cloud config. // When adding a new cloud type, add a case here. // ═══════════════════════════════════════════════════════════════════════════ function getDriverForCleanup(config: { cloud_type: string; cookie: string }): CloudCleanupDriver | null { switch (config.cloud_type) { case 'quark': return new QuarkDriver({ cookie: config.cookie }); case 'baidu': return new BaiduDriver({ cookie: config.cookie }); // case 'aliyun': return new AliyunDriver({ cookie: config.cookie }); default: return null; } } // ═══════════════════════════════════════════════════════════════════════════ // Cleanup controller — reads system configs and dispatches to each // active cloud driver. Every driver receives the same parameters; // the driver decides whether/how to act. // ═══════════════════════════════════════════════════════════════════════════ interface CleanupStats { filesTrashed: number; logsDeleted: number; trashEmptied: boolean; errors: string[]; } /** Get all active cloud configs (any type). Used by the orchestrator. */ function getActiveCleanupConfigs() { const configs = getActiveCloudConfigs(); return configs.filter((c): c is CloudConfig & { cookie: string } => !!c.cookie); } /** * Dispatch cleanupOldDateFolders to every active driver. * Each driver receives the same `days` parameter. */ async function cleanupCloudFiles(days: number): Promise { const configs = getActiveCleanupConfigs(); const errors: string[] = []; let totalTrashed = 0; // Read whitelist from system config let whitelist: string[] = []; try { const raw = getSystemConfig('cleanup_whitelist_dirs'); if (raw) whitelist = JSON.parse(raw); } catch {} for (const cfg of configs) { const driver = getDriverForCleanup(cfg); if (!driver) { console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`); continue; } try { const result = await driver.cleanupOldDateFolders(days, whitelist); totalTrashed += result.trashed; errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); } catch (err: any) { errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupOldDateFolders: ${err.message}`); } await new Promise(r => setTimeout(r, 1000)); } return { trashed: totalTrashed, errors }; } /** * Dispatch cleanupBySpaceThreshold to every active driver. * Each driver receives the same threshold/delete percentages. */ async function cleanupAllBySpaceThreshold( thresholdPercent: number, deletePercent: number, ): Promise { const configs = getActiveCleanupConfigs(); const errors: string[] = []; let totalTrashed = 0; // Read whitelist from system config let whitelist: string[] = []; try { const raw = getSystemConfig('cleanup_whitelist_dirs'); if (raw) whitelist = JSON.parse(raw); } catch {} for (const cfg of configs) { const driver = getDriverForCleanup(cfg); if (!driver) { console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`); continue; } try { const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent, whitelist); totalTrashed += result.trashed; errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); } catch (err: any) { errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupBySpaceThreshold: ${err.message}`); } await new Promise(r => setTimeout(r, 1000)); } return { trashed: totalTrashed, errors }; } /** * Dispatch emptyTrash to every active driver. */ export async function emptyAllTrash(): Promise<{ emptied: boolean; errors: string[] }> { const configs = getActiveCleanupConfigs(); const errors: string[] = []; let emptied = false; for (const cfg of configs) { const driver = getDriverForCleanup(cfg); if (!driver) continue; try { const ok = await driver.emptyTrash(); if (ok) { emptied = true; console.log(`[Cleanup] ✅ Emptied trash for [${cfg.cloud_type}#${cfg.id}]`); } else { errors.push(`[${cfg.cloud_type}#${cfg.id}] empty trash failed`); } } catch (err: any) { errors.push(`[${cfg.cloud_type}#${cfg.id}]: ${err.message}`); } } return { emptied, errors }; } /** * Delete save_records older than the specified number of days. */ function cleanupLogs(days: number): number { const db = getDb(); const cutoffStr = formatLocalDateTime(new Date(Date.now() - days * 24 * 60 * 60 * 1000)); const result = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoffStr); console.log(`[Cleanup] Deleted ${result.changes} save records older than ${days} days (before ${cutoffStr})`); return result.changes; } /** * Run full cleanup cycle: * 0. Force-clean by space threshold (if enabled & exceeded) — priority highest * 1. Delete old save_records * 2. Trash old date folders by retention days * 3. Empty recycle bin (permanently free space) */ export async function runFullCleanup(): Promise { const fileDays = parseInt(getSystemConfig('cleanup_file_retention_days') || '7', 10); const logDays = parseInt(getSystemConfig('cleanup_log_retention_days') || '30', 10); const emptyTrashEnabled = getSystemConfig('cleanup_empty_trash') !== 'false'; const stats: CleanupStats = { filesTrashed: 0, logsDeleted: 0, trashEmptied: false, errors: [] }; // 0. Space threshold (highest priority) const thresholdEnabled = getSystemConfig('cleanup_space_threshold_enabled'); if (thresholdEnabled === 'true') { const thresholdPercent = parseInt(getSystemConfig('cleanup_space_threshold_percent') || '90', 10); const deletePercent = parseInt(getSystemConfig('cleanup_space_threshold_delete_percent') || '10', 10); if (thresholdPercent > 0 && thresholdPercent < 100) { try { const result = await cleanupAllBySpaceThreshold(thresholdPercent, deletePercent); stats.filesTrashed += result.trashed; stats.errors.push(...result.errors); } catch (err: any) { stats.errors.push(`空间阈值清理失败: ${err.message}`); } } } // 1. Delete old save_records try { stats.logsDeleted = cleanupLogs(logDays); } catch (err: any) { stats.errors.push(`日志清理失败: ${err.message}`); } // 2. Trash old files from cloud drives try { const result = await cleanupCloudFiles(fileDays); stats.filesTrashed += result.trashed; stats.errors.push(...result.errors); } catch (err: any) { stats.errors.push(`文件清理失败: ${err.message}`); } // 3. Empty recycle bin (only if enabled, and only if we trashed something) if (emptyTrashEnabled && stats.filesTrashed > 0) { try { const result = await emptyAllTrash(); stats.trashEmptied = result.emptied; stats.errors.push(...result.errors); } catch (err: any) { stats.errors.push(`清空回收站失败: ${err.message}`); } } // Save last run timestamp and stats updateSystemConfig('cleanup_last_run', formatLocalDateTime()); updateSystemConfig('cleanup_last_stats', JSON.stringify({ filesTrashed: stats.filesTrashed, logsDeleted: stats.logsDeleted, trashEmptied: stats.trashEmptied, errors: stats.errors.length }) ); return stats; } /** * Check if a daily cleanup is due and run it. * Called periodically by the scheduler (setInterval). */ export async function checkAndRunScheduledCleanup(): Promise { const enabled = getSystemConfig('cleanup_enabled'); if (enabled !== 'true') return; const lastRun = getSystemConfig('cleanup_last_run'); const todayStr = formatLocalDate(); if (lastRun && lastRun.startsWith(todayStr)) return; console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`); const stats = await runFullCleanup(); console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`); }