import { getDb } from '../database/database'; import { formatLocalDate } from '../utils/time'; export interface AdminStats { todaySearches: number; todaySaves: number; monthSearches: number; monthSaves: number; totalSearches: number; totalSaves: number; hotKeywords: Array<{ keyword: string; count: number }>; trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }>; cloudUsage: Array<{ cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean; }>; topIps: Array<{ ip: string; ip_location: string | null; count: number }>; provinceRankings: Array<{ province: string; count: number }>; } /** * Get today's date string in the configured timezone (e.g. "2026-05-04"). * Delegates to shared formatLocalDate() in utils/time.ts. */ function todayLocalDate(): string { return formatLocalDate(); } /** * Get the first day of the current month in the configured timezone. */ function monthStartLocalDate(): string { return todayLocalDate().slice(0, 7) + '-01'; } export function getStats(trendDays: number = 7): AdminStats { const db = getDb(); // Use local timezone date — NOT UTC via toISOString() const today = todayLocalDate(); const monthStart = monthStartLocalDate(); // IMPORTANT: created_at is stored as "YYYY-MM-DDTHH:mm:ss+08:00" (localTimestamp) // SQLite's date() function would interpret the +08:00 timezone offset and // convert to UTC, giving wrong date. Instead, use SUBSTR to get first 10 chars. const todaySearchesRow = db.prepare( "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?" ).get(today) as any; const todaySavesRow = db.prepare( "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?" ).get(today) as any; const monthSearchesRow = db.prepare( "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) >= ?" ).get(monthStart) as any; const monthSavesRow = db.prepare( "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) >= ?" ).get(monthStart) as any; // Total searches const totalSearchesRow = db.prepare( "SELECT COUNT(*) as count FROM search_stats" ).get() as any; // Total saves const totalSavesRow = db.prepare( "SELECT COUNT(*) as count FROM save_records" ).get() as any; // Hot keywords const hotKeywords = db.prepare( 'SELECT keyword, search_count as count FROM hot_keywords ORDER BY search_count DESC LIMIT 20' ).all() as Array<{ keyword: string; count: number }>; // Trend data (configurable days, default 7) const trendLen = Math.min(Math.max(trendDays, 1), 90); const trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }> = []; for (let i = trendLen - 1; i >= 0; i--) { const d = new Date(); const target = new Date(d.getTime() - i * 86400000); const dateStr = formatLocalDate(target); const searchRow = db.prepare( "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?" ).get(dateStr) as any; const saveRow = db.prepare( "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?" ).get(dateStr) as any; trendTrend.push({ date: dateStr, searches: searchRow?.count || 0, saves: saveRow?.count || 0, searchDelta: 0, saveDelta: 0, }); } // Compute day-over-day delta (absolute change from previous day) for (let i = trendTrend.length - 1; i > 0; i--) { const prev = trendTrend[i - 1]; const curr = trendTrend[i]; curr.searchDelta = curr.searches - prev.searches; curr.saveDelta = curr.saves - prev.saves; } // Cloud usage const cloudUsage = db.prepare( 'SELECT cloud_type as cloudType, nickname, storage_used as storageUsed, storage_total as storageTotal, is_active as isActive FROM cloud_configs ORDER BY id ASC' ).all() as Array<{ cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean; }>; // Top IPs from save_records — correctly count total per IP, then get latest location const topIps = db.prepare( `SELECT ip_address as ip, COUNT(*) as count, (SELECT ip_location FROM save_records s2 WHERE s2.ip_address = s1.ip_address AND s2.ip_location IS NOT NULL AND s2.ip_location != '' ORDER BY s2.created_at DESC LIMIT 1) as ip_location FROM save_records s1 WHERE ip_address IS NOT NULL AND ip_address != '' GROUP BY ip_address ORDER BY count DESC LIMIT 10` ).all() as Array<{ ip: string; ip_location: string | null; count: number }>; // Province rankings — extract province from ip_location (first segment) let provinceRankings: Array<{ province: string; count: number }> = []; const locRows = db.prepare( `SELECT ip_location FROM save_records WHERE ip_location IS NOT NULL AND ip_location != ''` ).all() as Array<{ ip_location: string }>; const provMap = new Map(); for (const row of locRows) { const parts = row.ip_location.trim().split(/\s+/); const province = parts[0] || '未知'; provMap.set(province, (provMap.get(province) || 0) + 1); } provinceRankings = Array.from(provMap.entries()) .map(([province, count]) => ({ province, count })) .sort((a, b) => b.count - a.count) .slice(0, 15); return { todaySearches: (todaySearchesRow as any)?.count || 0, todaySaves: (todaySavesRow as any)?.count || 0, monthSearches: (monthSearchesRow as any)?.count || 0, monthSaves: (monthSavesRow as any)?.count || 0, totalSearches: (totalSearchesRow as any)?.count || 0, totalSaves: (totalSavesRow as any)?.count || 0, hotKeywords, trendTrend, cloudUsage, topIps, provinceRankings, }; }