chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
76
packages/backend/src/admin/auth.service.ts
Executable file
76
packages/backend/src/admin/auth.service.ts
Executable file
@@ -0,0 +1,76 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import config from '../config';
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
export interface AuthPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): string | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, username, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const valid = bcrypt.compareSync(password, row.password_hash);
|
||||
if (!valid) return null;
|
||||
|
||||
// Update last login
|
||||
db.prepare('UPDATE admins SET last_login = datetime(\'now\') WHERE id = ?').run(row.id);
|
||||
|
||||
// Generate JWT
|
||||
const payload: AuthPayload = { id: row.id, username: row.username };
|
||||
const token = jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' });
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as AuthPayload;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing or invalid authorization header', code: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
res.status(401).json({ error: 'Invalid or expired token', code: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function changePassword(username: string, oldPassword: string, newPassword: string): { success: boolean; message: string } {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||
if (!row) {
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
const valid = bcrypt.compareSync(oldPassword, row.password_hash);
|
||||
if (!valid) {
|
||||
return { success: false, message: '原密码错误' };
|
||||
}
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(newPassword, salt);
|
||||
db.prepare("UPDATE admins SET password_hash = ? WHERE id = ?").run(hash, row.id);
|
||||
return { success: true, message: '密码修改成功' };
|
||||
}
|
||||
161
packages/backend/src/admin/stats.service.ts
Executable file
161
packages/backend/src/admin/stats.service.ts
Executable file
@@ -0,0 +1,161 @@
|
||||
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<string, number>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
40
packages/backend/src/admin/system-config.service.ts
Executable file
40
packages/backend/src/admin/system-config.service.ts
Executable file
@@ -0,0 +1,40 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp } from '../utils/time';
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getAllSystemConfigs(): SystemConfigEntry[] {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT key, value, description, updated_at FROM system_configs ORDER BY key').all() as SystemConfigEntry[];
|
||||
}
|
||||
|
||||
export function getSystemConfig(key: string): string | null {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function updateSystemConfig(key: string, value: string): void {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||
).run(value, localTimestamp(), key);
|
||||
}
|
||||
|
||||
export function updateSystemConfigs(entries: { key: string; value: string }[]): void {
|
||||
const db = getDb();
|
||||
const update = db.prepare(
|
||||
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||
);
|
||||
const tx = db.transaction((items: { key: string; value: string }[]) => {
|
||||
for (const item of items) {
|
||||
update.run(item.value, localTimestamp(), item.key);
|
||||
}
|
||||
});
|
||||
tx(entries);
|
||||
}
|
||||
Reference in New Issue
Block a user