v0.3.20: 每日汇报系统 — 每天8点自动收集前一日数据并推送汇总报告

新增:
- 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 窗口内运行

报告内容: 搜索统计/转存统计(成功率)/各网盘容量和活跃状态/用户数
This commit is contained in:
2026-05-17 17:10:39 +08:00
parent 95df193e26
commit 0e0cad1271
45 changed files with 622 additions and 64 deletions

View File

@@ -183,6 +183,23 @@ async function start(): Promise<void> {
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL);
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
// Daily Report scheduler — check every 60 seconds
const DAILY_REPORT_CHECK_INTERVAL = 60 * 1000;
setInterval(() => {
try {
const { runDailyReportIfScheduled } = require('./services/daily-report.service');
runDailyReportIfScheduled().catch(err => console.error('[DailyReport] Scheduler error:', err.message));
} catch {}
}, DAILY_REPORT_CHECK_INTERVAL);
// Also run once 30s after startup (catch case where server starts at 08:00-08:05)
setTimeout(() => {
try {
const { runDailyReportIfScheduled } = require('./services/daily-report.service');
runDailyReportIfScheduled().catch(err => console.error('[DailyReport] Initial check error:', err.message));
} catch {}
}, 30000);
const server = app.listen(config.port, () => {
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
});

View File

@@ -796,5 +796,71 @@ router.delete('/admin/push-users/:id', (req: Request, res: Response) => {
}
});
// ═══════════════════════════════════════════════
// Daily Report
// ═══════════════════════════════════════════════
/** GET /api/admin/daily-report/config */
router.get('/admin/daily-report/config', (_req, res) => {
try {
const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig();
res.json(cfg);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' });
}
});
/** PUT /api/admin/daily-report/config */
router.put('/admin/daily-report/config', (req, res) => {
try {
const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(getDailyReportConfig());
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' });
}
});
/** GET /api/admin/daily-report/preview */
router.get('/admin/daily-report/preview', (req, res) => {
try {
const { previewDailyReport, generateDailyReport } = require('../services/daily-report.service');
const date = req.query.date as string || undefined;
const content = previewDailyReport(date);
const report = generateDailyReport(date);
res.json({ content, report });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to preview daily report' });
}
});
/** POST /api/admin/daily-report/test — send a test report immediately */
router.post('/admin/daily-report/test', async (_req, res) => {
try {
const { sendTestDailyReport } = require('../services/daily-report.service');
const result = await sendTestDailyReport();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to send test report' });
}
});
/** GET /api/admin/daily-report/last-run */
router.get('/admin/daily-report/last-run', (_req, res) => {
try {
const { getSystemConfig } = require('../admin/system-config.service');
const raw = getSystemConfig('daily_report_last_run') || '{}';
let data: any = {};
try { data = JSON.parse(raw); } catch {}
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@@ -0,0 +1,339 @@
/**
* 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 };
}