From 8ba279e957931b05b0f22d399a18fa9d4c339e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=AF=9A?= Date: Tue, 30 Dec 2025 11:07:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=92=8C=E6=B8=85=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B5=81=E5=BC=8F=E8=AF=BB=E5=8F=96=E5=92=8C?= =?UTF-8?q?=E5=AE=9A=E6=9C=9F=E6=B8=85=E7=90=86=E6=97=A5=E5=BF=97=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Service/LogCleanupService.cs | 106 +++++++++++++++ Web/src/views/LogView.vue | 13 +- WebApi/Controllers/LogController.cs | 199 +++++++++++++++++++++++----- WebApi/Program.cs | 5 +- WebApi/appsettings.json | 3 +- 5 files changed, 287 insertions(+), 39 deletions(-) create mode 100644 Service/LogCleanupService.cs diff --git a/Service/LogCleanupService.cs b/Service/LogCleanupService.cs new file mode 100644 index 0000000..6d38ab2 --- /dev/null +++ b/Service/LogCleanupService.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Hosting; + +namespace Service; + +/// +/// 日志清理后台服务 +/// +public class LogCleanupService(ILogger logger) : BackgroundService +{ + private readonly TimeSpan _checkInterval = TimeSpan.FromHours(24); // 每24小时检查一次 + private const int RetentionDays = 30; // 保留30天的日志 + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("日志清理服务已启动"); + + // 启动时立即执行一次清理 + await CleanupOldLogsAsync(); + + // 定期清理 + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(_checkInterval, stoppingToken); + await CleanupOldLogsAsync(); + } + catch (OperationCanceledException) + { + // 服务正在停止 + break; + } + catch (Exception ex) + { + logger.LogError(ex, "清理日志时发生错误"); + } + } + + logger.LogInformation("日志清理服务已停止"); + } + + /// + /// 清理过期的日志文件 + /// + private async Task CleanupOldLogsAsync() + { + await Task.Run(() => + { + try + { + var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs"); + if (!Directory.Exists(logDirectory)) + { + logger.LogWarning("日志目录不存在: {LogDirectory}", logDirectory); + return; + } + + var cutoffDate = DateTime.Now.AddDays(-RetentionDays); + var logFiles = Directory.GetFiles(logDirectory, "log-*.txt"); + var deletedCount = 0; + + foreach (var logFile in logFiles) + { + try + { + var fileName = Path.GetFileNameWithoutExtension(logFile); + var dateStr = fileName.Replace("log-", ""); + + // 尝试解析日期 (格式: yyyyMMdd) + if (DateTime.TryParseExact(dateStr, "yyyyMMdd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var logDate)) + { + if (logDate < cutoffDate) + { + File.Delete(logFile); + deletedCount++; + logger.LogInformation("已删除过期日志文件: {LogFile} (日期: {LogDate})", + Path.GetFileName(logFile), logDate.ToString("yyyy-MM-dd")); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "删除日志文件失败: {LogFile}", logFile); + } + } + + if (deletedCount > 0) + { + logger.LogInformation("日志清理完成,共删除 {DeletedCount} 个过期日志文件(保留 {RetentionDays} 天)", + deletedCount, RetentionDays); + } + else + { + logger.LogDebug("没有需要清理的过期日志文件"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "清理日志过程中发生错误"); + } + }); + } +} diff --git a/Web/src/views/LogView.vue b/Web/src/views/LogView.vue index 01166a5..49b261e 100644 --- a/Web/src/views/LogView.vue +++ b/Web/src/views/LogView.vue @@ -185,10 +185,17 @@ const loadLogs = async (reset = false) => { total.value = response.total // 判断是否还有更多数据 - if (logList.value.length >= total.value || newLogs.length < pageSize.value) { - finished.value = true + // total = -1 表示总数未知,此时只根据返回数据量判断 + if (total.value === -1) { + // 如果返回的数据少于请求的数量,说明没有更多了 + finished.value = newLogs.length < pageSize.value } else { - finished.value = false + // 如果有明确的总数,则判断是否已加载完全部数据 + if (logList.value.length >= total.value || newLogs.length < pageSize.value) { + finished.value = true + } else { + finished.value = false + } } } else { showToast(response.message || '获取日志失败') diff --git a/WebApi/Controllers/LogController.cs b/WebApi/Controllers/LogController.cs index 160abf8..bd46392 100644 --- a/WebApi/Controllers/LogController.cs +++ b/WebApi/Controllers/LogController.cs @@ -56,41 +56,15 @@ public class LogController(ILogger logger) : ControllerBase }; } - // 读取所有日志行(使用共享读取模式,允许其他进程写入) - var allLines = await ReadAllLinesAsync(logFilePath); - var logEntries = new List(); + // 流式读取日志(边读边过滤,满足条件后停止) + var (logEntries, total) = await ReadLogsStreamAsync( + logFilePath, + pageIndex, + pageSize, + searchKeyword, + logLevel); - foreach (var line in allLines) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - - var logEntry = ParseLogLine(line); - if (logEntry != null) - { - // 应用筛选条件 - if (!string.IsNullOrEmpty(searchKeyword) && - !logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!string.IsNullOrEmpty(logLevel) && - !logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - logEntries.Add(logEntry); - } - } - - // 倒序排列(最新的在前面) - logEntries.Reverse(); - - var total = logEntries.Count; - var skip = (pageIndex - 1) * pageSize; - var pagedData = logEntries.Skip(skip).Take(pageSize).ToList(); + var pagedData = logEntries; return new PagedResponse { @@ -143,6 +117,55 @@ public class LogController(ILogger logger) : ControllerBase } } + /// + /// 合并多行日志(已废弃,现在在流式读取中处理) + /// + [Obsolete("Use ReadLogsStreamAsync instead")] + private List MergeMultiLineLog(string[] lines) + { + var mergedLines = new List(); + var currentLog = new System.Text.StringBuilder(); + + // 日志行开始的正则表达式 + var logStartPattern = new System.Text.RegularExpressions.Regex( + @"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}\]" + ); + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + // 检查是否是新的日志条目 + if (logStartPattern.IsMatch(line)) + { + // 保存之前的日志 + if (currentLog.Length > 0) + { + mergedLines.Add(currentLog.ToString()); + currentLog.Clear(); + } + currentLog.Append(line); + } + else + { + // 这是上一条日志的延续,添加换行符后追加 + if (currentLog.Length > 0) + { + currentLog.Append('\n').Append(line); + } + } + } + + // 添加最后一条日志 + if (currentLog.Length > 0) + { + mergedLines.Add(currentLog.ToString()); + } + + return mergedLines; + } + /// /// 解析单行日志 /// @@ -181,6 +204,114 @@ public class LogController(ILogger logger) : ControllerBase } } + /// + /// 流式读取日志(真正的流式:只读取需要的数据,满足后立即停止) + /// + private async Task<(List entries, int total)> ReadLogsStreamAsync( + string path, + int pageIndex, + int pageSize, + string? searchKeyword, + string? logLevel) + { + var filteredEntries = new List(); + var currentLog = new System.Text.StringBuilder(); + var logStartPattern = new System.Text.RegularExpressions.Regex( + @"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}\]"); + + // 计算需要读取的最大条目数(取最近的N条日志用于倒序分页) + // 由于日志倒序显示,我们读取足够的数据以覆盖当前页 + var maxEntriesToRead = pageIndex * pageSize + pageSize; // 多读一页用于判断是否有下一页 + + using var fileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using var streamReader = new StreamReader(fileStream); + + string? line; + var readCount = 0; + + while ((line = await streamReader.ReadLineAsync()) != null) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + // 检查是否是新的日志条目 + if (logStartPattern.IsMatch(line)) + { + // 处理之前累积的日志 + if (currentLog.Length > 0) + { + var logEntry = ParseLogLine(currentLog.ToString()); + if (logEntry != null && PassFilter(logEntry, searchKeyword, logLevel)) + { + filteredEntries.Add(logEntry); + readCount++; + + // 如果已读取足够数据,提前退出 + if (readCount >= maxEntriesToRead) + { + break; + } + } + currentLog.Clear(); + } + currentLog.Append(line); + } + else + { + // 这是上一条日志的延续 + if (currentLog.Length > 0) + { + currentLog.Append('\n').Append(line); + } + } + } + + // 处理最后一条日志(如果循环正常结束或刚好在日志边界退出) + if (currentLog.Length > 0 && readCount < maxEntriesToRead) + { + var logEntry = ParseLogLine(currentLog.ToString()); + if (logEntry != null && PassFilter(logEntry, searchKeyword, logLevel)) + { + filteredEntries.Add(logEntry); + } + } + + // 倒序排列(最新的在前面) + filteredEntries.Reverse(); + + // 计算分页 + var skip = (pageIndex - 1) * pageSize; + var pagedData = filteredEntries.Skip(skip).Take(pageSize).ToList(); + + // total 返回 -1 表示未知(避免扫描整个文件) + // 前端可以根据返回数据量判断是否有下一页 + return (pagedData, -1); + } + + /// + /// 检查日志条目是否通过过滤条件 + /// + private bool PassFilter(LogEntry logEntry, string? searchKeyword, string? logLevel) + { + if (!string.IsNullOrEmpty(searchKeyword) && + !logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrEmpty(logLevel) && + !logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + /// /// 读取文件所有行(支持共享读取) /// diff --git a/WebApi/Program.cs b/WebApi/Program.cs index 6649257..7e06f8c 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -87,7 +87,7 @@ var fsql = new FreeSqlBuilder() .UseMonitorCommand( cmd => { - Log.Information("执行SQL: {Sql}", cmd.CommandText); + Log.Debug("执行SQL: {Sql}", cmd.CommandText); } ) .Build(); @@ -97,6 +97,9 @@ builder.Services.AddSingleton(fsql); // 自动扫描注册服务和仓储 builder.Services.AddServices(); +// 注册日志清理后台服务 +builder.Services.AddHostedService(); + // 配置 Quartz.NET 定时任务 builder.AddScheduler(); diff --git a/WebApi/appsettings.json b/WebApi/appsettings.json index cbc1fe5..8945721 100644 --- a/WebApi/appsettings.json +++ b/WebApi/appsettings.json @@ -11,7 +11,7 @@ }, "Serilog": { "MinimumLevel": { - "Default": "Information", + "Default": "Debug", "Override": { "Microsoft": "Warning", "Microsoft.EntityFrameworkCore": "Warning" @@ -26,6 +26,7 @@ "Args": { "path": "logs/log-.txt", "rollingInterval": "Day", + "retainedFileCountLimit": 30, "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}" } }