调整 .gitignore 以适配 backends 目录结构
将 MiGu.Server 相关忽略规则迁移至 backends/MiGu.Server,并新增对 .tmp-build* 和 /.cursor/rules 的忽略,优化敏感数据与临时文件的管理。
This commit is contained in:
@@ -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
|
||||
{
|
||||
// M1:waitForReady=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 });
|
||||
}
|
||||
}
|
||||
@@ -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] >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;
|
||||
}
|
||||
}
|
||||
@@ -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-4:JWT 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 }
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user