use chrono::Local; use serde::{Deserialize, Serialize}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; /// 日志级别 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum LogLevel { Debug, Info, Warn, Error, } /// 单条日志记录 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { pub timestamp: String, pub level: LogLevel, pub module: String, pub message: String, } /// 日志文件信息 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogInfo { pub log_dir: String, pub log_files: Vec, pub total_size_bytes: u64, pub max_file_size_bytes: u64, pub max_files: u32, } /// 日志管理器 —— 负责文件轮转、写入、查询 /// 所有写/轮转/读操作通过 write_lock 串行化,防止并发写交错与轮转竞争 #[derive(Clone)] pub struct LogManager { log_dir: PathBuf, max_file_size: u64, max_files: u32, base_name: String, write_lock: Arc>, } impl LogManager { pub fn new(log_dir: PathBuf) -> Self { fs::create_dir_all(&log_dir).ok(); Self { log_dir, max_file_size: 5 * 1024 * 1024, max_files: 5, base_name: "Thing".to_string(), write_lock: Arc::new(Mutex::new(())), } } /// 写入一条日志(写 + 轮转在锁内串行执行) pub fn log(&self, level: LogLevel, module: &str, message: &str) { let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); let level_str = match level { LogLevel::Debug => "DEBUG", LogLevel::Info => "INFO", LogLevel::Warn => "WARN", LogLevel::Error => "ERROR", }; let line = format!( "[{}] [{}] [{}] {}\n", timestamp, level_str, module, message ); let current_log = self.log_dir.join(format!("{}.log", self.base_name)); // 检查是否需要轮转 if let Ok(meta) = fs::metadata(¤t_log) { if meta.len() >= self.max_file_size { self.rotate(); } } // 追加写入 if let Ok(mut file) = OpenOptions::new() .create(true) .append(true) .open(¤t_log) { let _ = file.write_all(line.as_bytes()); let _ = file.flush(); } } /// 日志文件轮转: thing.log → thing.1.log, thing.1.log → thing.2.log, … fn rotate(&self) { // 删除最旧的文件 let oldest = self .log_dir .join(format!("{}.{}.log", self.base_name, self.max_files)); fs::remove_file(&oldest).ok(); // 依次重命名 for i in (1..self.max_files).rev() { let src = self .log_dir .join(format!("{}.{}.log", self.base_name, i)); let dst = self .log_dir .join(format!("{}.{}.log", self.base_name, i + 1)); fs::rename(&src, &dst).ok(); } // thing.log → thing.1.log let current = self.log_dir.join(format!("{}.log", self.base_name)); let first_rotated = self .log_dir .join(format!("{}.1.log", self.base_name)); fs::rename(¤t, &first_rotated).ok(); } /// 读取日志(支持按模块/级别过滤、条数限制) /// 从最新文件向旧文件倒序遍历,每个文件从末尾向前读取,达到 limit 即停止, /// 避免将全部日志读入内存后再排序截断 pub fn get_logs( &self, module: Option<&str>, level: Option, limit: Option, ) -> Vec { let limit = limit.unwrap_or(100); // 与写入共用同一把锁,避免读到轮转半途/写入半行的状态 let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); let mut entries: Vec = Vec::new(); // 收集日志文件(最新在前:thing.log → thing.1.log → …) let mut paths: Vec = Vec::new(); let current = self.log_dir.join(format!("{}.log", self.base_name)); if current.exists() { paths.push(current); } for i in 1..=self.max_files { let rotated = self .log_dir .join(format!("{}.{}.log", self.base_name, i)); if rotated.exists() { paths.push(rotated); } } 'outer: for path in &paths { // 每文件返回最近 limit 行(从新到旧),收集满即整体停止 for line in read_tail_lines(path, limit) { if let Some(entry) = Self::parse_line(&line) { if let Some(ref m) = module { if entry.module != *m { continue; } } if let Some(ref l) = level { if entry.level != *l { continue; } } entries.push(entry); if entries.len() >= limit { break 'outer; } } } } entries } /// 解析一行日志: `[YYYY-MM-DD HH:MM:SS] [LEVEL] [MODULE] message` fn parse_line(line: &str) -> Option { let line = line.trim(); if line.len() < 22 || !line.starts_with('[') { return None; } let ts_end = line[1..].find(']')? + 1; let timestamp = line[1..ts_end].to_string(); let rest = line[ts_end + 1..].trim(); if !rest.starts_with('[') { return None; } let lv_end = rest[1..].find(']')? + 1; let level_str = &rest[1..lv_end]; let level = match level_str { "DEBUG" => LogLevel::Debug, "INFO" => LogLevel::Info, "WARN" => LogLevel::Warn, "ERROR" => LogLevel::Error, _ => return None, }; let rest = rest[lv_end + 1..].trim(); if !rest.starts_with('[') { return None; } let mod_end = rest[1..].find(']')? + 1; let module = rest[1..mod_end].to_string(); let message = rest[mod_end + 1..].trim().to_string(); Some(LogEntry { timestamp, level, module, message, }) } /// 清空所有日志文件 pub fn clear_logs(&self) -> Result<(), String> { let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); let current = self.log_dir.join(format!("{}.log", self.base_name)); fs::remove_file(¤t).map_err(|e| e.to_string())?; for i in 1..=self.max_files { let rotated = self .log_dir .join(format!("{}.{}.log", self.base_name, i)); fs::remove_file(&rotated).ok(); } Ok(()) } /// 获取日志系统信息 pub fn get_info(&self) -> LogInfo { let mut log_files: Vec = Vec::new(); let mut total_size: u64 = 0; let current = self.log_dir.join(format!("{}.log", self.base_name)); if current.exists() { if let Ok(meta) = fs::metadata(¤t) { total_size += meta.len(); } log_files.push(format!("{}.log", self.base_name)); } for i in 1..=self.max_files { let rotated = self .log_dir .join(format!("{}.{}.log", self.base_name, i)); if rotated.exists() { if let Ok(meta) = fs::metadata(&rotated) { total_size += meta.len(); } log_files.push(format!("{}.{}.log", self.base_name, i)); } } LogInfo { log_dir: self.log_dir.to_string_lossy().to_string(), log_files, total_size_bytes: total_size, max_file_size_bytes: self.max_file_size, max_files: self.max_files, } } } // ===== Tauri 命令 ===== /// 从文件末尾向前读取日志行(返回时间从新到旧的最近 max_lines 行)。 /// 按 8KB 块向前 seek 读取并拼接跨块半行,只读取文件尾部,避免全量读入 fn read_tail_lines(path: &Path, max_lines: usize) -> Vec { let mut file = match File::open(path) { Ok(f) => f, Err(_) => return Vec::new(), }; let file_len = match file.metadata() { Ok(m) => m.len(), Err(_) => return Vec::new(), }; const CHUNK: u64 = 8192; // tail 保存"当前块更靠后的半行",下一轮(更早的块)拼在其前 let mut tail = String::new(); let mut lines: Vec = Vec::new(); let mut pos = file_len; while pos > 0 && lines.len() < max_lines { let start = pos.saturating_sub(CHUNK); let len = (pos - start) as usize; let mut bytes = vec![0u8; len]; if file.seek(SeekFrom::Start(start)).is_err() || file.read_exact(&mut bytes).is_err() { break; } pos = start; let mut text = String::from_utf8_lossy(&bytes).into_owned(); text.push_str(&tail); // 最后一段未以 \n 结尾 → 半行,作为下一轮 tail(与本块之前的内容拼接) let mut parts: Vec<&str> = text.split('\n').collect(); tail = parts.pop().unwrap_or("").to_string(); // 从后往前(新到旧)取完整行 for part in parts.iter().rev() { let trimmed = part.trim(); if !trimmed.is_empty() { lines.push(trimmed.to_string()); if lines.len() >= max_lines { break; } } } } // 文件头残余(pos == 0 时 tail 里可能是文件第一行) if pos == 0 { let trimmed = tail.trim(); if !trimmed.is_empty() && lines.len() < max_lines { lines.push(trimmed.to_string()); } } lines } #[tauri::command] pub fn log_message( state: tauri::State<'_, LogManager>, level: String, module: String, message: String, ) -> Result<(), String> { let level = match level.as_str() { "debug" => LogLevel::Debug, "info" => LogLevel::Info, "warn" => LogLevel::Warn, "error" => LogLevel::Error, _ => return Err(format!("无效的日志级别: {}", level)), }; state.log(level, &module, &message); Ok(()) } #[tauri::command] pub async fn log_list( state: tauri::State<'_, LogManager>, module: Option, level: Option, limit: Option, ) -> Result, String> { let level = level.and_then(|l| match l.as_str() { "debug" => Some(LogLevel::Debug), "info" => Some(LogLevel::Info), "warn" => Some(LogLevel::Warn), "error" => Some(LogLevel::Error), _ => None, }); // 文件读取(含 seek 尾部扫描)移出 async runtime 线程 let manager = state.inner().clone(); tauri::async_runtime::spawn_blocking(move || manager.get_logs(module.as_deref(), level, limit)) .await .map_err(|e| format!("读取日志任务失败: {}", e)) } #[tauri::command] pub fn log_clear(state: tauri::State<'_, LogManager>) -> Result<(), String> { state.clear_logs() } #[tauri::command] pub fn log_info_state(state: tauri::State<'_, LogManager>) -> LogInfo { state.get_info() } // ===== 进程级全局日志器 ===== // 后端模块(无 AppHandle 上下文)通过 log_line 写入统一的日志文件, // 与 Tauri 命令 log_message 共用同一 LogManager(write_lock 串行化), // 消除 eprintln!/println! 双轨并行问题。 static GLOBAL_LOGGER: OnceLock = OnceLock::new(); /// 在 setup 中注册全局日志器(与 app.manage 注册的实例共享同一把 write_lock) pub fn install_global(manager: LogManager) { let _ = GLOBAL_LOGGER.set(manager); } /// 写一条后端模块日志。未注册全局日志器时回退到 stderr(如测试环境)。 pub fn log_line(module: &str, level: LogLevel, message: &str) { match GLOBAL_LOGGER.get() { Some(m) => m.log(level, module, message), None => eprintln!("[{}] {}", module, message), } } /// 便捷:INFO 级别 pub fn log_info(module: &str, message: &str) { log_line(module, LogLevel::Info, message); } /// 便捷:WARN 级别 pub fn log_warn(module: &str, message: &str) { log_line(module, LogLevel::Warn, message); } /// 便捷:ERROR 级别 pub fn log_error(module: &str, message: &str) { log_line(module, LogLevel::Error, message); } #[cfg(test)] mod logger_tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); /// 创建唯一临时目录(进程内多次调用不冲突),测试结束自动清理 fn temp_dir(tag: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let pid = std::process::id(); let seq = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); let dir = std::env::temp_dir().join(format!( "thing_log_test_{}_{}_{}_{}", tag, pid, nanos, seq )); fs::create_dir_all(&dir).unwrap(); dir } struct TempGuard(PathBuf); impl Drop for TempGuard { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn parse_line_roundtrip() { let line = "[2026-08-05 10:00:00] [INFO] [downloader] 下载完成"; let entry = LogManager::parse_line(line).expect("标准行应能解析"); assert_eq!(entry.timestamp, "2026-08-05 10:00:00"); assert_eq!(entry.level, LogLevel::Info); assert_eq!(entry.module, "downloader"); assert_eq!(entry.message, "下载完成"); // 非法行返回 None assert!(LogManager::parse_line("not a log line").is_none()); assert!(LogManager::parse_line("").is_none()); assert!(LogManager::parse_line("[bad] [INFO] [m] msg").is_none()); assert!(LogManager::parse_line("[2026-08-05 10:00:00] [NOPE] [m] msg").is_none()); // 时间戳不做格式校验:仅按方括号切分,日期形式同样可解析 assert!(LogManager::parse_line("[2026-08-05] [INFO] [m] msg").is_some()); } #[test] fn rotate_shifts_files_and_caps_count() { let dir = temp_dir("rotate"); let _guard = TempGuard(dir.clone()); let mgr = LogManager::new(dir.clone()); let current = dir.join("thing.log"); fs::write(¤t, "content-0").unwrap(); mgr.rotate(); // thing.log → thing.1.log assert!(!current.exists()); assert_eq!( fs::read_to_string(dir.join("thing.1.log")).unwrap(), "content-0" ); // 第二次轮转:thing.1.log → thing.2.log,新 thing.log → thing.1.log fs::write(¤t, "content-1").unwrap(); mgr.rotate(); assert_eq!( fs::read_to_string(dir.join("thing.2.log")).unwrap(), "content-0" ); assert_eq!( fs::read_to_string(dir.join("thing.1.log")).unwrap(), "content-1" ); // 轮转超过 max_files(5) 后最旧文件被删除,文件数量不超上限 for i in 0..6 { fs::write(¤t, format!("content-{}", i)).unwrap(); mgr.rotate(); } let files: Vec = fs::read_dir(&dir) .unwrap() .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) .filter(|n| n.ends_with(".log")) .collect(); assert!(files.len() <= 5, "轮转文件数量超上限: {:?}", files); assert!(dir.join("thing.1.log").exists(), "最新的旋转文件应存在"); } #[test] fn log_writes_and_get_logs_filters() { let dir = temp_dir("query"); let _guard = TempGuard(dir.clone()); let mgr = LogManager::new(dir.clone()); mgr.log(LogLevel::Info, "downloader", "任务开始"); mgr.log(LogLevel::Error, "downloader", "任务失败"); mgr.log(LogLevel::Info, "proxy", "节点切换"); // 全部(新到旧) let all = mgr.get_logs(None, None, None); assert_eq!(all.len(), 3); assert_eq!(all[0].message, "节点切换"); assert_eq!(all[2].message, "任务开始"); // 按模块过滤 let dl = mgr.get_logs(Some("downloader"), None, None); assert_eq!(dl.len(), 2); assert!(dl.iter().all(|e| e.module == "downloader")); // 按级别过滤 let errs = mgr.get_logs(None, Some(LogLevel::Error), None); assert_eq!(errs.len(), 1); assert_eq!(errs[0].message, "任务失败"); // 条数限制 let limited = mgr.get_logs(None, None, Some(2)); assert_eq!(limited.len(), 2); } #[test] fn auto_rotate_triggers_on_size() { let dir = temp_dir("auto"); let _guard = TempGuard(dir.clone()); let mgr = LogManager::new(dir.clone()); // 写满 5MB 触发自动轮转(meta.len() >= max_file_size) let current = dir.join("thing.log"); let mut f = OpenOptions::new() .create(true) .append(true) .open(¤t) .unwrap(); let big = "x".repeat(5 * 1024 * 1024); f.write_all(big.as_bytes()).unwrap(); drop(f); mgr.log(LogLevel::Info, "test", "触发轮转"); assert!(dir.join("thing.1.log").exists(), "应自动轮转出 thing.1.log"); // 轮转后旧文件(5MB)被重命名为 thing.1.log,当前文件只含新追加的一行 let rotated_len = fs::metadata(dir.join("thing.1.log")).unwrap().len(); assert_eq!(rotated_len, 5 * 1024 * 1024, "轮转出的文件应保留完整旧内容"); let cur_len = fs::metadata(¤t).unwrap().len(); assert!(cur_len < 100, "轮转后的当前文件应只含新行: {}", cur_len); } }