Files
Migu2.0/MiGu.Server/Controllers/LogsController.cs
T

837 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}