using System.Globalization; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MiGu.Server.Launcher; namespace MiGu.Server.Controllers; /// /// 平台「日志管理」后端:把 SimpleLite 内核 SimpleCore.Library.Diagnosis 的落盘日志 /// (Diagnosis.Post / Diagnosis.Log 写入的 log/**/*.log,俗称 DLog)暴露给 /// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 SimpleLite/UI/LogViewer.cs /// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。 /// /// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,不依赖 SimpleLite 是否在运行, /// MiGu.Server 通过 定位工作目录后直接读 /// {工作目录}/log/。可用 appsettings Logs:Root 显式覆盖日志根目录。 /// /// 落盘行格式(见 Diagnosis.Log):[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}, /// 其中 tag 为 / 表示无标签(滚动记录)。不匹配该模式的行视为上一条的续行(多行内容)。 /// /// 鉴权:class 级 [Authorize(Policy = "PlatformScope")] —— 日志为内核落盘文件(可能含文件路径 / /// 内部运行状态等敏感信息),仅平台后台用户(scope=Platform)可读 / 下载,排除 RCSMonitor 监控大屏 token。 /// [ApiController] [Authorize(Policy = "PlatformScope")] [Route("api/logs")] public sealed class LogsController : ControllerBase { /// 文件列表默认上限(与 LogViewer.cs 的 MaxFilesShown=500 对齐)。 private const int DefaultFileLimit = 500; /// 单次解析的条目硬上限,防超大日志(实测单文件可达 46MB)撑爆内存。 private const int MaxEntries = 200_000; /// 单次扫描字节上限(超过则截断并标记 truncated)。 private const long MaxScanBytes = 96L * 1024 * 1024; /// 合订/某天聚合时最多遍历的文件数,避免一次扫描整月日志。 private const int MaxDigestFiles = 64; /// 跨文件聚合(analyze/digest 的 day 模式)的总条目上限,防某天多个大文件 entries 累加撑爆内存。 private const int MaxAggregateEntries = 300_000; /// Diagnosis 落盘行:[head] >tag: content;head 内含可选 prefix + 时间戳。 private static readonly Regex LineRegex = new(@"^\[(?[^\]]*)\]\s*>(?.*?):\s?(?.*)$", RegexOptions.Compiled); /// 从 head 里抠出时间戳(prefix = 时间戳之前的部分)。 private static readonly Regex TimeRegex = new(@"\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}", RegexOptions.Compiled); private const string TimeFormat = "yyyy/MM/dd-HH:mm:ss.fff"; /// /// 内容里的「数值字段」:key=valuekey: value,value 为数字(可带小数/负号)。 /// key 必须以字母/下划线/中文开头,避免把 12:30 这类时间误判为字段。供「日志分析器」识别可绘图字段。 /// private static readonly Regex NumericFieldRegex = new(@"(?[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?-?\d+(?:\.\d+)?)", RegexOptions.Compiled); private readonly SimpleLiteLauncher _launcher; private readonly IConfiguration _config; private readonly ILogger _log; public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger log) { _launcher = launcher; _config = config; _log = log; } // ─────────────────────────────────────────────────────────── 概览 ── /// 日志根概览:工作目录、根路径、是否存在、文件/字节总量、按天分组统计。 [HttpGet("overview")] public IActionResult Overview() { var (root, workdir, error) = ResolveLogRoot(); if (root == null) return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error }); if (!Directory.Exists(root)) return Ok(new { exists = false, root, workingDirectory = workdir, totalFiles = 0, totalBytes = 0L, days = Array.Empty(), message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。" }); var files = EnumerateLogFiles(root); var days = files .GroupBy(f => f.Day) .Select(g => new { day = g.Key, files = g.Count(), bytes = g.Sum(x => x.Bytes) }) .OrderByDescending(d => d.day, StringComparer.Ordinal) .ToList(); return Ok(new { exists = true, root, workingDirectory = workdir, totalFiles = files.Count, totalBytes = files.Sum(f => f.Bytes), latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null, days }); } // ────────────────────────────────────────────────────── 文件列表 ── /// 列出落盘日志文件(递归 log/),可按天 / 文件名关键字过滤,按修改时间降序。 [HttpGet("files")] public IActionResult Files([FromQuery] string? day, [FromQuery] string? keyword, [FromQuery] int limit = DefaultFileLimit) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); if (!Directory.Exists(root)) return Ok(new { root, total = 0, returned = 0, files = Array.Empty() }); var clamp = Math.Clamp(limit, 1, 5000); IEnumerable q = EnumerateLogFiles(root); if (!string.IsNullOrWhiteSpace(day)) q = q.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(f => f.Rel.Contains(keyword, StringComparison.OrdinalIgnoreCase)); var all = q.OrderByDescending(f => f.Mtime).ToList(); var page = all.Take(clamp).Select(f => new { rel = f.Rel, name = f.Name, day = f.Day, dir = f.Dir, bytes = f.Bytes, mtime = f.Mtime }).ToList(); return Ok(new { root, total = all.Count, returned = page.Count, files = page }); } // ──────────────────────────────────────────────────── 目录浏览 ── /// /// 文件夹 / 文件浏览器:列出 log/ 下指定相对目录的直接子项(子文件夹 + 文件),可逐层进入。 /// path 为空 = 日志根。对齐用户诉求「直接显示 log 下所有文件夹和文件,可进入文件夹、打开某个日志文件」。 /// 子文件夹附带其下一层的子目录/文件计数,文件附带大小、修改时间与是否 .log。 /// [HttpGet("browse")] public IActionResult Browse([FromQuery] string? path) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var normRoot = Path.GetFullPath(root); if (!Directory.Exists(normRoot)) return Ok(new { root = normRoot, exists = false, path = "", parent = (string?)null, dirCount = 0, fileCount = 0, dirs = Array.Empty(), files = Array.Empty(), message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。" }); var target = SafeResolveDir(normRoot, path); if (target == null) return BadRequest(new { message = "非法的目录路径" }); if (!Directory.Exists(target)) return NotFound(new { message = "目录不存在" }); var rel = NormalizeRel(Path.GetRelativePath(normRoot, target)); var parent = string.IsNullOrEmpty(rel) ? (string?)null : NormalizeRel(Path.GetDirectoryName(rel) ?? ""); var dirs = new List(); var files = new List(); try { var di = new DirectoryInfo(target); foreach (var sub in di.GetDirectories().OrderByDescending(x => x.LastWriteTime)) { int childDirs = 0, childFiles = 0; try { childDirs = sub.GetDirectories().Length; } catch { /* 无权限/并发删除:计数视为 0 */ } try { childFiles = sub.GetFiles().Length; } catch { /* 同上 */ } dirs.Add(new { name = sub.Name, rel = NormalizeRel(Path.GetRelativePath(normRoot, sub.FullName)), mtime = sub.LastWriteTime, dirCount = childDirs, fileCount = childFiles }); } foreach (var f in di.GetFiles().OrderByDescending(x => x.LastWriteTime)) { files.Add(new { name = f.Name, rel = NormalizeRel(Path.GetRelativePath(normRoot, f.FullName)), bytes = f.Length, mtime = f.LastWriteTime, isLog = f.Extension.Equals(".log", StringComparison.OrdinalIgnoreCase) }); } } catch (Exception ex) { _log.LogWarning(ex, "浏览日志目录失败 path={Path}", path); return Problem($"读取目录失败:{ex.Message}", statusCode: 500); } return Ok(new { root = normRoot, exists = true, path = rel, parent, dirCount = dirs.Count, fileCount = files.Count, dirs, files }); } // ──────────────────────────────────────────────────── 条目(分页)── /// /// 解析单个日志文件为结构化条目(时间 / 标签 / 内容),支持关键字 / 标签 / 仅带标签过滤、 /// 升降序与分页。超大文件按 / 截断并回 truncated。 /// [HttpGet("entries")] public IActionResult Entries( [FromQuery] string file, [FromQuery] string? keyword, [FromQuery] string? tag, [FromQuery] bool onlyTagged = false, [FromQuery] string order = "desc", [FromQuery] int limit = 300, [FromQuery] int offset = 0) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var full = SafeResolve(root, file); if (full == null) return BadRequest(new { message = "非法的文件路径" }); if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" }); var parsed = ParseFile(full); IEnumerable q = parsed.Entries; if (onlyTagged) q = q.Where(e => !string.IsNullOrEmpty(e.Tag)); if (!string.IsNullOrWhiteSpace(tag)) q = q.Where(e => e.Tag.Contains(tag, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase) || e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)); var filtered = q.ToList(); if (!string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase)) filtered.Reverse(); var clampLimit = Math.Clamp(limit, 1, 2000); var clampOffset = Math.Max(0, offset); var page = filtered.Skip(clampOffset).Take(clampLimit).Select(Project).ToList(); return Ok(new { file, bytes = parsed.Bytes, scannedLines = parsed.ScannedLines, truncated = parsed.Truncated, total = filtered.Count, offset = clampOffset, limit = clampLimit, order = order.ToLowerInvariant(), entries = page }); } // ─────────────────────────────────────────────────────── 合订本 ── /// /// 「合订本」:把带标签的 Post/Toast 按标签聚合成一册(条数 + 时间范围 + 最新内容 + 最近若干条), /// 无标签的归入「滚动记录」。对齐用户诉求「post 和 toast 如果有标签需要是合订本的形式」。 /// 范围二选一:file(单文件)或 day(某天全部文件,最多 个)。 /// [HttpGet("digest")] public IActionResult Digest( [FromQuery] string? file, [FromQuery] string? day, [FromQuery] string? keyword, [FromQuery] int maxPerTag = 100) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var targets = new List(); string sourceLabel; if (!string.IsNullOrWhiteSpace(file)) { var full = SafeResolve(root, file); if (full == null) return BadRequest(new { message = "非法的文件路径" }); if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" }); targets.Add(full); sourceLabel = file; } else if (!string.IsNullOrWhiteSpace(day)) { if (!Directory.Exists(root)) return Ok(EmptyDigest("day", day)); targets = EnumerateLogFiles(root) .Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase)) .OrderBy(f => f.Mtime) .Take(MaxDigestFiles) .Select(f => f.Full) .ToList(); sourceLabel = day; } else { return BadRequest(new { message = "请提供 file 或 day 之一作为合订范围" }); } var clampPerTag = Math.Clamp(maxPerTag, 1, 1000); var books = new Dictionary(StringComparer.Ordinal); var untagged = new Book { Tag = "" }; bool truncated = false; long scanned = 0; foreach (var f in targets) { var parsed = ParseFile(f); truncated |= parsed.Truncated; scanned += parsed.Bytes; foreach (var e in parsed.Entries) { if (!string.IsNullOrWhiteSpace(keyword) && !e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase) && !e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)) continue; if (string.IsNullOrEmpty(e.Tag)) { Accumulate(untagged, e, clampPerTag); } else { if (!books.TryGetValue(e.Tag, out var b)) { b = new Book { Tag = e.Tag }; books[e.Tag] = b; } Accumulate(b, e, clampPerTag); } } // 跨文件总字节熔断:合订各 Book 已有 maxPerTag 上限,这里再防 64 个大文件把 CPU 拉满。 if (scanned >= MaxScanBytes) { truncated = true; break; } } var bookList = books.Values .OrderByDescending(b => b.LastTime ?? DateTime.MinValue) .Select(b => ToBookDto(b)) .ToList(); return Ok(new { source = string.IsNullOrWhiteSpace(file) ? "day" : "file", target = sourceLabel, files = targets.Count, truncated, tagCount = bookList.Count, untaggedCount = untagged.Count, books = bookList, untagged = ToBookDto(untagged) }); } // ─────────────────────────────────────────────────────── 日志分析 ── /// /// 「日志分析器」:解析单文件(file)或某天(day)日志,产出图表所需的聚合数据: /// /// 标签分布 tags:各标签条数 + 占比(含「滚动记录」即无标签); /// 日志量直方图 volume:按 granularity(second/minute/hour) 分桶的总量 + Top6 标签拆分(稀疏桶,仅含有数据的时刻); /// 数值字段识别 fields:从内容里抽取 key=value/key:value 的数值字段,给出样本数/最小/最大/均值/最后值; /// 字段时序 series:当指定 field 时,返回该字段的 (时间, 值) 点序列(超量自动抽稀)。 /// /// keyword 过滤全部统计;tag 仅聚焦「字段识别 + 字段时序」(不影响标签分布/直方图,便于先看全貌再下钻)。 /// [HttpGet("analyze")] public IActionResult Analyze( [FromQuery] string? file, [FromQuery] string? day, [FromQuery] string granularity = "minute", [FromQuery] string? tag = null, [FromQuery] string? keyword = null, [FromQuery] string? field = null) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var gran = (granularity ?? "minute").ToLowerInvariant() switch { "second" or "sec" or "s" => Gran.Second, "hour" or "h" => Gran.Hour, _ => Gran.Minute }; var granName = gran.ToString().ToLowerInvariant(); var targets = new List(); string label; string source; if (!string.IsNullOrWhiteSpace(file)) { var full = SafeResolve(root, file); if (full == null) return BadRequest(new { message = "非法的文件路径" }); if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" }); targets.Add(full); label = file; source = "file"; } else if (!string.IsNullOrWhiteSpace(day)) { if (!Directory.Exists(root)) return Ok(EmptyAnalysis("day", day, granName)); targets = EnumerateLogFiles(root) .Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase)) .OrderBy(f => f.Mtime).Take(MaxDigestFiles).Select(f => f.Full).ToList(); label = day; source = "day"; } else { return BadRequest(new { message = "请提供 file 或 day 之一作为分析范围" }); } var all = new List(); bool truncated = false; long aggBytes = 0; foreach (var f in targets) { var parsed = ParseFile(f); truncated |= parsed.Truncated; aggBytes += parsed.Bytes; // 跨文件聚合熔断:总条数 / 总字节达上限即停止纳入并标记 truncated(图表基于已采样数据), // 避免某天多个大文件把全部 entries 堆进内存。 var room = MaxAggregateEntries - all.Count; if (room <= 0) { truncated = true; break; } if (parsed.Entries.Count > room) { all.AddRange(parsed.Entries.GetRange(0, room)); truncated = true; break; } all.AddRange(parsed.Entries); if (aggBytes >= MaxScanBytes) { truncated = true; break; } } if (!string.IsNullOrWhiteSpace(keyword)) all = all.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase) || e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); var total = all.Count; // ── 1) 标签分布(全量)── var tagCounts = new Dictionary(StringComparer.Ordinal); foreach (var e in all) { var key = e.Tag ?? ""; tagCounts.TryGetValue(key, out var c); tagCounts[key] = c + 1; } var tags = tagCounts.OrderByDescending(kv => kv.Value) .Select(kv => new { tag = kv.Key, count = kv.Value, percent = total > 0 ? Math.Round(kv.Value * 100.0 / total, 2) : 0 }).ToList(); // ── 2) 时间范围 + 日志量直方图(稀疏桶 + Top6 标签拆分)── var timed = all.Where(e => e.Time != null).Select(e => e.Time!.Value).ToList(); DateTime? start = timed.Count > 0 ? timed.Min() : (DateTime?)null; DateTime? end = timed.Count > 0 ? timed.Max() : (DateTime?)null; var topTagNames = tags.Where(t => t.tag != "").Take(6).Select(t => t.tag).ToList(); var topSet = new HashSet(topTagNames, StringComparer.Ordinal); var totalBuckets = new SortedDictionary(); var tagBuckets = topTagNames.ToDictionary(t => t, _ => new Dictionary(), StringComparer.Ordinal); foreach (var e in all) { if (e.Time == null) continue; var b = TruncateTime(e.Time.Value, gran); totalBuckets.TryGetValue(b, out var c); totalBuckets[b] = c + 1; var tg = e.Tag ?? ""; if (topSet.Contains(tg)) { var d = tagBuckets[tg]; d.TryGetValue(b, out var c2); d[b] = c2 + 1; } } var bucketTimes = totalBuckets.Keys.ToList(); var volume = new { granularity = granName, buckets = bucketTimes, total = bucketTimes.Select(t => totalBuckets[t]).ToList(), topTags = topTagNames.Select(tg => new { tag = tg, counts = bucketTimes.Select(t => tagBuckets[tg].TryGetValue(t, out var v) ? v : 0).ToList() }).ToList() }; // ── 3) 数值字段识别(受 tag 聚焦影响)── IEnumerable scope = all; if (!string.IsNullOrWhiteSpace(tag)) scope = all.Where(e => string.Equals(e.Tag, tag, StringComparison.Ordinal)).ToList(); var fieldAgg = new Dictionary(StringComparer.Ordinal); foreach (var e in scope) { foreach (Match m in NumericFieldRegex.Matches(e.Content)) { if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue; var k = m.Groups["k"].Value; if (!fieldAgg.TryGetValue(k, out var fs)) { fs = new FieldStat(); fieldAgg[k] = fs; } fs.N++; fs.Sum += v; fs.Last = v; if (v < fs.Min) fs.Min = v; if (v > fs.Max) fs.Max = v; } } var fields = fieldAgg.OrderByDescending(kv => kv.Value.N).Take(40).Select(kv => new { name = kv.Key, samples = kv.Value.N, min = kv.Value.Min, max = kv.Value.Max, avg = kv.Value.N > 0 ? Math.Round(kv.Value.Sum / kv.Value.N, 4) : 0, last = kv.Value.Last }).ToList(); // ── 4) 选定字段时序(超量抽稀,保留分布形态)── object? series = null; if (!string.IsNullOrWhiteSpace(field)) { var pts = new List<(DateTime t, double v)>(); foreach (var e in scope) { if (e.Time == null) continue; foreach (Match m in NumericFieldRegex.Matches(e.Content)) { if (!string.Equals(m.Groups["k"].Value, field, StringComparison.Ordinal)) continue; if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue; pts.Add((e.Time.Value, v)); } } const int cap = 8000; if (pts.Count > cap) { var stride = (int)Math.Ceiling(pts.Count / (double)cap); pts = pts.Where((_, i) => i % stride == 0).ToList(); } series = new { field, tag = tag ?? "", count = pts.Count, points = pts.Select(p => new { t = p.t, v = p.v }).ToList() }; } return Ok(new { source, target = label, files = targets.Count, truncated, total, timeRange = new { start, end }, granularity = granName, tags, volume, fields, series }); } // ──────────────────────────────────────────────── 原文 / 下载 ── /// 返回日志文件尾部 N 行原文(默认 2000 行),用于「查看原始日志」视图。 [HttpGet("raw")] public IActionResult Raw([FromQuery] string file, [FromQuery] int tail = 2000) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var full = SafeResolve(root, file); if (full == null) return BadRequest(new { message = "非法的文件路径" }); if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" }); var clamp = Math.Clamp(tail, 1, 50000); try { var lines = System.IO.File.ReadLines(full).TakeLast(clamp).ToList(); var text = string.Join("\n", lines); return Content(text, "text/plain; charset=utf-8"); } catch (Exception ex) { _log.LogWarning(ex, "读取日志原文失败 file={File}", file); return Problem($"读取失败:{ex.Message}", statusCode: 500); } } /// 下载原始日志文件。 [HttpGet("download")] public IActionResult Download([FromQuery] string file) { var (root, _, error) = ResolveLogRoot(); if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503); var full = SafeResolve(root, file); if (full == null) return BadRequest(new { message = "非法的文件路径" }); if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" }); var downloadName = Path.GetFileName(full); return PhysicalFile(full, "application/octet-stream", downloadName); } // ─────────────────────────────────────────────────────── helpers ── /// 定位日志根:优先 appsettings Logs:Root,否则取 SimpleLite 工作目录下的 log private (string? root, string? workdir, string? error) ResolveLogRoot() { var overrideRoot = _config["Logs:Root"]; if (!string.IsNullOrWhiteSpace(overrideRoot)) { var r = Path.GetFullPath(overrideRoot); return (r, Path.GetDirectoryName(r), null); } var wd = _launcher.ResolveWorkingDirectory(); if (string.IsNullOrWhiteSpace(wd)) return (null, null, "未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory," + "或显式设置 Logs:Root 指向日志根目录。"); return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null); } /// 把相对路径安全解析到日志根内(防 ../ 目录穿越),并要求落在 root 子级。 private static string? SafeResolve(string root, string? rel) { if (string.IsNullOrWhiteSpace(rel)) return null; var normRoot = Path.GetFullPath(root); var full = Path.GetFullPath(Path.Combine(normRoot, rel)); if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return null; var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar; return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null; } /// 把相对目录安全解析到日志根内(允许根自身;防 ../ 目录穿越)。用于目录浏览。 private static string? SafeResolveDir(string root, string? rel) { var normRoot = Path.GetFullPath(root); if (string.IsNullOrWhiteSpace(rel) || rel == "/" || rel == ".") return normRoot; var full = Path.GetFullPath(Path.Combine(normRoot, rel)); if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return normRoot; var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar; return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null; } /// 相对路径标准化:反斜杠转正斜杠、去尾斜杠、根目录归一为空串。 private static string NormalizeRel(string rel) { if (string.IsNullOrEmpty(rel) || rel == ".") return ""; return rel.Replace('\\', '/').TrimEnd('/'); } private static List EnumerateLogFiles(string root) { var list = new List(); IEnumerable files; try { files = Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories); } catch { return list; } foreach (var f in files) { try { var fi = new FileInfo(f); var rel = Path.GetRelativePath(root, f).Replace('\\', '/'); var slash = rel.IndexOf('/'); var day = slash > 0 ? rel[..slash] : "(根目录)"; var dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? ""; list.Add(new FileMeta(rel, fi.Name, day, dir, fi.Length, fi.LastWriteTime, f)); } catch { /* 个别文件读元数据失败跳过 */ } } return list; } /// 流式解析日志文件为条目;非标准行作为上一条的续行(多行内容)。 private LogParseResult ParseFile(string full) { var entries = new List(); long bytes = 0; int lineNo = 0; long scanned = 0; bool truncated = false; try { bytes = new FileInfo(full).Length; } catch { /* ignore */ } try { foreach (var raw in System.IO.File.ReadLines(full)) { lineNo++; scanned += raw.Length + 2; // 估算含换行 var line = raw.TrimEnd('\r'); var m = LineRegex.Match(line); if (m.Success) { var head = m.Groups["head"].Value; var tm = TimeRegex.Match(head); if (tm.Success && DateTime.TryParseExact(tm.Value, TimeFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) { var tag = m.Groups["tag"].Value; if (tag == "/") tag = ""; entries.Add(new LogEntry { LineNo = lineNo, Time = dt, Prefix = head[..tm.Index], Tag = tag, Content = m.Groups["content"].Value }); if (entries.Count >= MaxEntries) { truncated = true; break; } } else { AppendContinuation(entries, line); } } else { AppendContinuation(entries, line); } if (scanned >= MaxScanBytes) { truncated = true; break; } } } catch (Exception ex) { _log.LogWarning(ex, "解析日志文件失败 file={File}", full); } return new LogParseResult(entries, bytes, lineNo, truncated); } private static void AppendContinuation(List entries, string line) { if (entries.Count == 0) { // 文件开头就是无时间戳行:作为一条无标签、无时间的内容保留。 entries.Add(new LogEntry { LineNo = 1, Time = null, Tag = "", Content = line }); return; } var last = entries[^1]; last.Content = string.IsNullOrEmpty(last.Content) ? line : last.Content + "\n" + line; } private static void Accumulate(Book b, LogEntry e, int maxPerTag) { b.Count++; if (e.Time != null) { if (b.FirstTime == null || e.Time < b.FirstTime) b.FirstTime = e.Time; if (b.LastTime == null || e.Time > b.LastTime) b.LastTime = e.Time; } b.Latest = e.Content; b.LatestTime = e.Time; b.Recent.Enqueue(e); // 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆(Queue 头部出队 O(1))。 if (b.Recent.Count > maxPerTag) b.Recent.Dequeue(); } private static object ToBookDto(Book b) => new { tag = b.Tag, count = b.Count, firstTime = b.FirstTime, lastTime = b.LastTime, latest = b.Latest, latestTime = b.LatestTime, entries = b.Recent.Select(Project).ToList() }; private static object EmptyDigest(string source, string target) => new { source, target, files = 0, truncated = false, tagCount = 0, untaggedCount = 0, books = Array.Empty(), untagged = new { tag = "", count = 0, entries = Array.Empty() } }; /// 把时间戳截断到指定粒度的桶起点(用于直方图分桶)。 private static DateTime TruncateTime(DateTime t, Gran g) => g switch { Gran.Second => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, t.Kind), Gran.Hour => new DateTime(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Kind), _ => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, 0, t.Kind) }; private static object EmptyAnalysis(string source, string target, string granName) => new { source, target, files = 0, truncated = false, total = 0, timeRange = new { start = (DateTime?)null, end = (DateTime?)null }, granularity = granName, tags = Array.Empty(), volume = new { granularity = granName, buckets = Array.Empty(), total = Array.Empty(), topTags = Array.Empty() }, fields = Array.Empty(), series = (object?)null }; private static object Project(LogEntry e) => new { lineNo = e.LineNo, time = e.Time, prefix = e.Prefix, tag = e.Tag, content = e.Content }; // ─────────────────────────────────────────────────────── 内部类型 ── private enum Gran { Second, Minute, Hour } /// 「日志分析器」数值字段的累计统计。 private sealed class FieldStat { public long N; public double Sum; public double Min = double.MaxValue; public double Max = double.MinValue; public double Last; } private readonly record struct FileMeta( string Rel, string Name, string Day, string Dir, long Bytes, DateTime Mtime, string Full); private sealed record LogParseResult(List Entries, long Bytes, int ScannedLines, bool Truncated); private sealed class LogEntry { public int LineNo { get; set; } public DateTime? Time { get; set; } public string Prefix { get; set; } = ""; public string Tag { get; set; } = ""; public string Content { get; set; } = ""; } private sealed class Book { public string Tag { get; set; } = ""; public int Count { get; set; } public DateTime? FirstTime { get; set; } public DateTime? LastTime { get; set; } public string Latest { get; set; } = ""; public DateTime? LatestTime { get; set; } public Queue Recent { get; } = new(); } }