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:
339
source_clean/src/services/daily-report.service.ts
Normal file
339
source_clean/src/services/daily-report.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user