feat(server/logs): 内核 DLog 日志管理 API 与地图内容读取接口

LogsController 暴露 SimpleLite 落盘 DLog(概览/文件浏览/条目/合订/分析/原文/下载),
含路径穿越防护、单文件与跨文件聚合的条数/字节上限,鉴权收紧为 PlatformScope(仅平台后台用户);
MapsContentController 经投影 API 定位地图目录后读取地图 JSON,含路径穿越校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-08 16:09:59 +08:00
co-authored by Cursor
parent bc1d4b8b5c
commit 4ca5a5afeb
2 changed files with 944 additions and 0 deletions
+836
View File
@@ -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;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// <c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
/// 其中 tag 为 <c>/</c> 表示无标签(滚动记录)。不匹配该模式的行视为上一条的续行(多行内容)。
///
/// 鉴权:class 级 <c>[Authorize(Policy = "PlatformScope")]</c> —— 日志为内核落盘文件(可能含文件路径 /
/// 内部运行状态等敏感信息),仅平台后台用户(scope=Platform)可读 / 下载,排除 RCSMonitor 监控大屏 token。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/logs")]
public sealed class LogsController : ControllerBase
{
/// <summary>文件列表默认上限(与 LogViewer.cs 的 MaxFilesShown=500 对齐)。</summary>
private const int DefaultFileLimit = 500;
/// <summary>单次解析的条目硬上限,防超大日志(实测单文件可达 46MB)撑爆内存。</summary>
private const int MaxEntries = 200_000;
/// <summary>单次扫描字节上限(超过则截断并标记 truncated)。</summary>
private const long MaxScanBytes = 96L * 1024 * 1024;
/// <summary>合订/某天聚合时最多遍历的文件数,避免一次扫描整月日志。</summary>
private const int MaxDigestFiles = 64;
/// <summary>跨文件聚合(analyze/digest 的 day 模式)的总条目上限,防某天多个大文件 entries 累加撑爆内存。</summary>
private const int MaxAggregateEntries = 300_000;
/// <summary>Diagnosis 落盘行:<c>[head] &gt;tag: content</c>head 内含可选 prefix + 时间戳。</summary>
private static readonly Regex LineRegex =
new(@"^\[(?<head>[^\]]*)\]\s*>(?<tag>.*?):\s?(?<content>.*)$", RegexOptions.Compiled);
/// <summary>从 head 里抠出时间戳(prefix = 时间戳之前的部分)。</summary>
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";
/// <summary>
/// 内容里的「数值字段」:<c>key=value</c> 或 <c>key: value</c>value 为数字(可带小数/负号)。
/// key 必须以字母/下划线/中文开头,避免把 <c>12:30</c> 这类时间误判为字段。供「日志分析器」识别可绘图字段。
/// </summary>
private static readonly Regex NumericFieldRegex =
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
private readonly SimpleLiteLauncher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
{
_launcher = launcher;
_config = config;
_log = log;
}
// ─────────────────────────────────────────────────────────── 概览 ──
/// <summary>日志根概览:工作目录、根路径、是否存在、文件/字节总量、按天分组统计。</summary>
[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<object>(),
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
});
}
// ────────────────────────────────────────────────────── 文件列表 ──
/// <summary>列出落盘日志文件(递归 log/),可按天 / 文件名关键字过滤,按修改时间降序。</summary>
[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<object>() });
var clamp = Math.Clamp(limit, 1, 5000);
IEnumerable<FileMeta> 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 });
}
// ──────────────────────────────────────────────────── 目录浏览 ──
/// <summary>
/// 文件夹 / 文件浏览器:列出 <c>log/</c> 下指定相对目录的<b>直接</b>子项(子文件夹 + 文件),可逐层进入。
/// <c>path</c> 为空 = 日志根。对齐用户诉求「直接显示 log 下所有文件夹和文件,可进入文件夹、打开某个日志文件」。
/// 子文件夹附带其下一层的子目录/文件计数,文件附带大小、修改时间与是否 .log。
/// </summary>
[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<object>(), files = Array.Empty<object>(),
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<object>();
var files = new List<object>();
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
});
}
// ──────────────────────────────────────────────────── 条目(分页)──
/// <summary>
/// 解析单个日志文件为结构化条目(时间 / 标签 / 内容),支持关键字 / 标签 / 仅带标签过滤、
/// 升降序与分页。超大文件按 <see cref="MaxScanBytes"/> / <see cref="MaxEntries"/> 截断并回 truncated。
/// </summary>
[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<LogEntry> 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
});
}
// ─────────────────────────────────────────────────────── 合订本 ──
/// <summary>
/// 「合订本」:把带标签的 Post/Toast 按标签聚合成一册(条数 + 时间范围 + 最新内容 + 最近若干条),
/// 无标签的归入「滚动记录」。对齐用户诉求「post 和 toast 如果有标签需要是合订本的形式」。
/// 范围二选一:<c>file</c>(单文件)或 <c>day</c>(某天全部文件,最多 <see cref="MaxDigestFiles"/> 个)。
/// </summary>
[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>();
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<string, Book>(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)
});
}
// ─────────────────────────────────────────────────────── 日志分析 ──
/// <summary>
/// 「日志分析器」:解析单文件(<c>file</c>)或某天(<c>day</c>)日志,产出图表所需的聚合数据:
/// <list type="number">
/// <item><b>标签分布</b> tags:各标签条数 + 占比(含「滚动记录」即无标签);</item>
/// <item><b>日志量直方图</b> volume:按 <c>granularity</c>(second/minute/hour) 分桶的总量 + Top6 标签拆分(稀疏桶,仅含有数据的时刻);</item>
/// <item><b>数值字段识别</b> fields:从内容里抽取 <c>key=value</c>/<c>key:value</c> 的数值字段,给出样本数/最小/最大/均值/最后值;</item>
/// <item><b>字段时序</b> series:当指定 <c>field</c> 时,返回该字段的 (时间, 值) 点序列(超量自动抽稀)。</item>
/// </list>
/// <c>keyword</c> 过滤全部统计;<c>tag</c> 仅聚焦「字段识别 + 字段时序」(不影响标签分布/直方图,便于先看全貌再下钻)。
/// </summary>
[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>();
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<LogEntry>();
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<string, int>(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<string>(topTagNames, StringComparer.Ordinal);
var totalBuckets = new SortedDictionary<DateTime, int>();
var tagBuckets = topTagNames.ToDictionary(t => t, _ => new Dictionary<DateTime, int>(), 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<LogEntry> scope = all;
if (!string.IsNullOrWhiteSpace(tag))
scope = all.Where(e => string.Equals(e.Tag, tag, StringComparison.Ordinal)).ToList();
var fieldAgg = new Dictionary<string, FieldStat>(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
});
}
// ──────────────────────────────────────────────── 原文 / 下载 ──
/// <summary>返回日志文件尾部 N 行原文(默认 2000 行),用于「查看原始日志」视图。</summary>
[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);
}
}
/// <summary>下载原始日志文件。</summary>
[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 ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
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);
}
/// <summary>把相对路径安全解析到日志根内(防 ../ 目录穿越),并要求落在 root 子级。</summary>
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;
}
/// <summary>把相对目录安全解析到日志根内(允许根自身;防 ../ 目录穿越)。用于目录浏览。</summary>
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;
}
/// <summary>相对路径标准化:反斜杠转正斜杠、去尾斜杠、根目录归一为空串。</summary>
private static string NormalizeRel(string rel)
{
if (string.IsNullOrEmpty(rel) || rel == ".") return "";
return rel.Replace('\\', '/').TrimEnd('/');
}
private static List<FileMeta> EnumerateLogFiles(string root)
{
var list = new List<FileMeta>();
IEnumerable<string> 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;
}
/// <summary>流式解析日志文件为条目;非标准行作为上一条的续行(多行内容)。</summary>
private LogParseResult ParseFile(string full)
{
var entries = new List<LogEntry>();
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<LogEntry> 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<object>(),
untagged = new { tag = "", count = 0, entries = Array.Empty<object>() }
};
/// <summary>把时间戳截断到指定粒度的桶起点(用于直方图分桶)。</summary>
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<object>(),
volume = new { granularity = granName, buckets = Array.Empty<DateTime>(), total = Array.Empty<int>(), topTags = Array.Empty<object>() },
fields = Array.Empty<object>(),
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 }
/// <summary>「日志分析器」数值字段的累计统计。</summary>
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<LogEntry> 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<LogEntry> Recent { get; } = new();
}
}