559 lines
18 KiB
Rust
559 lines
18 KiB
Rust
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<String>,
|
||
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<Mutex<()>>,
|
||
}
|
||
|
||
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<LogLevel>,
|
||
limit: Option<usize>,
|
||
) -> Vec<LogEntry> {
|
||
let limit = limit.unwrap_or(100);
|
||
// 与写入共用同一把锁,避免读到轮转半途/写入半行的状态
|
||
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||
|
||
let mut entries: Vec<LogEntry> = Vec::new();
|
||
|
||
// 收集日志文件(最新在前:thing.log → thing.1.log → …)
|
||
let mut paths: Vec<PathBuf> = 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<LogEntry> {
|
||
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<String> = 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<String> {
|
||
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<String> = 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<String>,
|
||
level: Option<String>,
|
||
limit: Option<usize>,
|
||
) -> Result<Vec<LogEntry>, 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<LogManager> = 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<String> = 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);
|
||
}
|
||
}
|