From 4ca5a5afeb8300bc690ad2dce7b7eaf28619d160 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Mon, 8 Jun 2026 16:09:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/logs):=20=E5=86=85=E6=A0=B8=20DLog?= =?UTF-8?q?=20=E6=97=A5=E5=BF=97=E7=AE=A1=E7=90=86=20API=20=E4=B8=8E?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E5=86=85=E5=AE=B9=E8=AF=BB=E5=8F=96=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogsController 暴露 SimpleLite 落盘 DLog(概览/文件浏览/条目/合订/分析/原文/下载), 含路径穿越防护、单文件与跨文件聚合的条数/字节上限,鉴权收紧为 PlatformScope(仅平台后台用户); MapsContentController 经投影 API 定位地图目录后读取地图 JSON,含路径穿越校验。 Co-authored-by: Cursor --- MiGu.Server/Controllers/LogsController.cs | 836 ++++++++++++++++++ .../Controllers/MapsContentController.cs | 108 +++ 2 files changed, 944 insertions(+) create mode 100644 MiGu.Server/Controllers/LogsController.cs create mode 100644 MiGu.Server/Controllers/MapsContentController.cs diff --git a/MiGu.Server/Controllers/LogsController.cs b/MiGu.Server/Controllers/LogsController.cs new file mode 100644 index 0000000..2ac880b --- /dev/null +++ b/MiGu.Server/Controllers/LogsController.cs @@ -0,0 +1,836 @@ +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.Add(e); + // 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆。 + if (b.Recent.Count > maxPerTag) b.Recent.RemoveAt(0); + } + + 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 List Recent { get; } = new(); + } +} diff --git a/MiGu.Server/Controllers/MapsContentController.cs b/MiGu.Server/Controllers/MapsContentController.cs new file mode 100644 index 0000000..9c0c278 --- /dev/null +++ b/MiGu.Server/Controllers/MapsContentController.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using MiGu.Server.Auth; +using MiGu.Server.Launcher; + +namespace MiGu.Server.Controllers; + +/// +/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。 +/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。 +/// +[ApiController] +[Authorize] +[Route("api/maps")] +public class MapsContentController : ControllerBase +{ + private readonly IHttpClientFactory _httpFactory; + private readonly InternalTokenStore _internalToken; + private readonly SimpleLiteOptions _sl; + private readonly ILogger _log; + + public MapsContentController( + IHttpClientFactory httpFactory, + InternalTokenStore internalToken, + IOptions sl, + ILogger log) + { + _httpFactory = httpFactory; + _internalToken = internalToken; + _sl = sl.Value; + _log = log; + } + + [HttpGet("{name}/content")] + public async Task GetContent(string name, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name) + || name.Contains("..", StringComparison.Ordinal) + || name.IndexOfAny(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) >= 0) + { + return BadRequest(new { message = "地图名称非法" }); + } + + try + { + var directory = await FetchMapsDirectoryAsync(ct); + if (string.IsNullOrWhiteSpace(directory)) + return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" }); + + var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json"; + var fullPath = Path.GetFullPath(Path.Combine(directory, fileName)); + var root = Path.GetFullPath(directory); + if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + return BadRequest(new { message = "路径校验失败" }); + + if (!System.IO.File.Exists(fullPath)) + return NotFound(new { message = $"地图文件不存在:{fileName}" }); + + var content = await System.IO.File.ReadAllTextAsync(fullPath, ct); + return Ok(new + { + name, + fileName, + path = fullPath, + content + }); + } + catch (Exception ex) + { + _log.LogWarning(ex, "读取地图 JSON 失败 name={Name}", name); + return StatusCode(500, new { message = $"读取地图 JSON 失败:{ex.Message}" }); + } + } + + private async Task FetchMapsDirectoryAsync(CancellationToken ct) + { + var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/map-edit/maps"; + using var msg = new HttpRequestMessage(HttpMethod.Get, url); + if (!string.IsNullOrEmpty(_internalToken.Token)) + msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token); + + using var resp = await client.SendAsync(msg, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"SimpleLite maps 列表返回 {(int)resp.StatusCode}"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.TryGetProperty("success", out var ok) && ok.ValueKind == JsonValueKind.False) + { + var message = root.TryGetProperty("message", out var m) ? m.GetString() : "maps 列表失败"; + throw new InvalidOperationException(message ?? "maps 列表失败"); + } + + JsonElement data = root; + if (root.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object) + data = d; + + if (data.TryGetProperty("directory", out var dir) && dir.ValueKind == JsonValueKind.String) + return dir.GetString(); + + return null; + } +}