/** * Daily Report Service — generates a summary of yesterday's cloud storage activity * and sends it through configured notification channels at 08:00 daily. */ import { getDb } from '../database/database'; import { getSystemConfig } from '../admin/system-config.service'; import { getCloudConfigs } from '../cloud/credential.service'; import { notify } from '../cloud/notification.service'; // ═══════════════════════════════════════════════ // Types // ═══════════════════════════════════════════════ export interface DailyReportConfig { enabled: boolean; time: string; // "HH:mm" e.g. "08:00" includeSearch: boolean; includeSaves: boolean; includeStorage: boolean; includeUsers: boolean; } export interface DailyReport { date: string; searchCount: number; uniqueSearchers: number; saveCounts: { total: number; success: number; fail: number }; byCloud: Array<{ cloudType: string; nickname: string; saves: number; successes: number; storageUsed: string; storageTotal: string; isActive: boolean; }>; uniqueSavers: number; } // ═══════════════════════════════════════════════ // Config // ═══════════════════════════════════════════════ export function getDailyReportConfig(): DailyReportConfig { const raw = getSystemConfig('daily_report_config') || '{}'; let parsed: Partial = {}; try { parsed = JSON.parse(raw); } catch {} return { enabled: parsed.enabled !== false, time: parsed.time || '08:00', includeSearch: parsed.includeSearch !== false, includeSaves: parsed.includeSaves !== false, includeStorage: parsed.includeStorage !== false, includeUsers: parsed.includeUsers !== false, }; } export function saveDailyReportConfig(cfg: Partial): void { const current = getDailyReportConfig(); const merged = { ...current, ...cfg }; const { getDb } = require('../database/database'); const db = getDb(); const existing = db.prepare("SELECT key FROM system_configs WHERE key = 'daily_report_config'").get(); if (existing) { db.prepare("UPDATE system_configs SET value = ?, updated_at = datetime('now','localtime') WHERE key = 'daily_report_config'") .run(JSON.stringify(merged)); } else { db.prepare("INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)") .run('daily_report_config', JSON.stringify(merged), 'Daily report configuration'); } } // ═══════════════════════════════════════════════ // Report Generation // ═══════════════════════════════════════════════ function getYesterday(): string { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().slice(0, 10); // "2026-05-16" } function getYesterdayRange(): { start: string; end: string } { const date = getYesterday(); return { start: `${date}T00:00:00`, end: `${date}T23:59:59`, }; } export function generateDailyReport(dateOverride?: string): DailyReport { const db = getDb(); const { start, end } = dateOverride ? { start: `${dateOverride}T00:00:00`, end: `${dateOverride}T23:59:59` } : getYesterdayRange(); const date = dateOverride || getYesterday(); // ── Searches ── let searchCount = 0; let uniqueSearchers = 0; try { const searchRow = db.prepare( "SELECT COUNT(*) as cnt FROM search_stats WHERE created_at >= ? AND created_at <= ?" ).get(start, end) as any; searchCount = searchRow?.cnt || 0; const searcherRow = db.prepare( "SELECT COUNT(DISTINCT ip_address) as cnt FROM search_stats WHERE created_at >= ? AND created_at <= ?" ).get(start, end) as any; uniqueSearchers = searcherRow?.cnt || 0; } catch {} // ── Saves ── let totalSaves = 0; let successSaves = 0; let failSaves = 0; let uniqueSavers = 0; try { const saveRow = db.prepare( "SELECT COUNT(*) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ?" ).get(start, end) as any; totalSaves = saveRow?.cnt || 0; const successRow = db.prepare( "SELECT COUNT(*) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ? AND status = 'success'" ).get(start, end) as any; successSaves = successRow?.cnt || 0; const failRow = db.prepare( "SELECT COUNT(*) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ? AND status != 'success'" ).get(start, end) as any; failSaves = failRow?.cnt || 0; const saverRow = db.prepare( "SELECT COUNT(DISTINCT ip_address) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ?" ).get(start, end) as any; uniqueSavers = saverRow?.cnt || 0; } catch {} // ── Per Cloud Breakdown ── const byCloud: DailyReport['byCloud'] = []; try { const clouds = getCloudConfigs(); for (const c of clouds) { const cloudSaveRow = db.prepare( "SELECT COUNT(*) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ? AND target_cloud = ?" ).get(start, end, c.cloud_type) as any; const cloudSuccessRow = db.prepare( "SELECT COUNT(*) as cnt FROM save_records WHERE created_at >= ? AND created_at <= ? AND target_cloud = ? AND status = 'success'" ).get(start, end, c.cloud_type) as any; byCloud.push({ cloudType: c.cloud_type, nickname: c.nickname || '未知', saves: cloudSaveRow?.cnt || 0, successes: cloudSuccessRow?.cnt || 0, storageUsed: c.storage_used || 'N/A', storageTotal: c.storage_total || 'N/A', isActive: !!c.is_active, }); } } catch {} return { date, searchCount, uniqueSearchers, saveCounts: { total: totalSaves, success: successSaves, fail: failSaves }, byCloud, uniqueSavers, }; } // ═══════════════════════════════════════════════ // Formatting // ═══════════════════════════════════════════════ function formatStorage(used: string, total: string): string { try { const parseSize = (s: string): number => { const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i); if (!m) return 0; const units: Record = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }; return parseFloat(m[1]) * (units[m[2].toUpperCase()] || 1); }; const u = parseSize(used); const t = parseSize(total); if (t === 0) return `${used} / ${total}`; const pct = ((u / t) * 100).toFixed(1); return `${used} / ${total} (${pct}%)`; } catch { return `${used} / ${total}`; } } const CLOUD_LABELS: Record = { quark: '夸克', baidu: '百度', aliyun: '阿里云盘', '115': '115网盘', tianyi: '天翼云', '123pan': '123云盘', uc: 'UC网盘', xunlei: '迅雷云盘', pikpak: 'PikPak', magnet: '磁力链接', ed2k: '电驴链接', others: '其他', }; function cloudLabel(type: string): string { return CLOUD_LABELS[type] || type; } export function formatDailyReport(report: DailyReport, includeSearch = true, includeSaves = true, includeStorage = true, includeUsers = true): string { const lines: string[] = []; const formattedDate = report.date.replace(/-/g, '/'); lines.push(`📊 **CloudSearch 每日汇报**`); lines.push(`📅 ${formattedDate}`); lines.push(''); if (includeSearch && report.searchCount > 0) { lines.push(`🔍 **搜索统计**`); lines.push(` 总搜索: ${report.searchCount} 次`); if (includeUsers) lines.push(` 搜索用户: ${report.uniqueSearchers} 人`); lines.push(''); } if (includeSaves) { const { total, success, fail } = report.saveCounts; lines.push(`💾 **转存统计**`); lines.push(` 总转存: ${total} 次`); lines.push(` 成功: ${success} 次 | 失败: ${fail} 次`); if (total > 0) lines.push(` 成功率: ${((success / total) * 100).toFixed(1)}%`); if (includeUsers) lines.push(` 转存用户: ${report.uniqueSavers} 人`); lines.push(''); } if (includeStorage && report.byCloud.length > 0) { lines.push(`☁️ **网盘状态**`); for (const c of report.byCloud) { const icon = c.isActive ? '🟢' : '🔴'; const label = cloudLabel(c.cloudType); if (c.saves > 0) { lines.push(` ${icon} ${label} · ${c.nickname} · 转存 ${c.saves} 次 · ${formatStorage(c.storageUsed, c.storageTotal)}`); } else { lines.push(` ${icon} ${label} · ${c.nickname} · ${formatStorage(c.storageUsed, c.storageTotal)}`); } } lines.push(''); } if (report.saveCounts.total === 0 && report.searchCount === 0) { lines.push(`💤 昨日无活动记录`); } return lines.join('\n'); } // ═══════════════════════════════════════════════ // Scheduler // ═══════════════════════════════════════════════ let lastRunDate = ''; function getTodayStr(): string { return new Date().toISOString().slice(0, 10); } export async function runDailyReportIfScheduled(): Promise { const cfg = getDailyReportConfig(); if (!cfg.enabled) return; const now = new Date(); const [hh, mm] = cfg.time.split(':').map(Number); const scheduledMinute = hh * 60 + mm; const currentMinute = now.getHours() * 60 + now.getMinutes(); // Run within a 5-minute window of scheduled time const diff = currentMinute - scheduledMinute; if (diff < 0 || diff >= 5) return; const today = getTodayStr(); if (lastRunDate === today) return; // already ran today lastRunDate = today; console.log(`[DailyReport] Running daily report for ${today}...`); try { const report = generateDailyReport(); const content = formatDailyReport( report, cfg.includeSearch, cfg.includeSaves, cfg.includeStorage, cfg.includeUsers ); notify('CloudSearch 每日汇报', content, 'info'); // Record last run const { getDb } = require('../database/database'); const db = getDb(); const existing = db.prepare("SELECT key FROM system_configs WHERE key = 'daily_report_last_run'").get(); if (existing) { db.prepare("UPDATE system_configs SET value = ?, updated_at = datetime('now','localtime') WHERE key = 'daily_report_last_run'") .run(JSON.stringify({ date: today, sentAt: new Date().toISOString() })); } else { db.prepare("INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)") .run('daily_report_last_run', JSON.stringify({ date: today, sentAt: new Date().toISOString() }), 'Last daily report run'); } console.log(`[DailyReport] Report sent successfully for ${today}`); } catch (err: any) { console.error(`[DailyReport] Failed:`, err.message); } } // ═══════════════════════════════════════════════ // Manual test preview // ═══════════════════════════════════════════════ export function previewDailyReport(dateOverride?: string): string { const report = generateDailyReport(dateOverride); const cfg = getDailyReportConfig(); return formatDailyReport( report, cfg.includeSearch, cfg.includeSaves, cfg.includeStorage, cfg.includeUsers ); } export async function sendTestDailyReport(): Promise<{ success: boolean; report: DailyReport }> { const report = generateDailyReport(); const cfg = getDailyReportConfig(); const content = formatDailyReport( report, cfg.includeSearch, cfg.includeSaves, cfg.includeStorage, cfg.includeUsers ); notify('CloudSearch 每日汇报 [测试]', content, 'info'); return { success: true, report }; }