新增:
- src/services/daily-report.service.ts (核心服务: 数据收集/报告生成/格式化/调度器)
- API: GET/PUT daily-report/config, GET daily-report/preview, POST daily-report/test, GET daily-report/last-run
- 前端: 侧边栏"📊 每日汇报"菜单 + SystemConfig.vue 配置面板(时间/内容开关/预览/测试发送)
- main.ts: 每60秒检查调度, 08:00-08:04 窗口内运行
报告内容: 搜索统计/转存统计(成功率)/各网盘容量和活跃状态/用户数
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* 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<DailyReportConfig> = {};
|
|
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<DailyReportConfig>): 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<string, number> = { 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<string, string> = {
|
|
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<void> {
|
|
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 };
|
|
}
|