调整 .gitignore 以适配 backends 目录结构

将 MiGu.Server 相关忽略规则迁移至 backends/MiGu.Server,并新增对 .tmp-build* 和 /.cursor/rules 的忽略,优化敏感数据与临时文件的管理。
This commit is contained in:
ArtoriasWu
2026-06-22 08:55:55 +08:00
parent 45f0261ed7
commit 87629a8537
46 changed files with 10 additions and 3 deletions
-249
View File
@@ -1,249 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
/// <summary>
/// 登录请求体。
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
/// <summary>
/// 登录响应。
/// 会话 N+1 增量字段:
/// <list type="bullet">
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
public record LoginResponse(
string Token,
AuthUserDto User,
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
string? LaunchStatus = null,
string? LaunchWarning = null,
bool NeedsWizard = false);
public record AuthUserDto(string Id, string Username, string DisplayName, List<string> Roles);
/// <summary>
/// 当前会话身份的轻量摘要。
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
/// </summary>
public record MeResponse(
AuthUserDto User,
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
bool NeedsWizard = false);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly ConfigStore _config;
private readonly ILogger<AuthController> _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
{
_rbac = rbac;
_jwt = jwt;
_launcher = launcher;
_config = config;
_log = log;
}
/// <summary>是否需要进入配置向导(部署画像尚未完成)。登录 / me / switchScope 三处一致回填。</summary>
private bool NeedsWizard() => !_config.GetDeployment().Configured;
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req)
{
if (string.IsNullOrWhiteSpace(req.Username))
return BadRequest(new { message = "用户名不能为空" });
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
var user = _rbac.VerifyCredentials(req.Username, req.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
// scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
var launchMode = NormalizeLaunchMode(req.LaunchMode);
SimpleLiteLauncher.LaunchResult? launchResult = null;
try
{
// M1waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
}
catch (Exception ex)
{
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
LaunchStatus: launchResult?.Status,
LaunchWarning: launchResult?.Warning,
NeedsWizard: NeedsWizard()));
}
[HttpPost("logout")]
[AllowAnonymous]
public IActionResult Logout()
{
Response.Cookies.Delete(CookieName);
return Ok(new { ok = true });
}
/// <summary>用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。</summary>
[HttpGet("me")]
[Authorize]
public ActionResult<MeResponse> Me()
{
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
var scope = User.FindFirstValue("scope");
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope))
return Unauthorized(new { message = "身份无效" });
if (scope is not ("Platform" or "RCSMonitor"))
return Unauthorized(new { message = "无效 scope" });
var user = _rbac.FindUser(username);
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
var (perm, roleNames, _) = BuildSession(user, scope);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new MeResponse(dto, scope, InferRunMode(), perm, NeedsWizard()));
}
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
[HttpPost("switch-scope")]
[Authorize]
public ActionResult<LoginResponse> SwitchScope([FromBody] SwitchScopeRequest req)
{
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(username))
return Unauthorized(new { message = "身份无效" });
var user = _rbac.FindUser(username);
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, InferRunMode(), perm,
NeedsWizard: NeedsWizard()));
}
public record SwitchScopeRequest(string Scope);
// ───────────────────────── 内部工具 ─────────────────────────
/// <summary>
/// 计算指定 scope 下的有效权限(页面 / 操作 / 控件),并颁发携带该 scope 与 ops 的 JWT。
/// 这是登录 / me / switchScope 的公共核心,确保三条路径权限计算完全一致。
/// </summary>
private (EffectivePermissions perm, List<string> roleNames, string token) BuildSession(RbacUser user, string scope)
{
var eff = _rbac.ComputeEffective(user, scope);
// 按部署画像裁剪可见页:未启用的功能/模块对应的配置页从菜单隐藏(向导未完成则不裁剪)。
var pages = DeploymentCatalog.FilterPagesByDeployment(eff.Pages, _config.GetDeployment());
var perm = new EffectivePermissions(user.Id, 1, eff.Ops, eff.Widgets, pages);
var roleNames = _rbac.RoleNamesOf(user);
// ops claim 写入有效操作码(含可能的 "*"),供 RbacAdmin policy 判定管理权限。
var token = _jwt.Issue(user.Id, user.Username, scope, roleNames, eff.Ops);
return (perm, roleNames, token);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
private string InferRunMode()
{
var last = _launcher.LastLaunchMode;
if (string.IsNullOrEmpty(last)) return "Detached";
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
{
if (result is not { Started: true })
return "Detached";
if (result.Value.Status == "ReusingExisting")
return "WebEnabled";
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
{
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
return "WebOnly";
if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase))
return "WebEnabled";
}
return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled";
}
private static string NormalizeLaunchMode(string? raw)
{
return raw?.Trim().ToLowerInvariant() switch
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
};
}
private void SetAuthCookie(string token)
{
Response.Cookies.Append(CookieName, token, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(24)
});
}
}
@@ -1,68 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
namespace MiGu.Server.Controllers;
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
[ApiController]
[Authorize]
[Route("api/config")]
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
public ConfigController(ConfigStore store)
{
_store = store;
}
[HttpGet]
public IActionResult List()
{
var envs = _store.List().Select(e => new
{
section = e.Section,
version = e.Version,
updatedAt = e.UpdatedAt
});
return Ok(envs);
}
[HttpGet("{section}")]
public IActionResult Get(string section)
{
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
return NotFound(new { message = $"未知 section: {section}" });
var env = _store.Get(section);
return Ok(new
{
section = env.Section,
version = env.Version,
updatedAt = env.UpdatedAt,
payload = env.Payload
});
}
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
[HttpPut("{section}")]
[Authorize]
public IActionResult Put(string section, [FromBody] JsonElement payload)
{
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
return NotFound(new { message = $"未知 section: {section}" });
var env = _store.Put(section, payload);
return Ok(new
{
section = env.Section,
version = env.Version,
updatedAt = env.UpdatedAt,
payload = env.Payload
});
}
}
@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
[ApiController]
[Route("api/health")]
public class HealthController : ControllerBase
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private readonly SimpleLiteLauncher _launcher;
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
[HttpGet]
public IActionResult Get()
{
return Ok(new
{
status = "ok",
mode = "WebEnabled",
startTime = StartTime,
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
ports = new
{
webApi = 7001,
webSocket = 7002,
platform = 8080,
vrender = 8223,
vehicle = 8222
},
architecture = "v1.5"
});
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// </summary>
[HttpGet("simplelite")]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[Authorize]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
return Ok(new { restart = result, diagnostics = diag });
}
}
-836
View File
@@ -1,836 +0,0 @@
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();
}
}
@@ -1,108 +0,0 @@
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;
/// <summary>
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
/// </summary>
[ApiController]
[Authorize]
[Route("api/maps")]
public class MapsContentController : ControllerBase
{
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly ILogger<MapsContentController> _log;
public MapsContentController(
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
ILogger<MapsContentController> log)
{
_httpFactory = httpFactory;
_internalToken = internalToken;
_sl = sl.Value;
_log = log;
}
[HttpGet("{name}/content")]
public async Task<IActionResult> 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<string?> 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;
}
}
-194
View File
@@ -1,194 +0,0 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 运维白名单网关。运营端(RCSMonitor)通过本控制器执行受控运维动作。
///
/// AR-4[Authorize] 要求登录,再按 op 白名单 + JWT ops claim 双校验。
///
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发。
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
public OpsController(
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IConfiguration config,
ILogger<OpsController> log)
{
_audits = audits;
_httpFactory = httpFactory;
_internalToken = internalToken;
_sl = sl.Value;
_log = log;
_dispatch = LoadDispatch(config);
}
/// <summary>从 appsettings <c>Ops:Dispatch</c> 读取 opCode → "kind:Method" 映射(忽略空值与 _ 注释键)。</summary>
private static IReadOnlyDictionary<string, (string, string)> LoadDispatch(IConfiguration config)
{
var map = new Dictionary<string, (string, string)>(StringComparer.Ordinal);
foreach (var kv in config.GetSection("Ops:Dispatch").GetChildren())
{
var op = kv.Key;
var spec = kv.Value;
if (op.StartsWith('_') || string.IsNullOrWhiteSpace(spec)) continue;
var parts = spec.Split(':', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && parts[0].Length > 0 && parts[1].Length > 0)
map[op] = (parts[0], parts[1]);
}
return map;
}
[HttpPost("execute")]
public async Task<ActionResult<ExecuteResponse>> Execute([FromBody] ExecuteRequest req)
{
if (req is null || string.IsNullOrWhiteSpace(req.OpCode))
return BadRequest(new { message = "opCode 不能为空" });
if (!Whitelist.Contains(req.OpCode))
return BadRequest(new { message = $"非白名单 op{req.OpCode}" });
// AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。
var opsClaim = User.FindFirst("ops")?.Value ?? "";
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
if (!string.IsNullOrWhiteSpace(req.IdempotencyKey))
{
var dup = _audits.FindSuccessByIdempotencyKey(req.IdempotencyKey);
if (dup is not null)
return Ok(new ExecuteResponse(true, dup.Id, dup.Message ?? "幂等命中:已执行过相同请求,未重复下发"));
}
// monitor.note.write:运营备注,非内核动作,仅审计。
if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
var numericId = ExtractNumericId(req.TargetId);
if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
string result;
string? message;
try
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
result = success ? "ok" : "failed";
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}{ExtractMessage(body)}";
}
catch (Exception ex)
{
result = "failed";
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
}
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
}
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
{
var entry = _audits.Append(user, scope, req.OpCode, req.TargetId, result, message ?? req.Reason, req.IdempotencyKey);
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
/// </summary>
private static int? ExtractNumericId(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var m = System.Text.RegularExpressions.Regex.Match(raw, @"\d+");
return m.Success && int.TryParse(m.Value, out var n) ? n : null;
}
private static bool ParseSuccess(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
}
catch { return false; }
}
private static string ExtractMessage(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
return m.GetString() ?? "";
}
catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…";
}
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MiGu.Server.Controllers;
/// <summary>
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
///
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
/// </summary>
[ApiController]
[Authorize]
[Route("api/projection")]
public class ProjectionController : ControllerBase
{
[HttpGet("sites")]
public IActionResult Sites() => Ok(new[]
{
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
});
[HttpGet("tracks")]
public IActionResult Tracks() => Ok(new[]
{
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
});
[HttpGet("cars")]
public IActionResult Cars() => Ok(new[]
{
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
});
[HttpGet("missions")]
public IActionResult Missions() => Ok(new[]
{
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
});
}
-137
View File
@@ -1,137 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
namespace MiGu.Server.Controllers;
/// <summary>
/// RBAC 管理端:用户 / 角色 / 权限页面分配。整个控制器要求 <c>RbacAdmin</c> 策略
/// JWT 的 ops claim 含 <c>*</c> 或 <c>auth.manage</c>),即只有「超级管理员」类账号可访问。
///
/// 对应前端「平台配置中心 → 权限与角色」页(/admin/config/auth)。
/// </summary>
[ApiController]
[Authorize(Policy = "RbacAdmin")]
[Route("api/rbac")]
public class RbacController : ControllerBase
{
public sealed record OpDef(string Code, string Label);
public sealed record WidgetDef(string Id, string Label);
/// <summary>可分配的操作码候选(管理端配置角色时下拉/勾选用)。</summary>
private static readonly OpDef[] KnownOps =
{
new("*", "全部操作(通配)"),
new("ops.car.pause", "车辆 · 暂停"),
new("ops.car.resume", "车辆 · 恢复"),
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
/// <summary>可配置可见性的控件候选。</summary>
private static readonly WidgetDef[] KnownWidgets =
{
new("MapEditor", "地图编辑器"),
new("CadToolbar", "CAD 工具栏"),
new("CarPanel", "车辆面板"),
new("MissionEditor", "任务编辑器"),
new("OpsActionPanel", "运维操作面板"),
new("ConfigCenter", "配置中心"),
};
private readonly RbacStore _store;
private readonly ILogger<RbacController> _log;
public RbacController(RbacStore store, ILogger<RbacController> log)
{
_store = store;
_log = log;
}
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog() => Ok(new
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
{
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
}
});
// ───────────────────────── 角色 ─────────────────────────
[HttpGet("roles")]
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
{
_store.DeleteRole(id);
return Ok(new { ok = true });
});
// ───────────────────────── 用户 ─────────────────────────
[HttpGet("users")]
public IActionResult ListUsers() => Ok(_store.ListUsers());
[HttpPost("users")]
public IActionResult CreateUser([FromBody] CreateUserRequest req) => Guard(() => Ok(_store.CreateUser(req)));
[HttpPut("users/{id}")]
public IActionResult UpdateUser(string id, [FromBody] UpdateUserRequest req) => Guard(() =>
{
// 自我保护:禁止把当前登录账号自己停用,避免管理员把自己锁在门外。
if (id == CurrentUserId() && req.Enabled == false)
return (IActionResult)BadRequest(new { message = "不能停用当前登录的账号" });
return Ok(_store.UpdateUser(id, req));
});
[HttpPut("users/{id}/password")]
public IActionResult SetPassword(string id, [FromBody] SetPasswordRequest req) => Guard(() =>
{
_store.SetPassword(id, req.Password);
return Ok(new { ok = true });
});
[HttpDelete("users/{id}")]
public IActionResult DeleteUser(string id) => Guard(() =>
{
if (id == CurrentUserId())
return (IActionResult)BadRequest(new { message = "不能删除当前登录的账号" });
_store.DeleteUser(id);
return Ok(new { ok = true });
});
// ───────────────────────── 工具 ─────────────────────────
/// <summary>统一把 <see cref="RbacException"/> 翻译成 400 + message,其余异常向上抛。</summary>
private IActionResult Guard(Func<IActionResult> action)
{
try { return action(); }
catch (RbacException ex) { return BadRequest(new { message = ex.Message }); }
}
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
}
-143
View File
@@ -1,143 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 配置向导 API。登录后若 <c>deployment.Configured=false</c>(见 <c>LoginResponse.NeedsWizard</c>),
/// 前端进入向导:
/// <list type="bullet">
/// <item><c>GET /api/wizard/options</c>:可选项目录(导航方式 / 模块 / 业务场景模板)。</item>
/// <item><c>GET /api/wizard/profile</c>:回显当前部署画像(含由导航选型推导的激活场景 id)。</item>
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>。</item>
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(保留草稿)。</item>
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
/// </summary>
[ApiController]
[Authorize]
[Route("api/wizard")]
public class WizardController : ControllerBase
{
private readonly ConfigStore _store;
private readonly SimpleLiteLauncher _launcher;
private readonly ILogger<WizardController> _log;
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
{
_store = store;
_launcher = launcher;
_log = log;
}
[HttpGet("options")]
public IActionResult Options()
{
return Ok(new
{
navigationKinds = DeploymentCatalog.NavigationKinds,
modules = DeploymentCatalog.Modules,
scenarios = _store.Get("scenario").Payload
});
}
[HttpGet("profile")]
public IActionResult GetProfile() => Ok(Project(_store.GetDeployment()));
/// <summary>当前部署画像对菜单的裁剪结果(供前端做面板/能力级裁剪与排查)。</summary>
[HttpGet("effective-pages")]
public IActionResult EffectivePages()
{
var dp = _store.GetDeployment();
return Ok(new
{
configured = dp.Configured,
tailorablePages = DeploymentCatalog.TailorablePages(),
enabledPages = DeploymentCatalog.EnabledPages(dp),
hiddenPages = DeploymentCatalog.HiddenPages(dp)
});
}
[HttpPut("profile")]
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
{
if (req == null)
return BadRequest(new { message = "请求体不能为空" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var profile = new DeploymentProfile(
Configured: true,
PlatformType: string.IsNullOrWhiteSpace(req.PlatformType) ? "standard" : req.PlatformType.Trim(),
Modules: Clean(req.Modules),
NavigationKinds: Clean(req.NavigationKinds),
Scenarios: Clean(req.Scenarios),
UpdatedBy: user);
_store.PutDeployment(profile);
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
var sceneIds = profile.ToActiveSceneIds();
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
user, string.Join(",", profile.NavigationKinds), string.Join(",", sceneIds), write.Ok);
return Ok(Project(profile, write));
}
[HttpPost("reset")]
public IActionResult Reset()
{
var reset = _store.GetDeployment() with { Configured = false };
_store.PutDeployment(reset);
return Ok(Project(reset));
}
/// <summary>
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
/// </summary>
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
{
configured = dp.Configured,
platformType = dp.PlatformType,
modules = dp.Modules,
navigationKinds = dp.NavigationKinds,
scenarios = dp.Scenarios,
updatedBy = dp.UpdatedBy,
activeSceneIds = dp.ToActiveSceneIds(),
hiddenPages = DeploymentCatalog.HiddenPages(dp),
activeScenesWrite = write == null ? null : new
{
ok = write.Value.Ok,
path = write.Value.Path,
error = write.Value.Error
}
};
/// <summary>去空白、去重(大小写不敏感)、保持顺序。</summary>
private static List<string> Clean(List<string>? items)
{
var result = new List<string>();
if (items == null) return result;
foreach (var s in items)
{
var t = s?.Trim();
if (!string.IsNullOrEmpty(t) && !result.Contains(t, StringComparer.OrdinalIgnoreCase))
result.Add(t!);
}
return result;
}
public record SaveWizardRequest(
string? PlatformType,
List<string>? Modules,
List<string>? NavigationKinds,
List<string>? Scenarios);
}