初始化 MiGu.Server 项目,包含基本的 ASP.NET Core 8 配置、JWT 鉴权、RBAC 权限管理、YARP 反向代理及相关配置文件。新增 build-and-run 脚本以简化构建与运行流程,添加 README 文档以指导用户快速上手。
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
data/*.json
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||
///
|
||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||
///
|
||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
||||
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header;
|
||||
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
|
||||
/// 会自动追加该 header;
|
||||
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
||||
///
|
||||
/// Token 来源优先级:
|
||||
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32)
|
||||
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
|
||||
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
|
||||
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
|
||||
/// </summary>
|
||||
public sealed class InternalTokenStore
|
||||
{
|
||||
public string Token { get; }
|
||||
public bool IsEphemeral { get; }
|
||||
public string? PersistedFilePath { get; }
|
||||
|
||||
public InternalTokenStore(IConfiguration config, IWebHostEnvironment env, ILogger<InternalTokenStore> logger)
|
||||
{
|
||||
var configured = config["Internal:Token"];
|
||||
if (!string.IsNullOrWhiteSpace(configured) && configured != "REPLACE_ME")
|
||||
{
|
||||
Token = configured;
|
||||
IsEphemeral = false;
|
||||
logger.LogInformation("Internal token 从配置读取(长度 {Len})。", Token.Length);
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
|
||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
var file = Path.Combine(dataDir, ".internal-token");
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = File.ReadAllText(file).Trim();
|
||||
if (existing.Length >= 32)
|
||||
{
|
||||
Token = existing;
|
||||
IsEphemeral = false;
|
||||
PersistedFilePath = file;
|
||||
logger.LogInformation("Internal token 复用 {File}(长度 {Len})。", file, Token.Length);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "读取 {File} 失败,将重新生成 internal token。", file);
|
||||
}
|
||||
}
|
||||
|
||||
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
IsEphemeral = true;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(file, Token);
|
||||
PersistedFilePath = file;
|
||||
logger.LogWarning(
|
||||
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
||||
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
|
||||
file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 平台 JWT 颁发与验签。Secret 从 <c>appsettings.json</c> 的 <c>Jwt:Secret</c> 读取;
|
||||
/// 若为占位值 <c>"REPLACE_ME"</c> 则启动期生成临时随机 secret 并在日志里强制告警,
|
||||
/// 防止生产部署忘改 secret 导致历史 hardcoded "simple-mock-secret" 风险复现。
|
||||
/// </summary>
|
||||
public sealed class JwtIssuer
|
||||
{
|
||||
/// <summary>占位 secret;启动期检测到时自动换成进程随机值,并在日志里 critical 告警。</summary>
|
||||
public const string PlaceholderSecret = "REPLACE_ME";
|
||||
|
||||
public string Issuer { get; }
|
||||
public string Audience { get; }
|
||||
public TimeSpan Lifetime { get; }
|
||||
public string SecretInUse { get; }
|
||||
public bool SecretIsEphemeral { get; }
|
||||
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
private readonly SigningCredentials _signing;
|
||||
private readonly TokenValidationParameters _validation;
|
||||
|
||||
public JwtIssuer(string secret, string issuer, string audience, TimeSpan lifetime, ILogger<JwtIssuer> logger)
|
||||
{
|
||||
Issuer = issuer;
|
||||
Audience = audience;
|
||||
Lifetime = lifetime;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret) || secret == PlaceholderSecret)
|
||||
{
|
||||
// 兜底:用进程随机 secret,保证签名不被预知,但 token 在 MiGu.Server 重启后失效。
|
||||
// 这条路径只应出现在「开发机首次启动」/「忘改配置的部署」,**日志里强制告警让运维注意**。
|
||||
secret = RandomBase64Secret(64);
|
||||
SecretIsEphemeral = true;
|
||||
logger.LogCritical(
|
||||
"Jwt:Secret 是占位值或未配置 —— 已生成进程随机 secret(重启后所有 token 失效)。" +
|
||||
"生产环境必须在 appsettings.Production.json 或环境变量 PLATFORM__JWT__SECRET 配置一个 ≥ 32 字节的稳定 secret。");
|
||||
}
|
||||
else if (Encoding.UTF8.GetByteCount(secret) < 32)
|
||||
{
|
||||
logger.LogWarning("Jwt:Secret 长度不足 32 字节,HS256 推荐 ≥ 32 字节 secret 以达到 256bit 强度。");
|
||||
}
|
||||
|
||||
SecretInUse = secret;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||||
_signing = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||
_validation = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateLifetime = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidAudience = Audience,
|
||||
IssuerSigningKey = _key,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>颁发 access token。<paramref name="ops"/> 写入私有 claim <c>ops</c>(空格分隔,便于后端 <c>[Authorize]</c> policy 解析)。</summary>
|
||||
public string Issue(string userId, string username, string scope, IReadOnlyList<string> roles, IReadOnlyList<string> ops)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new("scope", scope),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||
new(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new("ops", string.Join(' ', ops ?? Array.Empty<string>())),
|
||||
};
|
||||
foreach (var r in roles ?? Array.Empty<string>())
|
||||
claims.Add(new Claim(ClaimTypes.Role, r));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: now.Add(Lifetime),
|
||||
signingCredentials: _signing);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>给框架的 JwtBearer middleware 用的验证参数(启动期注入到 AddJwtBearer)。</summary>
|
||||
public TokenValidationParameters BuildValidationParameters() => _validation;
|
||||
|
||||
private static string RandomBase64Secret(int byteLen)
|
||||
{
|
||||
var buf = RandomNumberGenerator.GetBytes(byteLen);
|
||||
return Convert.ToBase64String(buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 单个「权限页面」定义。Key 与前端 vue-router 的 route.name 一一对齐,
|
||||
/// 角色通过勾选 Key 集合决定可访问的页面(菜单 + 路由守卫据此放行)。
|
||||
/// </summary>
|
||||
public sealed record PageDef(string Key, string Label, string Group, string Scope);
|
||||
|
||||
/// <summary>
|
||||
/// 平台「权限页面清单」——RBAC 的最小授权单元。
|
||||
///
|
||||
/// 设计:页面是由前端路由静态决定的(相对稳定),因此后端维护一份与
|
||||
/// <c>frontends/.../router/index.ts</c> 对齐的静态清单,通过 <c>GET /api/rbac/pages</c>
|
||||
/// 暴露给「权限与角色」管理页,让管理员可视化地把页面分配给角色。
|
||||
///
|
||||
/// Scope 含义:
|
||||
/// - <c>Platform</c>:管理端(/admin/*)页面;
|
||||
/// - <c>RCSMonitor</c>:运营监控端(/monitor/*)页面。
|
||||
/// </summary>
|
||||
public static class PageCatalog
|
||||
{
|
||||
public const string ScopePlatform = "Platform";
|
||||
public const string ScopeMonitor = "RCSMonitor";
|
||||
|
||||
/// <summary>权限页面通配符:角色 Pages 含此值表示「该 scope 下全部页面」(超级管理员)。</summary>
|
||||
public const string Wildcard = "*";
|
||||
|
||||
public static readonly IReadOnlyList<PageDef> All = new List<PageDef>
|
||||
{
|
||||
// ── 管理端 / Platform:概览 ──
|
||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:设计与编排 ──
|
||||
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
||||
new("admin-map-editor", "地图编辑", "设计与编排", ScopePlatform),
|
||||
new("admin-project-properties", "项目属性", "设计与编排", ScopePlatform),
|
||||
new("admin-tracks", "场景管理", "设计与编排", ScopePlatform),
|
||||
new("admin-cars", "车辆管理", "设计与编排", ScopePlatform),
|
||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-facility", "设备与库位", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
|
||||
|
||||
// ── 运营端 / RCSMonitor ──
|
||||
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
|
||||
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
|
||||
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
|
||||
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
|
||||
new("monitor-notes", "运营备注", "运营监控", ScopeMonitor),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _keys =
|
||||
All.Select(p => p.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>已下线页面 Key → 合并后的新 Key(角色数据迁移用)。</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> LegacyKeyAliases =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["admin-config-vehicle"] = "admin-vehicle-hub",
|
||||
["admin-config-fleet"] = "admin-vehicle-hub",
|
||||
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
|
||||
["admin-config-routing"] = "admin-config-strategy",
|
||||
["admin-config-task"] = "admin-config-strategy",
|
||||
["admin-config-traffic"] = "admin-config-strategy",
|
||||
["admin-config-charge"] = "admin-config-strategy",
|
||||
["admin-config-device"] = "admin-config-facility",
|
||||
["admin-config-location"] = "admin-config-facility",
|
||||
["admin-config-integrations"] = "admin-config-business",
|
||||
["admin-config-scenario"] = "admin-config-business",
|
||||
["admin-config-widget"] = "admin-config-business",
|
||||
["admin-playback"] = "admin-config-ops-center",
|
||||
["admin-config-ops"] = "admin-config-ops-center",
|
||||
["admin-config-logs"] = "admin-config-ops-center",
|
||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||
["admin-config-system"] = "admin-config-system-center",
|
||||
["admin-config-auth"] = "admin-config-system-center",
|
||||
};
|
||||
|
||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||
public static bool IsValidKey(string key) => _keys.Contains(key);
|
||||
|
||||
/// <summary>将旧页面 Key 映射为当前有效 Key;未知 Key 原样返回。</summary>
|
||||
public static string NormalizeKey(string key) =>
|
||||
LegacyKeyAliases.TryGetValue(key, out var mapped) ? mapped : key;
|
||||
|
||||
/// <summary>列出某 scope 下的全部页面 Key(用于把角色的 "*" 通配展开成具体页面集合)。</summary>
|
||||
public static IReadOnlyList<string> KeysForScope(string scope) =>
|
||||
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using MiGu.Server.Configs;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// RBAC 角色。一个角色 = 一组「页面 + 操作码 + 控件可见性」授权,归属某个 scope。
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Scope"/>:<c>Platform</c> / <c>RCSMonitor</c> / <c>*</c>(通用,对两个 scope 都生效)。</item>
|
||||
/// <item><see cref="Pages"/>:可访问页面 Key 集合(见 <see cref="PageCatalog"/>);含 <c>*</c> 表示该 scope 全部页面。</item>
|
||||
/// <item><see cref="Ops"/>:细粒度操作码(如 <c>ops.car.pause</c>);含 <c>*</c> 表示全部操作。</item>
|
||||
/// <item><see cref="WidgetGrants"/>:控件级可见性(hidden / readonly / interactive)。</item>
|
||||
/// <item><see cref="System"/>:内置系统角色,禁止删除(可改名/调权限但保底不被误删)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class RbacRole
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Scope { get; set; } = PageCatalog.ScopePlatform;
|
||||
public List<string> Pages { get; set; } = new();
|
||||
public List<string> Ops { get; set; } = new();
|
||||
public List<WidgetGrantDto> WidgetGrants { get; set; } = new();
|
||||
public bool System { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RBAC 用户。密码以 PBKDF2-SHA256 哈希存储(<see cref="Salt"/> / <see cref="PasswordHash"/> 均为 base64)。
|
||||
/// 一个用户可拥有多个角色,其有效权限 = 当前 scope 下各角色授权的并集。
|
||||
/// </summary>
|
||||
public sealed class RbacUser
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public List<string> RoleIds { get; set; } = new();
|
||||
public string Salt { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>rbac.json 的根对象(内存 + 文件持久化)。</summary>
|
||||
public sealed class RbacSnapshot
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public List<RbacRole> Roles { get; set; } = new();
|
||||
public List<RbacUser> Users { get; set; } = new();
|
||||
}
|
||||
|
||||
// ─────────────────────────── API DTO ───────────────────────────
|
||||
|
||||
/// <summary>对外用户视图:绝不含 Salt / PasswordHash。<see cref="Scopes"/> 为该用户可登录的 scope 集合。</summary>
|
||||
public sealed record RbacUserDto(
|
||||
string Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
bool Enabled,
|
||||
List<string> RoleIds,
|
||||
List<string> Scopes);
|
||||
|
||||
public sealed record CreateUserRequest(
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
string Password,
|
||||
List<string>? RoleIds,
|
||||
bool Enabled = true);
|
||||
|
||||
public sealed record UpdateUserRequest(
|
||||
string? DisplayName,
|
||||
List<string>? RoleIds,
|
||||
bool? Enabled);
|
||||
|
||||
public sealed record SetPasswordRequest(string Password);
|
||||
|
||||
public sealed record SaveRoleRequest(
|
||||
string? Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Scope,
|
||||
List<string>? Pages,
|
||||
List<string>? Ops,
|
||||
List<WidgetGrantDto>? WidgetGrants);
|
||||
@@ -0,0 +1,551 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Infra;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// RBAC 权威存储:用户 + 角色 + 权限页面授权,内存态 + <c>data/rbac.json</c> 文件持久化。
|
||||
///
|
||||
/// 取代旧的硬编码 <c>UserStore</c> + <c>AuthController.BuildPermissions</c>:
|
||||
/// - 登录密码校验、角色解析、有效权限(页面 / 操作 / 控件)全部由本类计算;
|
||||
/// - 管理端「权限与角色」页通过 <c>RbacController</c> 增删改用户 / 角色,落盘后即时生效;
|
||||
/// - 密码以 PBKDF2-SHA256(100k, 16B salt) 哈希存储,比较走 FixedTimeEquals 防时序攻击。
|
||||
///
|
||||
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
|
||||
/// admin(超级管理员,scope=*,全部页面 / 操作)
|
||||
/// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码)
|
||||
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
|
||||
/// </summary>
|
||||
public sealed class RbacStore
|
||||
{
|
||||
public sealed record EffectiveResult(List<string> Pages, List<string> Ops, List<WidgetGrantDto> Widgets);
|
||||
|
||||
private const string RoleAdminId = "role-admin";
|
||||
private const string RoleOpsId = "role-ops";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly string _file;
|
||||
private readonly ILogger<RbacStore> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private RbacSnapshot _snapshot = new();
|
||||
|
||||
public RbacStore(IConfiguration config, IWebHostEnvironment env, ILogger<RbacStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
_file = Path.Combine(dataDir, "rbac.json");
|
||||
Load(config);
|
||||
}
|
||||
|
||||
// ───────────────────────── 加载 / 持久化 ─────────────────────────
|
||||
|
||||
private void Load(IConfiguration config)
|
||||
{
|
||||
if (File.Exists(_file))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_file);
|
||||
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
|
||||
if (snap is { Users.Count: > 0 })
|
||||
{
|
||||
_snapshot = Normalize(snap);
|
||||
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
|
||||
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
|
||||
return;
|
||||
}
|
||||
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RBAC 文件 {File} 解析失败,回退到默认 seed。", _file);
|
||||
// S2:先备份疑似损坏的 rbac.json,避免随后默认 seed 的写入把自定义用户 / 角色
|
||||
// 永久冲掉(损坏 → 静默重置成 admin/ops 弱口令是高危场景)。
|
||||
var bak = AtomicFile.BackupCorrupt(_file);
|
||||
if (bak != null)
|
||||
_logger.LogWarning("已备份疑似损坏的 RBAC 文件到 {Backup},请人工核查后恢复自定义数据。", bak);
|
||||
}
|
||||
}
|
||||
|
||||
_snapshot = SeedDefault(config);
|
||||
Persist();
|
||||
_logger.LogInformation("RBAC 已生成默认数据并写入 {File}(admin / ops)。", _file);
|
||||
}
|
||||
|
||||
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
|
||||
private static RbacSnapshot Normalize(RbacSnapshot snap)
|
||||
{
|
||||
snap.Roles ??= new();
|
||||
snap.Users ??= new();
|
||||
foreach (var r in snap.Roles)
|
||||
{
|
||||
r.Pages = (r.Pages ?? new())
|
||||
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
|
||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
||||
r.WidgetGrants ??= new();
|
||||
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
|
||||
BackfillKnownPageMigrations(r);
|
||||
}
|
||||
foreach (var u in snap.Users)
|
||||
{
|
||||
u.RoleIds = (u.RoleIds ?? new()).Distinct().ToList();
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static void BackfillKnownPageMigrations(RbacRole r)
|
||||
{
|
||||
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
|
||||
&& r.Scope != PageCatalog.Wildcard) return;
|
||||
|
||||
// 任务编排页(admin-task-templates,原任务模板 / WorkflowEditor)与脚本、进程管理同属一组编排能力。
|
||||
// 旧的 admin-missions 入口已下线:上面 Normalize 会按 PageCatalog 过滤掉历史角色里的该 key。
|
||||
// 这里在角色已有进程 + 脚本入口时补齐「任务编排」菜单权限。
|
||||
var hasProcessAndScript =
|
||||
r.Pages.Contains("admin-processes", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-scripts", StringComparer.OrdinalIgnoreCase);
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
||||
&& hasProcessAndScript)
|
||||
r.Pages.Add("admin-task-templates");
|
||||
}
|
||||
|
||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||
{
|
||||
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
|
||||
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
|
||||
if (adminPwd == "admin" || opsPwd == "ops")
|
||||
_logger.LogWarning("RBAC seed 使用默认弱密码(admin/ops),生产环境请尽快在「权限与角色」页修改或通过环境变量覆盖。");
|
||||
|
||||
var snap = new RbacSnapshot
|
||||
{
|
||||
Version = 1,
|
||||
Roles = new List<RbacRole>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = RoleAdminId, Name = "超级管理员", Description = "拥有全部页面与操作权限的内置角色",
|
||||
Scope = PageCatalog.Wildcard,
|
||||
Pages = new() { PageCatalog.Wildcard },
|
||||
Ops = new() { "*" },
|
||||
WidgetGrants = new(),
|
||||
System = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
|
||||
Scope = PageCatalog.ScopeMonitor,
|
||||
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
|
||||
Ops = new()
|
||||
{
|
||||
"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"
|
||||
},
|
||||
WidgetGrants = new()
|
||||
{
|
||||
new("MapEditor", "readonly"),
|
||||
new("CadToolbar", "hidden"),
|
||||
new("CarPanel", "readonly"),
|
||||
new("MissionEditor", "readonly"),
|
||||
new("OpsActionPanel", "interactive"),
|
||||
new("ConfigCenter", "hidden")
|
||||
},
|
||||
System = true
|
||||
}
|
||||
},
|
||||
Users = new List<RbacUser>()
|
||||
};
|
||||
|
||||
snap.Users.Add(NewUser("u-admin", "admin", "系统管理员", adminPwd, new() { RoleAdminId }));
|
||||
snap.Users.Add(NewUser("u-ops", "ops", "运营人员", opsPwd, new() { RoleOpsId }));
|
||||
return snap;
|
||||
}
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
try
|
||||
{
|
||||
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_snapshot, _jsonOpts));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RBAC 持久化到 {File} 失败。", _file);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 登录 / 鉴权读取 ─────────────────────────
|
||||
|
||||
/// <summary>用户名 + 密码校验。返回 null = 不存在 / 已禁用 / 密码错(不区分原因,防用户名枚举)。</summary>
|
||||
public RbacUser? VerifyCredentials(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
|
||||
lock (_gate)
|
||||
{
|
||||
var u = FindByName(username);
|
||||
if (u is null || !u.Enabled) return null;
|
||||
if (!VerifyHash(password, u.Salt, u.PasswordHash)) return null;
|
||||
return Clone(u);
|
||||
}
|
||||
}
|
||||
|
||||
public RbacUser? FindUser(string username)
|
||||
{
|
||||
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
|
||||
}
|
||||
|
||||
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope,<c>*</c> 角色覆盖全部)。</summary>
|
||||
public List<string> UsableScopes(RbacUser user)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var roles = RolesOf(user);
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (r.Scope == PageCatalog.Wildcard)
|
||||
{
|
||||
set.Add(PageCatalog.ScopePlatform);
|
||||
set.Add(PageCatalog.ScopeMonitor);
|
||||
}
|
||||
else set.Add(r.Scope);
|
||||
}
|
||||
return set.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanUseScope(RbacUser user, string scope) =>
|
||||
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
|
||||
public List<string> RoleNamesOf(RbacUser user)
|
||||
{
|
||||
lock (_gate) { return RolesOf(user).Select(r => r.Name).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算用户在指定 scope 下的有效权限:可访问页面、操作码、控件可见性,均取适用角色的并集。
|
||||
/// 适用角色 = 角色 scope 等于该 scope,或角色 scope 为通配 <c>*</c>。
|
||||
/// </summary>
|
||||
public EffectiveResult ComputeEffective(RbacUser user, string scope)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var roles = RolesOf(user).Where(r => r.Scope == PageCatalog.Wildcard
|
||||
|| string.Equals(r.Scope, scope, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
var scopeKeys = PageCatalog.KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var pages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var ops = new HashSet<string>(StringComparer.Ordinal);
|
||||
var allOps = false;
|
||||
var bestWidget = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
|
||||
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
|
||||
|
||||
foreach (var o in r.Ops)
|
||||
{
|
||||
if (o == "*") allOps = true;
|
||||
else ops.Add(o);
|
||||
}
|
||||
|
||||
foreach (var g in r.WidgetGrants)
|
||||
{
|
||||
if (!bestWidget.TryGetValue(g.WidgetId, out var cur) || Rank(g.Visibility) > Rank(cur))
|
||||
bestWidget[g.WidgetId] = g.Visibility;
|
||||
}
|
||||
}
|
||||
|
||||
return new EffectiveResult(
|
||||
pages.OrderBy(p => p, StringComparer.Ordinal).ToList(),
|
||||
allOps ? new List<string> { "*" } : ops.OrderBy(o => o, StringComparer.Ordinal).ToList(),
|
||||
bestWidget.Select(kv => new WidgetGrantDto(kv.Key, kv.Value)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 管理端读取 ─────────────────────────
|
||||
|
||||
public List<RbacRole> ListRoles()
|
||||
{
|
||||
lock (_gate) { return _snapshot.Roles.Select(Clone).ToList(); }
|
||||
}
|
||||
|
||||
public List<RbacUserDto> ListUsers()
|
||||
{
|
||||
lock (_gate) { return _snapshot.Users.Select(ToDto).ToList(); }
|
||||
}
|
||||
|
||||
// ───────────────────────── 用户 CRUD ─────────────────────────
|
||||
|
||||
public RbacUserDto CreateUser(CreateUserRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username)) throw new RbacException("用户名不能为空");
|
||||
if (string.IsNullOrEmpty(req.Password)) throw new RbacException("初始密码不能为空");
|
||||
lock (_gate)
|
||||
{
|
||||
if (FindByName(req.Username) is not null) throw new RbacException($"用户名 {req.Username} 已存在");
|
||||
var roleIds = FilterExistingRoles(req.RoleIds);
|
||||
var user = NewUser($"u-{NewId()}", req.Username.Trim(),
|
||||
string.IsNullOrWhiteSpace(req.DisplayName) ? req.Username.Trim() : req.DisplayName!.Trim(),
|
||||
req.Password, roleIds);
|
||||
user.Enabled = req.Enabled;
|
||||
_snapshot.Users.Add(user);
|
||||
Persist();
|
||||
return ToDto(user);
|
||||
}
|
||||
}
|
||||
|
||||
public RbacUserDto UpdateUser(string id, UpdateUserRequest req)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
|
||||
var oldName = u.DisplayName;
|
||||
var oldRoles = u.RoleIds;
|
||||
var oldEnabled = u.Enabled;
|
||||
if (req.DisplayName is not null) u.DisplayName = req.DisplayName.Trim();
|
||||
if (req.RoleIds is not null) u.RoleIds = FilterExistingRoles(req.RoleIds);
|
||||
if (req.Enabled is bool en) u.Enabled = en;
|
||||
// M5:若本次改动(改角色 / 停用)导致系统再无有效管理员,则回滚后报错。
|
||||
try { EnsureAdminRemainsNoLock(); }
|
||||
catch { u.DisplayName = oldName; u.RoleIds = oldRoles; u.Enabled = oldEnabled; throw; }
|
||||
Persist();
|
||||
return ToDto(u);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPassword(string id, string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password)) throw new RbacException("密码不能为空");
|
||||
lock (_gate)
|
||||
{
|
||||
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
u.Salt = Convert.ToBase64String(salt);
|
||||
u.PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt));
|
||||
Persist();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteUser(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id) ?? throw new RbacException("用户不存在");
|
||||
var idx = _snapshot.Users.IndexOf(u);
|
||||
_snapshot.Users.RemoveAt(idx);
|
||||
// M5:删除后若系统再无有效管理员,则恢复并报错。
|
||||
try { EnsureAdminRemainsNoLock(); }
|
||||
catch { _snapshot.Users.Insert(idx, u); throw; }
|
||||
Persist();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 角色 CRUD ─────────────────────────
|
||||
|
||||
public RbacRole CreateRole(SaveRoleRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空");
|
||||
var scope = NormalizeScope(req.Scope);
|
||||
lock (_gate)
|
||||
{
|
||||
var role = new RbacRole
|
||||
{
|
||||
Id = $"role-{NewId()}",
|
||||
Name = req.Name.Trim(),
|
||||
Description = req.Description?.Trim() ?? "",
|
||||
Scope = scope,
|
||||
Pages = SanitizePages(req.Pages),
|
||||
Ops = req.Ops?.Distinct().ToList() ?? new(),
|
||||
WidgetGrants = req.WidgetGrants ?? new(),
|
||||
System = false
|
||||
};
|
||||
_snapshot.Roles.Add(role);
|
||||
Persist();
|
||||
return Clone(role);
|
||||
}
|
||||
}
|
||||
|
||||
public RbacRole UpdateRole(string id, SaveRoleRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) throw new RbacException("角色名称不能为空");
|
||||
var scope = NormalizeScope(req.Scope);
|
||||
lock (_gate)
|
||||
{
|
||||
var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在");
|
||||
var backup = Clone(role);
|
||||
role.Name = req.Name.Trim();
|
||||
role.Description = req.Description?.Trim() ?? "";
|
||||
role.Scope = scope;
|
||||
role.Pages = SanitizePages(req.Pages);
|
||||
role.Ops = req.Ops?.Distinct().ToList() ?? new();
|
||||
role.WidgetGrants = req.WidgetGrants ?? new();
|
||||
// M5:若本次改动(如去掉角色的 "*"/auth.manage)导致系统再无有效管理员,则回滚。
|
||||
try { EnsureAdminRemainsNoLock(); }
|
||||
catch
|
||||
{
|
||||
role.Name = backup.Name; role.Description = backup.Description; role.Scope = backup.Scope;
|
||||
role.Pages = backup.Pages; role.Ops = backup.Ops; role.WidgetGrants = backup.WidgetGrants;
|
||||
throw;
|
||||
}
|
||||
Persist();
|
||||
return Clone(role);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteRole(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var role = _snapshot.Roles.FirstOrDefault(r => r.Id == id) ?? throw new RbacException("角色不存在");
|
||||
if (role.System) throw new RbacException("内置系统角色不可删除");
|
||||
var inUse = _snapshot.Users.Where(u => u.RoleIds.Contains(id)).Select(u => u.Username).ToList();
|
||||
if (inUse.Count > 0)
|
||||
throw new RbacException($"角色仍被 {inUse.Count} 个用户使用({string.Join(", ", inUse.Take(5))}{(inUse.Count > 5 ? "…" : "")}),请先解除关联");
|
||||
_snapshot.Roles.Remove(role);
|
||||
Persist();
|
||||
}
|
||||
}
|
||||
|
||||
public bool RoleExists(string id)
|
||||
{
|
||||
lock (_gate) { return _snapshot.Roles.Any(r => r.Id == id); }
|
||||
}
|
||||
|
||||
// ───────────────────────── 内部工具 ─────────────────────────
|
||||
|
||||
private RbacUser? FindByName(string username) =>
|
||||
_snapshot.Users.FirstOrDefault(u => string.Equals(u.Username, username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private List<RbacRole> RolesOf(RbacUser user) =>
|
||||
user.RoleIds.Select(id => _snapshot.Roles.FirstOrDefault(r => r.Id == id))
|
||||
.Where(r => r is not null).Select(r => r!).ToList();
|
||||
|
||||
private const string OpAuthManage = "auth.manage";
|
||||
|
||||
/// <summary>
|
||||
/// 该用户当前是否为「有效系统管理员」:启用 且 至少一个角色的 Ops 含 "*" 或 "auth.manage"。
|
||||
/// 用于 M5「最后管理员」保护。
|
||||
/// </summary>
|
||||
private bool IsActiveAdminNoLock(RbacUser u)
|
||||
{
|
||||
if (!u.Enabled) return false;
|
||||
foreach (var r in RolesOf(u))
|
||||
if (r.Ops.Contains("*") || r.Ops.Contains(OpAuthManage)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private int CountActiveAdminsNoLock() => _snapshot.Users.Count(IsActiveAdminNoLock);
|
||||
|
||||
/// <summary>校验修改应用后系统仍至少有一名有效管理员,否则抛异常(调用方负责回滚内存改动)。</summary>
|
||||
private void EnsureAdminRemainsNoLock()
|
||||
{
|
||||
if (CountActiveAdminsNoLock() == 0)
|
||||
throw new RbacException("该操作会使系统再无任何具备管理权限(auth.manage)的启用账号,已阻止。请至少保留一名超级管理员。");
|
||||
}
|
||||
|
||||
private List<string> FilterExistingRoles(List<string>? roleIds) =>
|
||||
(roleIds ?? new()).Where(id => _snapshot.Roles.Any(r => r.Id == id)).Distinct().ToList();
|
||||
|
||||
private static List<string> SanitizePages(List<string>? pages)
|
||||
{
|
||||
if (pages is null) return new();
|
||||
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
|
||||
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeScope(string? scope) => scope switch
|
||||
{
|
||||
PageCatalog.ScopePlatform => PageCatalog.ScopePlatform,
|
||||
PageCatalog.ScopeMonitor => PageCatalog.ScopeMonitor,
|
||||
PageCatalog.Wildcard => PageCatalog.Wildcard,
|
||||
_ => throw new RbacException($"无效 scope: {scope}(应为 Platform / RCSMonitor / *)")
|
||||
};
|
||||
|
||||
private static int Rank(string visibility) => visibility switch
|
||||
{
|
||||
"interactive" => 2,
|
||||
"readonly" => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static RbacUser NewUser(string id, string username, string displayName, string password, List<string> roleIds)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
return new RbacUser
|
||||
{
|
||||
Id = id,
|
||||
Username = username,
|
||||
DisplayName = displayName,
|
||||
Enabled = true,
|
||||
RoleIds = roleIds,
|
||||
Salt = Convert.ToBase64String(salt),
|
||||
PasswordHash = Convert.ToBase64String(Pbkdf2(password, salt))
|
||||
};
|
||||
}
|
||||
|
||||
private RbacUserDto ToDto(RbacUser u) =>
|
||||
new(u.Id, u.Username, u.DisplayName, u.Enabled, new List<string>(u.RoleIds), UsableScopesNoLock(u));
|
||||
|
||||
private List<string> UsableScopesNoLock(RbacUser user)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var r in RolesOf(user))
|
||||
{
|
||||
if (r.Scope == PageCatalog.Wildcard) { set.Add(PageCatalog.ScopePlatform); set.Add(PageCatalog.ScopeMonitor); }
|
||||
else set.Add(r.Scope);
|
||||
}
|
||||
return set.ToList();
|
||||
}
|
||||
|
||||
private static bool VerifyHash(string password, string saltB64, string hashB64)
|
||||
{
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(saltB64);
|
||||
var expected = Convert.FromBase64String(hashB64);
|
||||
var actual = Pbkdf2(password, salt);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static byte[] Pbkdf2(string password, byte[] salt) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
|
||||
|
||||
private static string NewId() => Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private static RbacUser Clone(RbacUser u) => new()
|
||||
{
|
||||
Id = u.Id, Username = u.Username, DisplayName = u.DisplayName, Enabled = u.Enabled,
|
||||
RoleIds = new List<string>(u.RoleIds), Salt = u.Salt, PasswordHash = u.PasswordHash
|
||||
};
|
||||
|
||||
private static RbacRole Clone(RbacRole r) => new()
|
||||
{
|
||||
Id = r.Id, Name = r.Name, Description = r.Description, Scope = r.Scope,
|
||||
Pages = new List<string>(r.Pages), Ops = new List<string>(r.Ops),
|
||||
WidgetGrants = r.WidgetGrants.Select(w => new WidgetGrantDto(w.WidgetId, w.Visibility)).ToList(),
|
||||
System = r.System
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>RBAC 业务校验异常 —— 由 <c>RbacController</c> 统一翻译成 400 + message。</summary>
|
||||
public sealed class RbacException : Exception
|
||||
{
|
||||
public RbacException(string message) : base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ChargePriorityRule(string Id, string Condition, double Weight);
|
||||
|
||||
public record ChargePolicy(
|
||||
bool AllowMidTaskCharge,
|
||||
int IdleChargeAfterSec,
|
||||
List<ChargePriorityRule> Priority)
|
||||
{
|
||||
public static ChargePolicy Default() => new(
|
||||
AllowMidTaskCharge: false,
|
||||
IdleChargeAfterSec: 300,
|
||||
Priority: new List<ChargePriorityRule>
|
||||
{
|
||||
new("CP1", "soc<0.2", 100),
|
||||
new("CP2", "idle>5min", 30)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
public static readonly string[] AllSections =
|
||||
{
|
||||
"system", "integrations", "routing", "vehicle", "charge", "task",
|
||||
"traffic", "auth", "device", "fleet", "scenario", "location", "ops", "widget",
|
||||
"deployment"
|
||||
};
|
||||
|
||||
public sealed record Envelope(string Section, int Version, DateTimeOffset UpdatedAt, object Payload);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Envelope> _mem = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly string _dataDir;
|
||||
private readonly ILogger<ConfigStore> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public ConfigStore(IWebHostEnvironment env, ILogger<ConfigStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(_dataDir);
|
||||
SeedAndLoad();
|
||||
}
|
||||
|
||||
public Envelope Get(string section)
|
||||
{
|
||||
section = section.ToLowerInvariant();
|
||||
if (_mem.TryGetValue(section, out var env))
|
||||
{
|
||||
if (section == "ops") return MergeOpsEnvelope(env);
|
||||
return env;
|
||||
}
|
||||
var def = NewDefault(section);
|
||||
_mem[section] = def;
|
||||
Persist(def);
|
||||
return def;
|
||||
}
|
||||
|
||||
public Envelope Put(string section, JsonElement payload)
|
||||
{
|
||||
section = section.ToLowerInvariant();
|
||||
if (!AllSections.Contains(section))
|
||||
throw new ArgumentException($"未知 section: {section}");
|
||||
|
||||
var prev = _mem.TryGetValue(section, out var p) ? p : null;
|
||||
var version = (prev?.Version ?? 0) + 1;
|
||||
// payload 保留 JsonElement 原样;序列化器会按 camelCase 输出
|
||||
var env = new Envelope(section, version, DateTimeOffset.UtcNow, JsonElementToObject(payload));
|
||||
if (section == "ops") env = MergeOpsEnvelope(env);
|
||||
_mem[section] = env;
|
||||
Persist(env);
|
||||
return env;
|
||||
}
|
||||
|
||||
public IEnumerable<Envelope> List() => AllSections.Select(Get);
|
||||
|
||||
/// <summary>
|
||||
/// 强类型读取部署画像(<c>deployment</c> section)。兼容 Payload 为 <see cref="DeploymentProfile"/>(默认值场景)
|
||||
/// 或 <see cref="JsonElement"/>(已持久化场景)两种形态,并把 null 列表/空字符串规整为安全默认值,
|
||||
/// 供 AuthController(NeedsWizard)/ WizardController / Launcher 直接使用。
|
||||
/// </summary>
|
||||
public DeploymentProfile GetDeployment()
|
||||
{
|
||||
var env = Get("deployment");
|
||||
var dp = env.Payload switch
|
||||
{
|
||||
DeploymentProfile d => d,
|
||||
JsonElement el => SafeDeserializeDeployment(el),
|
||||
_ => DeploymentProfile.Default()
|
||||
};
|
||||
return new DeploymentProfile(
|
||||
dp.Configured,
|
||||
string.IsNullOrWhiteSpace(dp.PlatformType) ? "standard" : dp.PlatformType,
|
||||
dp.Modules ?? new List<string>(),
|
||||
dp.NavigationKinds ?? new List<string>(),
|
||||
dp.Scenarios ?? new List<string>(),
|
||||
dp.UpdatedBy ?? "");
|
||||
}
|
||||
|
||||
/// <summary>以强类型保存部署画像(统一经 <see cref="Put"/> 走版本/持久化/camelCase 序列化)。</summary>
|
||||
public Envelope PutDeployment(DeploymentProfile profile)
|
||||
{
|
||||
var el = JsonSerializer.SerializeToElement(profile, _jsonOpts);
|
||||
return Put("deployment", el);
|
||||
}
|
||||
|
||||
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
|
||||
{
|
||||
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "反序列化 deployment 失败,回退默认值");
|
||||
return DeploymentProfile.Default();
|
||||
}
|
||||
}
|
||||
|
||||
private static object JsonElementToObject(JsonElement el)
|
||||
{
|
||||
// 简化:直接保留 JsonElement,让 STJ 在响应时再原样写回
|
||||
return el.Clone();
|
||||
}
|
||||
|
||||
private void SeedAndLoad()
|
||||
{
|
||||
foreach (var s in AllSections)
|
||||
{
|
||||
var file = FilePath(s);
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : 1;
|
||||
var updated = root.TryGetProperty("updatedAt", out var u) && u.ValueKind == JsonValueKind.String
|
||||
? DateTimeOffset.Parse(u.GetString()!) : DateTimeOffset.UtcNow;
|
||||
var payload = root.TryGetProperty("payload", out var pl)
|
||||
? (object)pl.Clone()
|
||||
: DefaultPayload(s);
|
||||
_mem[s] = new Envelope(s, version, updated, payload);
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "加载 {Section} 失败,回退到默认值", s);
|
||||
// S2:疑似损坏的配置先备份,避免随后写入的默认值把用户配置永久冲掉。
|
||||
var bak = AtomicFile.BackupCorrupt(file);
|
||||
if (bak != null) _logger.LogWarning("已备份疑似损坏的 {Section} 配置到 {Backup}", s, bak);
|
||||
}
|
||||
}
|
||||
_mem[s] = NewDefault(s);
|
||||
Persist(_mem[s]);
|
||||
}
|
||||
}
|
||||
|
||||
private Envelope NewDefault(string section)
|
||||
{
|
||||
return new Envelope(section, 1, DateTimeOffset.UtcNow, DefaultPayload(section));
|
||||
}
|
||||
|
||||
private static object DefaultPayload(string section) => section switch
|
||||
{
|
||||
"system" => SystemConfig.Default(),
|
||||
"integrations" => ExternalIntegrations.Default(),
|
||||
"routing" => RoutingPolicy.Default(),
|
||||
"vehicle" => VehicleMaintenancePolicy.Default(),
|
||||
"charge" => ChargePolicy.Default(),
|
||||
"task" => TaskAllocationPolicy.Default(),
|
||||
"traffic" => TrafficRule.Default(),
|
||||
"auth" => AuthRoleConfig.Default(),
|
||||
"device" => DeviceManagementConfig.Default(),
|
||||
"fleet" => FleetLifecycleConfig.Default(),
|
||||
"scenario" => ScenarioTemplateConfig.Default(),
|
||||
"location" => LocationManagement.Default(),
|
||||
"ops" => OpsConfig.Default(),
|
||||
"widget" => CustomWidgetConfig.Default(),
|
||||
"deployment" => DeploymentProfile.Default(),
|
||||
_ => new { }
|
||||
};
|
||||
|
||||
private void Persist(Envelope env)
|
||||
{
|
||||
try
|
||||
{
|
||||
var toWrite = env.Section == "ops" ? MergeOpsEnvelope(env) : env;
|
||||
var root = new Dictionary<string, object?>
|
||||
{
|
||||
["section"] = toWrite.Section,
|
||||
["version"] = toWrite.Version,
|
||||
["updatedAt"] = toWrite.UpdatedAt,
|
||||
["payload"] = toWrite.Payload
|
||||
};
|
||||
var json = JsonSerializer.Serialize(root, _jsonOpts);
|
||||
AtomicFile.WriteAllText(FilePath(env.Section), json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "持久化 {Section} 失败", env.Section);
|
||||
}
|
||||
}
|
||||
|
||||
private static Envelope MergeOpsEnvelope(Envelope env)
|
||||
{
|
||||
if (env.Payload is not JsonElement el || el.ValueKind != JsonValueKind.Object)
|
||||
return env;
|
||||
|
||||
var def = OpsConfig.Default();
|
||||
var merged = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
playback = ReadSection(el, "playback") ?? def.Playback,
|
||||
logRetention = ReadSection(el, "logRetention") ?? def.LogRetention,
|
||||
version = ReadSection(el, "version") ?? def.Version,
|
||||
monitor = MergeOpsMonitor(el, def.Monitor)
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
return env with { Payload = merged.Clone() };
|
||||
}
|
||||
|
||||
private static object? ReadSection(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var p) ? p.Clone() : null;
|
||||
|
||||
private static JsonElement MergeOpsMonitor(JsonElement root, MonitorOpsPolicy def)
|
||||
{
|
||||
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
if (!root.TryGetProperty("monitor", out var m) || m.ValueKind != JsonValueKind.Object)
|
||||
return JsonSerializer.SerializeToElement(def, opts);
|
||||
|
||||
var car = m.TryGetProperty("car", out var c) ? c.Clone() : JsonSerializer.SerializeToElement(def.Car, opts);
|
||||
var site = m.TryGetProperty("site", out var s) ? s.Clone() : JsonSerializer.SerializeToElement(def.Site, opts);
|
||||
var track = m.TryGetProperty("track", out var t) ? t.Clone() : JsonSerializer.SerializeToElement(def.Track, opts);
|
||||
var carActionByType = m.TryGetProperty("carActionByType", out var cat)
|
||||
? cat.Clone()
|
||||
: JsonSerializer.SerializeToElement(def.CarActionByType, opts);
|
||||
|
||||
return JsonSerializer.SerializeToElement(new { car, site, track, carActionByType }, opts);
|
||||
}
|
||||
|
||||
private string FilePath(string section) => Path.Combine(_dataDir, $"config-{section}.json");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record CustomWidget(
|
||||
string Id, string Name, string SchemaJson, string LayoutJson,
|
||||
List<string> BindToScopes);
|
||||
|
||||
public record CustomWidgetConfig(List<CustomWidget> Items)
|
||||
{
|
||||
public static CustomWidgetConfig Default() => new(
|
||||
Items: new List<CustomWidget>
|
||||
{
|
||||
new("widget-call-button", "呼叫按钮",
|
||||
"{\"fields\":[{\"name\":\"siteId\"}]}",
|
||||
"{\"x\":0,\"y\":0,\"w\":2,\"h\":1}",
|
||||
new List<string> { "RCSMonitor" })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 配置向导「可选项目录」与「选型 → 平台能力」映射的单一事实来源。
|
||||
///
|
||||
/// <para>前端向导从 <c>GET /api/wizard/options</c> 拿到这些可选项渲染勾选框;后端
|
||||
/// <see cref="ModuleToPages"/> 在「按选型裁剪菜单」时把模块
|
||||
/// 映射为 PageCatalog 的可见页 id。集中定义,避免前后端各写一份导致漂移。</para>
|
||||
/// </summary>
|
||||
public static class DeploymentCatalog
|
||||
{
|
||||
/// <summary>一个可勾选项。<see cref="Id"/> 是稳定机器标识,<see cref="Group"/> 用于前端分组展示。</summary>
|
||||
public record Option(string Id, string Name, string Group, string Description);
|
||||
|
||||
/// <summary>导航方式(多选,一等维度)。id 与 <see cref="DeploymentProfile.NavKindToSceneId"/> 的 key 对齐。</summary>
|
||||
public static readonly IReadOnlyList<Option> NavigationKinds = new[]
|
||||
{
|
||||
new Option("magnetic", "磁导航", "navigation", "磁条循迹 + 地标 / RFID 定位"),
|
||||
new Option("qrcode", "二维码导航", "navigation", "二维码地标 + 码值地图"),
|
||||
new Option("laser", "激光导航", "navigation", "反光板 / SLAM + 激光避障"),
|
||||
};
|
||||
|
||||
/// <summary>功能模块(多选)。暂定保留 WMS / PTL 两项,后续按需扩展(参考 RIOT 的 WMS/WCS/MES/APS 分层)。</summary>
|
||||
public static readonly IReadOnlyList<Option> Modules = new[]
|
||||
{
|
||||
new Option("wms", "WMS 仓储管理", "module", "库位 / 库存 / 出入库管理"),
|
||||
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
|
||||
};
|
||||
|
||||
/// <summary>功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["wms"] = new[] { "admin-config-location" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||
public static IReadOnlyCollection<string> TailorablePages()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dp == null) return set;
|
||||
foreach (var m in dp.Modules ?? new List<string>())
|
||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下应隐藏的页(可裁剪但未被点亮)。向导未完成(Configured=false)则不隐藏任何页。</summary>
|
||||
public static IReadOnlyCollection<string> HiddenPages(DeploymentProfile dp)
|
||||
{
|
||||
if (dp == null || !dp.Configured) return Array.Empty<string>();
|
||||
var enabled = EnabledPages(dp);
|
||||
return TailorablePages().Where(p => !enabled.Contains(p)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 RBAC 计算出的可见页集合中移除「被部署画像隐藏」的页 —— 这是「按选型裁剪菜单」的核心。
|
||||
/// 不在任何映射中的页不受影响;向导未完成时原样返回(避免首登空菜单)。
|
||||
/// </summary>
|
||||
public static List<string> FilterPagesByDeployment(IEnumerable<string> pages, DeploymentProfile dp)
|
||||
{
|
||||
var input = (pages ?? Enumerable.Empty<string>()).ToList();
|
||||
if (dp == null || !dp.Configured) return input;
|
||||
var hidden = new HashSet<string>(HiddenPages(dp), StringComparer.OrdinalIgnoreCase);
|
||||
return input.Where(p => !hidden.Contains(p)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 部署画像 —— 登录后「配置向导」的结果,对应 ConfigStore 的 <c>deployment</c> section
|
||||
/// (持久化于 <c>data/config-deployment.json</c>)。
|
||||
///
|
||||
/// <para>它是「按选型裁剪」的单一事实来源:</para>
|
||||
/// <list type="number">
|
||||
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
|
||||
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id,
|
||||
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
|
||||
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="Configured">向导是否已完成。false = 登录后强制进入配置向导。</param>
|
||||
/// <param name="PlatformType">平台主定位(仅用于 UI 标题/默认值),如 <c>standard</c> / <c>wcs</c> / <c>wms-wcs</c> / <c>custom</c>。</param>
|
||||
/// <param name="Modules">启用的功能模块(多选),暂定 <c>wms</c> / <c>ptl</c>。</param>
|
||||
/// <param name="NavigationKinds">导航方式(多选,一等维度),取值 <c>magnetic</c> / <c>qrcode</c> / <c>laser</c>,与 SimpleCore.NavKind 对齐。</param>
|
||||
/// <param name="Scenarios">选用的业务场景模板 id(来自 ScenarioTemplateConfig),如 <c>tpl-sps</c> / <c>tpl-pack</c>。</param>
|
||||
/// <param name="UpdatedBy">最近一次保存向导的用户名(审计用)。</param>
|
||||
public record DeploymentProfile(
|
||||
bool Configured,
|
||||
string PlatformType,
|
||||
List<string> Modules,
|
||||
List<string> NavigationKinds,
|
||||
List<string> Scenarios,
|
||||
string UpdatedBy)
|
||||
{
|
||||
public static DeploymentProfile Default() => new(
|
||||
Configured: false,
|
||||
PlatformType: "standard",
|
||||
Modules: new List<string>(),
|
||||
NavigationKinds: new List<string>(),
|
||||
Scenarios: new List<string>(),
|
||||
UpdatedBy: "");
|
||||
|
||||
/// <summary>
|
||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> NavKindToSceneId =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["magnetic"] = "scene.magnetic",
|
||||
["qrcode"] = "scene.qrcode",
|
||||
["laser"] = "scene.laser",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
||||
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ToActiveSceneIds()
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (NavigationKinds == null) return result;
|
||||
foreach (var k in NavigationKinds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(k)) continue;
|
||||
if (NavKindToSceneId.TryGetValue(k.Trim(), out var sceneId) && !result.Contains(sceneId))
|
||||
result.Add(sceneId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record DeviceDriverBinding(string Id, string DeviceType, string DriverName, string Version);
|
||||
|
||||
public record DeviceInstance(
|
||||
string Id, string Name, string DeviceType, string Protocol, string Address,
|
||||
string DriverId, bool Enabled);
|
||||
|
||||
public record DeviceHealthPolicy(int HeartbeatSec, int OfflineSec);
|
||||
|
||||
public record AlarmRule(string Level, string Condition);
|
||||
public record AlarmPolicy(bool Enabled, List<AlarmRule> Rules);
|
||||
|
||||
public record DeviceManagementConfig(
|
||||
List<DeviceDriverBinding> Drivers,
|
||||
List<DeviceInstance> Devices,
|
||||
DeviceHealthPolicy HealthPolicy,
|
||||
AlarmPolicy AlarmPolicy)
|
||||
{
|
||||
public static DeviceManagementConfig Default() => new(
|
||||
Drivers: new List<DeviceDriverBinding>
|
||||
{
|
||||
new("drv-elev", "电梯", "OpcUaElevatorDriver", "1.2.0"),
|
||||
new("drv-chrg", "充电桩", "ModbusChargerDriver", "1.0.5"),
|
||||
new("drv-cam", "摄像头", "OnvifCameraDriver", "2.1.0")
|
||||
},
|
||||
Devices: new List<DeviceInstance>
|
||||
{
|
||||
new("dev-elev-1", "#1 电梯", "电梯", "opc-ua", "opc.tcp://10.0.2.20:4840", "drv-elev", true),
|
||||
new("dev-chrg-1", "充电桩-A1", "充电桩", "modbus-tcp", "10.0.2.30:502", "drv-chrg", true)
|
||||
},
|
||||
HealthPolicy: new DeviceHealthPolicy(5, 30),
|
||||
AlarmPolicy: new AlarmPolicy(true, new List<AlarmRule>
|
||||
{
|
||||
new("warn", "offline>30s"),
|
||||
new("error", "driverException")
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record WidgetGrantDto(string WidgetId, string Visibility);
|
||||
|
||||
public record AuthRole(
|
||||
string Id, string Name, string Scope,
|
||||
List<string> Permissions,
|
||||
List<WidgetGrantDto> WidgetGrants);
|
||||
|
||||
public record AuthUser(string Id, string Username, List<string> Roles, bool Enabled);
|
||||
|
||||
public record AuthRoleConfig(List<AuthRole> Roles, List<AuthUser> Users)
|
||||
{
|
||||
public static AuthRoleConfig Default() => new(
|
||||
Roles: new List<AuthRole>
|
||||
{
|
||||
new("role-admin", "管理员", "Platform",
|
||||
new List<string> { "*" },
|
||||
new List<WidgetGrantDto>()),
|
||||
new("role-ops", "运营", "RCSMonitor",
|
||||
new List<string>
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome",
|
||||
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
},
|
||||
new List<WidgetGrantDto>
|
||||
{
|
||||
new("MapEditor", "readonly"),
|
||||
new("CadToolbar", "hidden")
|
||||
})
|
||||
},
|
||||
Users: new List<AuthUser>
|
||||
{
|
||||
new("u-admin", "admin", new List<string> { "role-admin" }, true),
|
||||
new("u-ops", "ops", new List<string> { "role-ops" }, true)
|
||||
});
|
||||
}
|
||||
|
||||
public record EffectivePermissions(
|
||||
string UserId,
|
||||
int Version,
|
||||
List<string> AllowedOps,
|
||||
List<WidgetGrantDto> VisibleWidgets,
|
||||
List<string> AllowedPages);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record EndpointDescriptor(string Id, string Name, string Url, bool Enabled);
|
||||
|
||||
public record ExternalIntegrations(
|
||||
List<EndpointDescriptor> Mes,
|
||||
List<EndpointDescriptor> Wms,
|
||||
List<EndpointDescriptor> Rcs,
|
||||
List<EndpointDescriptor> Ptl)
|
||||
{
|
||||
public static ExternalIntegrations Default() => new(
|
||||
Mes: new List<EndpointDescriptor> { new("mes-1", "MES 主线", "http://mes.lan/api", true) },
|
||||
Wms: new List<EndpointDescriptor> { new("wms-1", "WMS 仓储", "http://wms.lan/api", true) },
|
||||
Rcs: new List<EndpointDescriptor>(),
|
||||
Ptl: new List<EndpointDescriptor> { new("ptl-1", "PTL 拣选", "http://ptl.lan/api", true) });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FleetGroup(string Id, string Name, string Floor, string Region, List<string> CarIds);
|
||||
public record OtaPolicy(bool Enabled, int BatchSize, bool RollbackOnFail);
|
||||
public record BatchOpsPolicy(bool ConfirmationRequired, int MaxBatch);
|
||||
public record NetworkDiagPolicy(int RttThresholdMs, double PacketLossThreshold);
|
||||
|
||||
public record FleetLifecycleConfig(
|
||||
List<FleetGroup> Groups,
|
||||
OtaPolicy Ota,
|
||||
BatchOpsPolicy BatchOps,
|
||||
NetworkDiagPolicy NetworkDiag)
|
||||
{
|
||||
public static FleetLifecycleConfig Default() => new(
|
||||
Groups: new List<FleetGroup>
|
||||
{
|
||||
new("G-A", "A 区车队", "F1", "A", new List<string> { "C01", "C02", "C03" }),
|
||||
new("G-B", "B 区车队", "F1", "B", new List<string> { "C04", "C05" })
|
||||
},
|
||||
Ota: new OtaPolicy(true, 2, true),
|
||||
BatchOps: new BatchOpsPolicy(true, 10),
|
||||
NetworkDiag: new NetworkDiagPolicy(80, 0.02));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record Location(string Id, string Code, string Name, string SiteId, int Capacity, int Occupied);
|
||||
public record InventoryRule(string Id, string ItemType, int MinQty, int MaxQty);
|
||||
|
||||
public record LocationManagement(
|
||||
List<Location> Locations,
|
||||
List<InventoryRule> InventoryRules)
|
||||
{
|
||||
public static LocationManagement Default() => new(
|
||||
Locations: new List<Location>
|
||||
{
|
||||
new("L01", "A-01", "A 区货架 1", "S001", 20, 12),
|
||||
new("L02", "A-02", "A 区货架 2", "S002", 20, 7),
|
||||
new("L03", "B-01", "B 区缓存", "S003", 30, 25)
|
||||
},
|
||||
InventoryRules: new List<InventoryRule>
|
||||
{
|
||||
new("IR1", "PalletA", 5, 30)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 运维操作审计存储:内存队列 + <c>data/ops-audit.json</c> 原子持久化。
|
||||
///
|
||||
/// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢,
|
||||
/// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘,
|
||||
/// 仅保留最近 <see cref="MaxEntries"/> 条避免无限增长。
|
||||
/// </summary>
|
||||
public sealed class OpsAuditStore
|
||||
{
|
||||
public sealed record AuditEntry(
|
||||
string Id,
|
||||
DateTimeOffset Ts,
|
||||
string User,
|
||||
string Scope,
|
||||
string OpCode,
|
||||
string Target,
|
||||
string Result,
|
||||
string? Message,
|
||||
string? IdempotencyKey = null);
|
||||
|
||||
private const int MaxEntries = 500;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly string _file;
|
||||
private readonly ILogger<OpsAuditStore> _logger;
|
||||
private readonly List<AuditEntry> _entries = new();
|
||||
private long _seq;
|
||||
|
||||
private readonly JsonSerializerOptions _json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OpsAuditStore(IWebHostEnvironment env, ILogger<OpsAuditStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var dir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dir);
|
||||
_file = Path.Combine(dir, "ops-audit.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_file)) return;
|
||||
var list = JsonSerializer.Deserialize<List<AuditEntry>>(File.ReadAllText(_file), _json);
|
||||
if (list is { Count: > 0 })
|
||||
{
|
||||
_entries.AddRange(list.Count > MaxEntries ? list.GetRange(list.Count - MaxEntries, MaxEntries) : list);
|
||||
// 续上序号,避免重启后 id 从 A000001 重新开始造成重复。
|
||||
_seq = _entries.Select(e => ParseSeq(e.Id)).DefaultIfEmpty(0).Max();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ops 审计文件 {File} 加载失败,将以空记录开始并备份损坏文件", _file);
|
||||
AtomicFile.BackupCorrupt(_file);
|
||||
}
|
||||
}
|
||||
|
||||
private static long ParseSeq(string id) => long.TryParse(id.TrimStart('A'), out var n) ? n : 0;
|
||||
|
||||
/// <summary>追加一条审计并原子落盘。返回生成的条目(含 Id)。</summary>
|
||||
public AuditEntry Append(string user, string scope, string opCode, string target, string result, string? message, string? idempotencyKey = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = new AuditEntry(
|
||||
$"A{++_seq:D6}", DateTimeOffset.UtcNow,
|
||||
user, scope, opCode, target, result, message, idempotencyKey);
|
||||
_entries.Add(entry);
|
||||
if (_entries.Count > MaxEntries)
|
||||
_entries.RemoveRange(0, _entries.Count - MaxEntries);
|
||||
Persist();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。
|
||||
/// 仅匹配成功记录:上次失败的请求允许重试重新下发。
|
||||
/// </summary>
|
||||
public AuditEntry? FindSuccessByIdempotencyKey(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||
lock (_gate)
|
||||
{
|
||||
for (var i = _entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var e = _entries[i];
|
||||
if (e.Result == "ok" && string.Equals(e.IdempotencyKey, key, StringComparison.Ordinal))
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>最近的审计(倒序,最新在前)。</summary>
|
||||
public IReadOnlyList<AuditEntry> Recent()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var copy = new List<AuditEntry>(_entries);
|
||||
copy.Reverse();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
try
|
||||
{
|
||||
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record PlaybackPolicy(int RetentionDays, int SamplingHz);
|
||||
public record LogRetention(int HotDays, int ColdDays);
|
||||
public record VersionPolicy(int KeepReleases);
|
||||
public record MonitorPanelPolicy(string[] PropertyKeys, string[] StatusKeys, string[] ActionKeys);
|
||||
public record MonitorOpsPolicy(
|
||||
MonitorPanelPolicy Car,
|
||||
MonitorPanelPolicy Site,
|
||||
MonitorPanelPolicy Track,
|
||||
Dictionary<string, string[]> CarActionByType);
|
||||
|
||||
public record OpsConfig(
|
||||
PlaybackPolicy Playback,
|
||||
LogRetention LogRetention,
|
||||
VersionPolicy Version,
|
||||
MonitorOpsPolicy Monitor)
|
||||
{
|
||||
public static OpsConfig Default() => new(
|
||||
Playback: new PlaybackPolicy(30, 5),
|
||||
LogRetention: new LogRetention(7, 180),
|
||||
Version: new VersionPolicy(5),
|
||||
Monitor: new MonitorOpsPolicy(
|
||||
Car: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Site: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Track: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
CarActionByType: new Dictionary<string, string[]>()));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record AvoidanceRule(string Id, string ZoneId, string Rule);
|
||||
public record ZoneSpeedLimit(string ZoneId, double MaxSpeedMps);
|
||||
|
||||
public record RoutingPolicy(
|
||||
string Algorithm,
|
||||
Dictionary<string, double> Weights,
|
||||
List<AvoidanceRule> Avoidance,
|
||||
List<ZoneSpeedLimit> ZoneSpeedLimits)
|
||||
{
|
||||
public static RoutingPolicy Default() => new(
|
||||
Algorithm: "astar",
|
||||
Weights: new Dictionary<string, double>
|
||||
{
|
||||
["distance"] = 1.0,
|
||||
["congestion"] = 0.5,
|
||||
["turnPenalty"] = 0.2
|
||||
},
|
||||
Avoidance: new List<AvoidanceRule> { new("AV1", "Z-NORTH", "no-entry-while-loading") },
|
||||
ZoneSpeedLimits: new List<ZoneSpeedLimit> { new("Z-NARROW", 0.5) });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ScenarioTemplate(string Id, string Name, string Category, string Version, string BaselineJson);
|
||||
public record TemplateDslPolicy(bool Enabled, string SchemaVersion);
|
||||
public record LowCodePolicy(bool Enabled, string Editor);
|
||||
public record TemplateVersionPolicy(int KeepVersions, bool AllowRollback);
|
||||
|
||||
public record ScenarioTemplateConfig(
|
||||
List<ScenarioTemplate> Templates,
|
||||
TemplateDslPolicy DslPolicy,
|
||||
LowCodePolicy LowCode,
|
||||
TemplateVersionPolicy VersionPolicy)
|
||||
{
|
||||
public static ScenarioTemplateConfig Default() => new(
|
||||
Templates: new List<ScenarioTemplate>
|
||||
{
|
||||
new("tpl-sps", "SPS 物料配送", "SPS", "1.0.0", "{}"),
|
||||
new("tpl-pack", "电池 Pack 自动化产线", "BatteryPack", "1.0.0", "{}"),
|
||||
new("tpl-loop", "环线运行", "Loop", "1.0.0", "{}"),
|
||||
new("tpl-p2p", "点对点柔性搬运", "P2P", "1.0.0", "{}")
|
||||
},
|
||||
DslPolicy: new TemplateDslPolicy(true, "1"),
|
||||
LowCode: new LowCodePolicy(false, "json"),
|
||||
VersionPolicy: new TemplateVersionPolicy(10, true));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
||||
|
||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
||||
{
|
||||
public static SystemConfig Default() => new(
|
||||
DispatchLoopHz: 50,
|
||||
Log: new LogPolicy("info", 7, 256),
|
||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record TaskAllocationPolicy(
|
||||
string Mode,
|
||||
bool LoadBalance,
|
||||
int MaxQueuePerCar)
|
||||
{
|
||||
public static TaskAllocationPolicy Default() => new(
|
||||
Mode: "leastLoad",
|
||||
LoadBalance: true,
|
||||
MaxQueuePerCar: 3);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record IntersectionPolicy(string Id, List<string> SiteIds, string Mode);
|
||||
public record ZoneMutex(string Id, List<string> ZoneIds);
|
||||
public record DynamicYield(string Id, string From, string To, string Condition);
|
||||
|
||||
public record TrafficRule(
|
||||
List<IntersectionPolicy> Intersections,
|
||||
List<ZoneMutex> Mutex,
|
||||
List<DynamicYield> Yields)
|
||||
{
|
||||
public static TrafficRule Default() => new(
|
||||
Intersections: new List<IntersectionPolicy> { new("IX1", new List<string> { "S006", "S007" }, "mutex") },
|
||||
Mutex: new List<ZoneMutex> { new("MZ1", new List<string> { "Z-CROSS" }) },
|
||||
Yields: new List<DynamicYield> { new("YD1", "A 区", "B 区", "priority<peer") });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FaultReportPolicy(bool Enabled, List<string> EmailTo);
|
||||
public record AutoRepairPolicy(bool Enabled, int CooldownSec);
|
||||
|
||||
public record VehicleMaintenancePolicy(
|
||||
double LowBatteryThreshold,
|
||||
double CriticalBatteryThreshold,
|
||||
FaultReportPolicy FaultReport,
|
||||
AutoRepairPolicy AutoRepair)
|
||||
{
|
||||
public static VehicleMaintenancePolicy Default() => new(
|
||||
LowBatteryThreshold: 0.3,
|
||||
CriticalBatteryThreshold: 0.15,
|
||||
FaultReport: new FaultReportPolicy(true, new List<string> { "ops@example.com" }),
|
||||
AutoRepair: new AutoRepairPolicy(false, 600));
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||||
/// (<c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
|
||||
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
|
||||
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
|
||||
///
|
||||
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>,
|
||||
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
||||
///
|
||||
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>,
|
||||
/// 其中 tag 为 <c>/</c> 表示无标签(滚动记录)。不匹配该模式的行视为上一条的续行(多行内容)。
|
||||
///
|
||||
/// 鉴权:class 级 <c>[Authorize(Policy = "PlatformScope")]</c> —— 日志为内核落盘文件(可能含文件路径 /
|
||||
/// 内部运行状态等敏感信息),仅平台后台用户(scope=Platform)可读 / 下载,排除 RCSMonitor 监控大屏 token。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
[Route("api/logs")]
|
||||
public sealed class LogsController : ControllerBase
|
||||
{
|
||||
/// <summary>文件列表默认上限(与 LogViewer.cs 的 MaxFilesShown=500 对齐)。</summary>
|
||||
private const int DefaultFileLimit = 500;
|
||||
|
||||
/// <summary>单次解析的条目硬上限,防超大日志(实测单文件可达 46MB)撑爆内存。</summary>
|
||||
private const int MaxEntries = 200_000;
|
||||
|
||||
/// <summary>单次扫描字节上限(超过则截断并标记 truncated)。</summary>
|
||||
private const long MaxScanBytes = 96L * 1024 * 1024;
|
||||
|
||||
/// <summary>合订/某天聚合时最多遍历的文件数,避免一次扫描整月日志。</summary>
|
||||
private const int MaxDigestFiles = 64;
|
||||
|
||||
/// <summary>跨文件聚合(analyze/digest 的 day 模式)的总条目上限,防某天多个大文件 entries 累加撑爆内存。</summary>
|
||||
private const int MaxAggregateEntries = 300_000;
|
||||
|
||||
/// <summary>Diagnosis 落盘行:<c>[head] >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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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] + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace MiGu.Server.Infra;
|
||||
|
||||
/// <summary>
|
||||
/// 原子写文件工具:先写同目录临时文件,再用 File.Replace / File.Move 整体替换目标,
|
||||
/// 避免 File.WriteAllText 写到一半进程崩溃 / 断电导致目标文件被截断成「半个 JSON」。
|
||||
///
|
||||
/// 用于 rbac.json / config-*.json / ops-audit.json 等关键持久化文件 —— 这些文件一旦损坏,
|
||||
/// 加载时会被当成「解析失败」回退默认 seed,进而静默丢失自定义用户 / 角色 / 配置。
|
||||
/// </summary>
|
||||
public static class AtomicFile
|
||||
{
|
||||
public static void WriteAllText(string path, string contents)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// 临时名带 GUID:保证同一目标文件的并发写各用独立临时文件,互不覆盖(即便调用方未加锁)。
|
||||
var tmp = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tmp, contents);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Replace(tmp, path, null);
|
||||
else
|
||||
File.Move(tmp, path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// 个别环境(杀软锁定 / 跨卷)File.Replace 会失败:退化为覆盖复制兜底(仍优于半截写入)。
|
||||
File.Copy(tmp, path, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 兜底清理:File.Replace/Move 成功时 tmp 已不存在;其余异常路径下避免遗留临时文件累积。
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把疑似损坏的文件复制一份带时间戳的备份(不抛异常)。返回备份路径或 null。</summary>
|
||||
public static string? BackupCorrupt(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var bak = $"{path}.corrupt-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
File.Copy(path, bak, true);
|
||||
return bak;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
|
||||
/// </summary>
|
||||
public static class SimpleLiteBuildSync
|
||||
{
|
||||
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
|
||||
{
|
||||
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
|
||||
return false;
|
||||
|
||||
var binDll = Path.Combine(binDir, "SimpleLite.dll");
|
||||
var binExe = Path.Combine(binDir, "SimpleLite.exe");
|
||||
|
||||
if (!File.Exists(objDll))
|
||||
{
|
||||
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
|
||||
{
|
||||
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
|
||||
return false;
|
||||
}
|
||||
|
||||
var objTime = File.GetLastWriteTimeUtc(objDll);
|
||||
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
|
||||
{
|
||||
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
|
||||
return false;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(binDir);
|
||||
File.Copy(objDll, binDll, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
|
||||
|
||||
if (File.Exists(objExe))
|
||||
{
|
||||
File.Copy(objExe, binExe, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
|
||||
}
|
||||
|
||||
SyncRuntimeDeps(objDll, binDir, log);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool? ProbeGotoSiteRoute(int port = 8222)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
using var resp = client.PostAsync(
|
||||
$"http://127.0.0.1:{port}/projection/reflection/car/0/goto-site?siteId=0",
|
||||
null).GetAwaiter().GetResult();
|
||||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return body.Contains("<html", StringComparison.OrdinalIgnoreCase) ? false : true;
|
||||
return body.Contains("\"success\"", StringComparison.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>同步 obj 输出目录中的运行时依赖(Costura 未嵌入或需独立存在的 DLL)。</summary>
|
||||
private static void SyncRuntimeDeps(string objDll, string binDir, ILogger? log)
|
||||
{
|
||||
var objDir = Path.GetDirectoryName(objDll);
|
||||
if (string.IsNullOrEmpty(objDir)) return;
|
||||
|
||||
var names = new[] { "LessokajiWeaverUtilities.dll" };
|
||||
foreach (var name in names)
|
||||
{
|
||||
var src = Path.Combine(objDir, name);
|
||||
if (!File.Exists(src))
|
||||
{
|
||||
var deps = Path.Combine(objDir, "..", "..", "tools", "deps", name);
|
||||
deps = Path.GetFullPath(deps);
|
||||
if (File.Exists(deps)) src = deps;
|
||||
else continue;
|
||||
}
|
||||
|
||||
var dst = Path.Combine(binDir, name);
|
||||
File.Copy(src, dst, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolvePaths(string contentRoot, out string objDll, out string objExe, out string binDir)
|
||||
{
|
||||
objDll = objExe = "";
|
||||
binDir = "";
|
||||
var repo = FindRepoRoot(contentRoot);
|
||||
if (repo == null) return false;
|
||||
var sl = Path.Combine(repo, "Simple", "SimpleLite");
|
||||
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
|
||||
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
|
||||
binDir = Path.Combine(sl, "bin", "Debug");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot(string contentRoot)
|
||||
{
|
||||
var dir = new DirectoryInfo(contentRoot);
|
||||
while (dir != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||||
///
|
||||
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||||
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||||
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||||
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
|
||||
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
|
||||
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
|
||||
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
private readonly SimpleLiteOptions _opts;
|
||||
private readonly ILogger<SimpleLiteLauncher> _log;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private Process? _proc;
|
||||
private IntPtr _job = IntPtr.Zero;
|
||||
private string? _lastLaunchMode;
|
||||
private bool _disposed;
|
||||
|
||||
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_log = log;
|
||||
_env = env;
|
||||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
||||
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||
if (_opts.FollowParent)
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync) return _proc is { HasExited: false };
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||||
|
||||
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||
internal const string ExternalReuseLaunchMode = "external";
|
||||
|
||||
/// <summary>
|
||||
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
||||
/// </summary>
|
||||
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||||
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||||
public LaunchResult MaybeStart(string launchMode, bool waitForReady = true)
|
||||
{
|
||||
var displayMode = NormalizeDisplayMode(launchMode);
|
||||
|
||||
if (!_opts.Enabled)
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||||
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
|
||||
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
|
||||
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
|
||||
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
||||
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
|
||||
: null;
|
||||
return new LaunchResult(true, "AlreadyRunning",
|
||||
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||||
DisplayMode: _lastLaunchMode,
|
||||
Warning: warning);
|
||||
}
|
||||
|
||||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||||
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||||
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||
//
|
||||
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
||||
//
|
||||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
||||
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
|
||||
{
|
||||
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
|
||||
if (!probeOk)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
|
||||
_opts.ProjectionPort);
|
||||
return new LaunchResult(false, "PortOccupied",
|
||||
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
||||
DisplayMode: null,
|
||||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
|
||||
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
|
||||
}
|
||||
|
||||
_log.LogInformation(
|
||||
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
|
||||
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
|
||||
_opts.ProjectionPort);
|
||||
|
||||
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||||
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||||
_lastLaunchMode = ExternalReuseLaunchMode;
|
||||
|
||||
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
|
||||
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
|
||||
{
|
||||
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
|
||||
}
|
||||
|
||||
return new LaunchResult(true, "ReusingExisting",
|
||||
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
|
||||
DisplayMode: ExternalReuseLaunchMode,
|
||||
Warning: reuseWarning);
|
||||
}
|
||||
|
||||
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
if (resolved == null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
|
||||
_env.ContentRootPath);
|
||||
return new LaunchResult(false, "ExecutableNotFound",
|
||||
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
|
||||
DisplayMode: null,
|
||||
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
|
||||
}
|
||||
|
||||
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||||
? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory
|
||||
: ResolveConfiguredDirectory(_opts.WorkingDirectory) ?? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory;
|
||||
|
||||
var arguments = BuildArguments(displayMode, _opts.Arguments);
|
||||
|
||||
try
|
||||
{
|
||||
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
|
||||
if (proc == null)
|
||||
{
|
||||
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
|
||||
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
|
||||
}
|
||||
|
||||
WireProcessExitHandler(proc);
|
||||
|
||||
_proc = proc;
|
||||
_lastLaunchMode = displayMode;
|
||||
|
||||
if (_opts.FollowParent) AttachToJobObject(proc);
|
||||
|
||||
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
|
||||
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[SimpleLite] auto-start failed");
|
||||
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
|
||||
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
|
||||
// GET /api/health/simplelite 获知就绪状态。
|
||||
if (!waitForReady)
|
||||
{
|
||||
return new LaunchResult(true, "Starting",
|
||||
$"projection :{_opts.ProjectionPort} readiness wait skipped (async), displayMode={displayMode}",
|
||||
DisplayMode: displayMode,
|
||||
Warning: null);
|
||||
}
|
||||
|
||||
var ready = WaitForProjectionReady();
|
||||
return new LaunchResult(true, ready ? "Ready" : "StartedButNotReady",
|
||||
ready ? $"projection :{_opts.ProjectionPort} reachable, displayMode={displayMode}"
|
||||
: $"projection :{_opts.ProjectionPort} did not respond within {_opts.ReadinessTimeoutMs}ms, displayMode={displayMode}",
|
||||
DisplayMode: displayMode,
|
||||
Warning: ready
|
||||
? null
|
||||
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动关闭 SimpleLite 子进程。
|
||||
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
||||
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Process? proc;
|
||||
IntPtr job;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
proc = _proc;
|
||||
job = _job;
|
||||
_proc = null;
|
||||
_job = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (!_opts.FollowParent)
|
||||
{
|
||||
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
|
||||
}
|
||||
try { proc?.Dispose(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
|
||||
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (job != IntPtr.Zero)
|
||||
{
|
||||
try { CloseHandle(job); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeDisplayMode(string launchMode)
|
||||
{
|
||||
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 SimpleLite「内部语言」(web / web+local)。
|
||||
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
|
||||
return launchMode?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web" or "web-only" => "web",
|
||||
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
||||
_ => "web+local",
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildArguments(string displayMode, string extra)
|
||||
{
|
||||
var args = $"--display-mode={displayMode}";
|
||||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||||
var sceneArg = ReadActiveScenesArg();
|
||||
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
|
||||
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
|
||||
return args;
|
||||
}
|
||||
|
||||
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>,
|
||||
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
|
||||
public string? ResolveWorkingDirectory()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_opts.WorkingDirectory))
|
||||
return ResolveConfiguredDirectory(_opts.WorkingDirectory);
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
|
||||
}
|
||||
|
||||
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
||||
public string? ResolvePluginsDir()
|
||||
{
|
||||
var wd = ResolveWorkingDirectory();
|
||||
return wd == null ? null : Path.Combine(wd, "plugins");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
|
||||
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
|
||||
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite 端
|
||||
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
|
||||
/// </summary>
|
||||
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
|
||||
{
|
||||
var pluginsDir = ResolvePluginsDir();
|
||||
if (pluginsDir == null)
|
||||
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(pluginsDir);
|
||||
var path = Path.Combine(pluginsDir, "active-scenes.json");
|
||||
var payload = new
|
||||
{
|
||||
activeScenes = (activeScenes ?? Enumerable.Empty<string>())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(),
|
||||
alwaysLoad = (alwaysLoad ?? Enumerable.Empty<string>())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(),
|
||||
source = string.IsNullOrWhiteSpace(source) ? "deployment-profile" : source,
|
||||
updatedAt = DateTime.UtcNow
|
||||
};
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
||||
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
|
||||
path, string.Join(",", payload.activeScenes));
|
||||
return new ActiveScenesWriteResult(true, path, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
|
||||
return new ActiveScenesWriteResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从已写入的 active-scenes.json 读取激活场景,拼成 <c>--scenes=a,b</c>(拉起时透传);无内容返回 null。</summary>
|
||||
private string? ReadActiveScenesArg()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginsDir = ResolvePluginsDir();
|
||||
if (pluginsDir == null) return null;
|
||||
var path = Path.Combine(pluginsDir, "active-scenes.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (!doc.RootElement.TryGetProperty("activeScenes", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return null;
|
||||
var ids = arr.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
return ids.Count == 0 ? null : "--scenes=" + string.Join(",", ids);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||
/// </summary>
|
||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!proc.HasExited)
|
||||
{
|
||||
proc.Kill(entireProcessTree: true);
|
||||
_log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] restart-for-update: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(1500);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_proc = null;
|
||||
_lastLaunchMode = null;
|
||||
}
|
||||
|
||||
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
var result = MaybeStart(launchMode);
|
||||
if (!synced && result.Warning == null)
|
||||
{
|
||||
return result with
|
||||
{
|
||||
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||
public SimpleLiteDiagnostics GetDiagnostics()
|
||||
{
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||
var gotoSite = projectionUp ? SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) : null;
|
||||
var deployHint = gotoSite == false
|
||||
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
||||
: null;
|
||||
return new SimpleLiteDiagnostics(
|
||||
Enabled: _opts.Enabled,
|
||||
FollowParent: _opts.FollowParent,
|
||||
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||||
ConfiguredWorkingDirectory: _opts.WorkingDirectory ?? "",
|
||||
ContentRootPath: _env.ContentRootPath,
|
||||
ResolvedExecutablePath: resolved,
|
||||
ExecutableExists: resolved != null && File.Exists(resolved),
|
||||
IsRunning: IsRunning,
|
||||
LastLaunchMode: LastLaunchMode,
|
||||
ProjectionPort: _opts.ProjectionPort,
|
||||
ProjectionPortReachable: projectionUp,
|
||||
GotoSiteApiAvailable: gotoSite,
|
||||
DeployHint: deployHint,
|
||||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
||||
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite。
|
||||
/// </summary>
|
||||
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
|
||||
{
|
||||
if (_opts.FollowParent)
|
||||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
|
||||
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
|
||||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
}
|
||||
|
||||
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolved,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workdir,
|
||||
UseShellExecute = useShellExecute,
|
||||
CreateNoWindow = false,
|
||||
WindowStyle = ProcessWindowStyle.Normal,
|
||||
};
|
||||
var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
return proc.Start() ? proc : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
|
||||
/// </summary>
|
||||
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
|
||||
{
|
||||
var exeName = Path.GetFileNameWithoutExtension(resolved);
|
||||
var beforeIds = new HashSet<int>(
|
||||
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
|
||||
.Where(id => id > 0));
|
||||
|
||||
var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
|
||||
var shimPsi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmdArgs,
|
||||
WorkingDirectory = workdir,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var shim = Process.Start(shimPsi);
|
||||
shim?.WaitForExit(8000);
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
foreach (var p in Process.GetProcessesByName(exeName))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (p.HasExited) continue;
|
||||
if (beforeIds.Contains(p.Id)) continue;
|
||||
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
|
||||
return AttachToExistingProcess(p.Id);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Process? AttachToExistingProcess(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var proc = Process.GetProcessById(pid);
|
||||
proc.EnableRaisingEvents = true;
|
||||
return proc;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void WireProcessExitHandler(Process proc)
|
||||
{
|
||||
proc.Exited += (_, _) =>
|
||||
{
|
||||
int exit;
|
||||
try { exit = proc.ExitCode; } catch { exit = -1; }
|
||||
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_proc, proc))
|
||||
{
|
||||
_proc = null;
|
||||
_lastLaunchMode = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private string? ResolveExecutable(string configured)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
var p = ResolveConfiguredFile(configured);
|
||||
if (p != null) return p;
|
||||
}
|
||||
|
||||
var cwd = _env.ContentRootPath;
|
||||
var staticCandidates = new[]
|
||||
{
|
||||
Path.Combine(cwd, "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
|
||||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
};
|
||||
foreach (var c in staticCandidates)
|
||||
{
|
||||
if (File.Exists(c)) return Path.GetFullPath(c);
|
||||
}
|
||||
|
||||
// 沿父目录上行兜底(开发机 cwd 可能是 MiGu.Server/bin/Debug/net8.0/)
|
||||
var dir = new DirectoryInfo(cwd);
|
||||
while (dir != null)
|
||||
{
|
||||
foreach (var sub in new[]
|
||||
{
|
||||
"SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
"SimpleLite/bin/Release/SimpleLite.exe",
|
||||
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
"Simple/SimpleLite/bin/Release/SimpleLite.exe",
|
||||
})
|
||||
{
|
||||
var p = Path.Combine(dir.FullName, sub.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(p)) return Path.GetFullPath(p);
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ResolveConfiguredFile(string configured)
|
||||
{
|
||||
foreach (var baseDir in EnumeratePathBases())
|
||||
{
|
||||
var p = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(configured, baseDir);
|
||||
if (File.Exists(p)) return p;
|
||||
if (Path.IsPathRooted(configured)) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ResolveConfiguredDirectory(string configured)
|
||||
{
|
||||
foreach (var baseDir in EnumeratePathBases())
|
||||
{
|
||||
var p = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(configured, baseDir);
|
||||
if (Directory.Exists(p)) return p;
|
||||
if (Path.IsPathRooted(configured)) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumeratePathBases()
|
||||
{
|
||||
var dir = new DirectoryInfo(_env.ContentRootPath);
|
||||
while (dir != null)
|
||||
{
|
||||
yield return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
private bool WaitForProjectionReady()
|
||||
{
|
||||
if (_opts.ReadinessTimeoutMs == 0) return false;
|
||||
var deadline = _opts.ReadinessTimeoutMs < 0
|
||||
? DateTime.MaxValue
|
||||
: DateTime.UtcNow.AddMilliseconds(_opts.ReadinessTimeoutMs);
|
||||
var interval = Math.Max(50, _opts.ReadinessPollIntervalMs);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
// 进程已死就别等了 —— 端口永远不会就绪。
|
||||
lock (_sync)
|
||||
{
|
||||
if (_proc is null || _proc.HasExited) return false;
|
||||
}
|
||||
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(interval);
|
||||
}
|
||||
|
||||
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConnect(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(timeout) && client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
||||
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||||
/// </summary>
|
||||
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var httpClient = new System.Net.Http.HttpClient
|
||||
{
|
||||
Timeout = timeout
|
||||
};
|
||||
using var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, $"http://{host}:{port}/projection/cars");
|
||||
using var resp = httpClient.Send(req);
|
||||
if (!resp.IsSuccessStatusCode) return false;
|
||||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult().TrimStart();
|
||||
return body.StartsWith("[", StringComparison.Ordinal) || body.StartsWith("{", StringComparison.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉起 SimpleLite 的结果摘要。
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
||||
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
||||
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
||||
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
||||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
|
||||
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
||||
string? DisplayMode = null, string? Warning = null);
|
||||
|
||||
public sealed record SimpleLiteDiagnostics(
|
||||
bool Enabled,
|
||||
bool FollowParent,
|
||||
string ConfiguredExecutablePath,
|
||||
string ConfiguredWorkingDirectory,
|
||||
string ContentRootPath,
|
||||
string? ResolvedExecutablePath,
|
||||
bool ExecutableExists,
|
||||
bool IsRunning,
|
||||
string? LastLaunchMode,
|
||||
int ProjectionPort,
|
||||
bool ProjectionPortReachable,
|
||||
bool? GotoSiteApiAvailable,
|
||||
string? DeployHint,
|
||||
string ConfigHint);
|
||||
|
||||
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public long Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct IO_COUNTERS
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
private const int JobObjectExtendedLimitInformation = 9;
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpInfo, uint cbInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
private void AttachToJobObject(Process child)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_job == IntPtr.Zero)
|
||||
{
|
||||
_job = CreateJobObject(IntPtr.Zero, null);
|
||||
if (_job == IntPtr.Zero)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
|
||||
return;
|
||||
}
|
||||
|
||||
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
||||
IntPtr ptr = Marshal.AllocHGlobal(len);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(info, ptr, false);
|
||||
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
||||
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(ptr); }
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(_job, child.Handle))
|
||||
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
|
||||
///
|
||||
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
|
||||
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe,
|
||||
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
|
||||
///
|
||||
/// 绑定 <c>appsettings.json:SimpleLite</c>。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteOptions
|
||||
{
|
||||
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
||||
/// 1) <c>./SimpleLite.exe</c>(合并发布布局:SimpleLite 与 MiGu.Server 同目录)
|
||||
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
|
||||
/// 3) <c>../SimpleLite.exe</c>(MiGu.Server 部署到子目录的常见布局)
|
||||
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出)
|
||||
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
|
||||
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
|
||||
/// </summary>
|
||||
public string ExecutablePath { get; set; } = "";
|
||||
|
||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||
public string WorkingDirectory { get; set; } = "";
|
||||
|
||||
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
||||
public string Arguments { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
||||
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
|
||||
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
||||
/// </summary>
|
||||
public int ReadinessTimeoutMs { get; set; } = 8000;
|
||||
|
||||
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
||||
public int ReadinessPollIntervalMs { get; set; } = 250;
|
||||
|
||||
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
|
||||
public int ProjectionPort { get; set; } = 8222;
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||
///
|
||||
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite;
|
||||
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false:
|
||||
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
||||
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
||||
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||
///
|
||||
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||
/// </summary>
|
||||
public bool FollowParent { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MiGu.Server</RootNamespace>
|
||||
<AssemblyName>MiGu.Server</AssemblyName>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.10" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,314 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
static string? FindSourceContentRoot(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "MiGu.Server.csproj")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
Args = args,
|
||||
// 直接运行 bin/Debug/net8.0/MiGu.Server.exe 时,默认 ContentRoot 会落到 bin 目录,
|
||||
// 导致 appsettings/data/wwwroot 与 dotnet run 不一致。源码构建输出中统一回到项目目录;
|
||||
// 发布包没有 csproj,则使用 exe 所在目录作为常规内容根。
|
||||
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS")))
|
||||
{
|
||||
// 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
||||
}
|
||||
|
||||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||
// 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」
|
||||
// 的死循环。
|
||||
//
|
||||
// 探测策略:从 ContentRootPath 出发向上逐级找前端产物:
|
||||
// 1) frontends/apps/simple-platform-vue/dist/index.html(开发态刚跑完 pnpm build);
|
||||
// 2) MiGu.Server/wwwroot/index.html(直接运行 bin/Debug/net8.0/MiGu.Server.exe 时的源码区兜底);
|
||||
// 3) 当前目录 wwwroot/index.html(dotnet run / 发布包常规布局)。
|
||||
// 找到则将 WebRootPath 改指到对应目录;找不到才保持默认 wwwroot/。
|
||||
//
|
||||
// 影响:
|
||||
// - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效;
|
||||
// 也不再需要 commit wwwroot/index.html。
|
||||
// - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在
|
||||
// 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。
|
||||
{
|
||||
static string? FindFrontendWebRoot(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir != null)
|
||||
{
|
||||
var distIndex = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html");
|
||||
if (File.Exists(distIndex)) return Path.GetDirectoryName(distIndex);
|
||||
|
||||
var sourceWwwrootIndex = Path.Combine(dir.FullName, "MiGu.Server", "wwwroot", "index.html");
|
||||
if (File.Exists(sourceWwwrootIndex)) return Path.GetDirectoryName(sourceWwwrootIndex);
|
||||
|
||||
var localWwwrootIndex = Path.Combine(dir.FullName, "wwwroot", "index.html");
|
||||
if (File.Exists(localWwwrootIndex)) return Path.GetDirectoryName(localWwwrootIndex);
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var webRoot = FindFrontendWebRoot(builder.Environment.ContentRootPath);
|
||||
if (webRoot != null)
|
||||
{
|
||||
builder.Environment.WebRootPath = webRoot;
|
||||
builder.Environment.WebRootFileProvider = new PhysicalFileProvider(webRoot);
|
||||
Console.WriteLine($"[MiGu.Server] WebRoot -> {webRoot}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[MiGu.Server] WebRoot -> default wwwroot (frontend index not found): {builder.Environment.WebRootPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// 控制器 + JSON 默认大小写、忽略 null
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opt =>
|
||||
{
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
opt.JsonSerializerOptions.WriteIndented = false;
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
||||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Description = "JWT bearer。值: \"Bearer {token}\"",
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// CORS(Vite dev 5173 与 MiGu.Server 8080 跨域)
|
||||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
||||
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||||
|
||||
// ─── AR-3 / AR-5:JWT + Cookie 双轨鉴权 ─────────────────────────────────────────
|
||||
//
|
||||
// 设计:
|
||||
// - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输;
|
||||
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||||
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||||
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
|
||||
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||||
// 共享密钥(YARP transform 自动追加)。
|
||||
builder.Services.AddSingleton<RbacStore>();
|
||||
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
|
||||
var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret;
|
||||
var issuer = config["Jwt:Issuer"] ?? "MiGu.Server";
|
||||
var audience = config["Jwt:Audience"] ?? "platform.client";
|
||||
var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60;
|
||||
return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger);
|
||||
});
|
||||
builder.Services.AddSingleton<InternalTokenStore>();
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
||||
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = false,
|
||||
ValidateLifetime = false,
|
||||
};
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
// 优先从 Authorization: Bearer 取;没有再从 Cookie 取。
|
||||
if (string.IsNullOrEmpty(ctx.Token))
|
||||
{
|
||||
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||||
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||||
}
|
||||
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
||||
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
||||
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(opts =>
|
||||
{
|
||||
// Platform scope:完整管理端权限,对应 admin 账号。
|
||||
opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform"));
|
||||
// RCSMonitor scope:运营白名单。
|
||||
opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor"));
|
||||
// 任一登录用户。
|
||||
opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser());
|
||||
// RBAC 管理:JWT 的 ops claim(空格分隔)含 "*" 或 "auth.manage" 才放行。
|
||||
// 用于 RbacController(用户 / 角色 / 权限页面管理),即「超级管理员」类账号专属。
|
||||
opts.AddPolicy("RbacAdmin", p => p.RequireAuthenticatedUser().RequireAssertion(ctx =>
|
||||
{
|
||||
var ops = ctx.User.FindFirst("ops")?.Value ?? string.Empty;
|
||||
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return set.Contains("*") || set.Contains("auth.manage");
|
||||
}));
|
||||
});
|
||||
|
||||
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(tctx =>
|
||||
{
|
||||
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||
if (tctx.Route.RouteId != "sl-route") return;
|
||||
tctx.AddRequestTransform(rt =>
|
||||
{
|
||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||||
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||||
builder.Services.AddSingleton<ConfigStore>();
|
||||
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
|
||||
builder.Services.AddSingleton<OpsAuditStore>();
|
||||
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||||
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
|
||||
_ = app.Services.GetRequiredService<RbacStore>();
|
||||
// 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
||||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||||
{
|
||||
var sl = simpleLiteLauncher.GetDiagnostics();
|
||||
app.Logger.LogInformation(
|
||||
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
|
||||
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||||
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||||
}
|
||||
|
||||
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
|
||||
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
|
||||
{
|
||||
app.Lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
||||
catch { /* shutdown best-effort */ }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
|
||||
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
|
||||
// 默认(未配置 KnownProxies):清空 Known* 表 = 信任所有前置转发头,适合「反代与本服务同机 /
|
||||
// 可信内网」部署。生产若可能被不可信网络直连,请在 appsettings 配置 ForwardedHeaders:KnownProxies
|
||||
// (可信代理 IP 列表)收紧:仅接受来自这些代理的 X-Forwarded-* 头,防止外部伪造 Proto/For。
|
||||
var forwardedOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
};
|
||||
forwardedOptions.KnownNetworks.Clear();
|
||||
forwardedOptions.KnownProxies.Clear();
|
||||
var trustedProxies = app.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>()
|
||||
?? Array.Empty<string>();
|
||||
if (trustedProxies.Length > 0)
|
||||
{
|
||||
// 转发链深度按可信代理数 +1 收紧,避免外部多塞一层伪造头被采信。
|
||||
forwardedOptions.ForwardLimit = trustedProxies.Length + 1;
|
||||
foreach (var ip in trustedProxies)
|
||||
{
|
||||
if (System.Net.IPAddress.TryParse(ip.Trim(), out var addr))
|
||||
forwardedOptions.KnownProxies.Add(addr);
|
||||
else
|
||||
app.Logger.LogWarning("ForwardedHeaders:KnownProxies 含无法解析的地址,已忽略:{Ip}", ip);
|
||||
}
|
||||
// 全部解析失败时 KnownProxies 为空 → 中间件将拒绝所有转发头(fail-closed,安全方向)。
|
||||
app.Logger.LogInformation("ForwardedHeaders 已限定 {Count} 个可信代理", forwardedOptions.KnownProxies.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
app.Logger.LogInformation("ForwardedHeaders 未配置 KnownProxies:信任所有前置转发头(适合同机 / 可信内网)");
|
||||
}
|
||||
app.UseForwardedHeaders(forwardedOptions);
|
||||
|
||||
app.UseCors();
|
||||
|
||||
// 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// YARP:/api/sl/* → :8222;/vr/* → :8223
|
||||
app.MapReverseProxy();
|
||||
|
||||
// SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status)
|
||||
// 所有非 API、非静态资源的路径都返回 wwwroot/index.html
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"MiGu.Server": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://0.0.0.0:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
# MiGu.Server
|
||||
|
||||
> 「**迷毂 · 智能调度平台**」的后端骨架(ASP.NET Core 8 + YARP)。
|
||||
> 对应 [ARCHITECTURE.md](ARCHITECTURE.md) v1.6 §1.1 中提到的「Platform 管理端」后端可执行文件。
|
||||
>
|
||||
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
||||
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
||||
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`,
|
||||
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
|
||||
>
|
||||
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
||||
|
||||
## 克隆后首次部署速查(必读)
|
||||
|
||||
本仓库为 **Migu2.0**,已包含预构建的 `wwwroot/` 前端,克隆后可直接编译运行 MiGu.Server。
|
||||
|
||||
```pwsh
|
||||
# 1) 编译 MiGu.Server
|
||||
cd MiGu.Server
|
||||
dotnet build MiGu.Server.csproj -c Debug
|
||||
|
||||
# 2) 启动(登录后按所选模式拉起 SimpleLite)
|
||||
dotnet run
|
||||
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
||||
```
|
||||
|
||||
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
||||
|
||||
## 配置 SimpleLite 路径(必读)
|
||||
|
||||
> **配置文件位置(无 Web 界面):**
|
||||
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
|
||||
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
|
||||
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
|
||||
|
||||
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
|
||||
|
||||
### 本机开发(与 Simple 仓库并列)
|
||||
|
||||
仓库默认已在 `appsettings.Development.json` 中写好:
|
||||
|
||||
```json
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
}
|
||||
```
|
||||
|
||||
请先编译 SimpleLite(在 Simple 仓库):
|
||||
|
||||
```pwsh
|
||||
cd ..\Simple
|
||||
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
||||
```
|
||||
|
||||
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
||||
|
||||
```json
|
||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 键 | 含义 |
|
||||
|----|------|
|
||||
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI) |
|
||||
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs`) |
|
||||
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
||||
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
||||
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
|
||||
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
```pwsh
|
||||
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
|
||||
$env:SimpleLite__WorkingDirectory = "D:\apps"
|
||||
$env:SimpleLite__Enabled = "true"
|
||||
```
|
||||
|
||||
### 生产 / 合并发布
|
||||
|
||||
将 `SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
|
||||
|
||||
浏览器访问 `http://localhost:8080/login`:
|
||||
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
||||
2. 选 scope(管理员 / 运营);
|
||||
3. 选 **SimpleLite 启动模式**:
|
||||
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
||||
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
|
||||
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
|
||||
|
||||
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
|
||||
|
||||
## 安全须知(生产部署必读)
|
||||
|
||||
> **重要:以下默认值仅供本地开发,切勿原样用于生产环境。**
|
||||
>
|
||||
> - `appsettings.json:Auth.Users` 内置 `admin/admin`、`ops/ops` 为弱口令演示账号;
|
||||
> - `appsettings.json:Jwt.Secret`、`Internal.Token` 为占位符 `REPLACE_ME`。
|
||||
>
|
||||
> 生产部署务必通过**环境变量**或 `appsettings.Production.json` / 密钥管理覆盖
|
||||
> (ASP.NET Core 配置优先级:环境变量 > `appsettings.{Environment}.json` > `appsettings.json`)。
|
||||
> 环境变量示例(`__` 双下划线表示配置层级):
|
||||
>
|
||||
> ```pwsh
|
||||
> $env:Auth__Users__admin__Password = "<强密码>"
|
||||
> $env:Jwt__Secret = "<不少于 32 字节的随机串>"
|
||||
> $env:Internal__Token = "<随机串>"
|
||||
> ```
|
||||
>
|
||||
> 详见代码审核 ISSUE-03(`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`)。
|
||||
>
|
||||
> **SimpleLite 8222 内部 API(RV-04)**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
|
||||
|
||||
## 能力清单(本轮)
|
||||
|
||||
- Kestrel 监听 `:8080`(HTTP);
|
||||
- 静态托管:启动时自动探测 WebRoot ——
|
||||
- 开发模式:仓库内 `frontends/apps/simple-platform-vue/dist/` 存在则直接挂为 WebRoot(`pnpm build` 后立即生效,无需 robocopy 到 wwwroot);
|
||||
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
|
||||
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
|
||||
- YARP 反向代理:
|
||||
- `/api/sl/{**catch-all}` → `http://127.0.0.1:8222/`(SimpleLite WebAPI;当前 SimpleLite 未启 WebAPI 时返回 502);
|
||||
- `/vr/{**catch-all}` → `http://127.0.0.1:8223/`(webVRender;可用于同源 iframe 解决 X-Frame-Options 限制);
|
||||
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
|
||||
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `SimpleLiteLauncher` 据此拉起子进程;
|
||||
- SimpleLite 子进程编排:`Launcher/SimpleLiteLauncher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:SimpleLite`;
|
||||
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
||||
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
||||
- 健康检查:`/api/health`;
|
||||
- Swagger:开发环境下 `/swagger`。
|
||||
|
||||
> 不在本轮范围:真实 SimpleLite WebAPI、SystemMission 拉起、真实 JWT/RBAC、SQLite/EF Core 持久层。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
MiGu.Server/
|
||||
├── MiGu.Server.csproj
|
||||
├── Program.cs # Kestrel + YARP + CORS + StaticFiles + SPA fallback
|
||||
├── appsettings.json # YARP 路由 + CORS 白名单
|
||||
├── appsettings.Development.json
|
||||
├── Properties/launchSettings.json
|
||||
├── Controllers/
|
||||
│ ├── AuthController.cs # /api/auth/login | /api/auth/logout
|
||||
│ ├── ConfigController.cs # GET/PUT /api/config[/{section}]
|
||||
│ ├── ProjectionController.cs # /api/projection/{sites,tracks,cars,missions}
|
||||
│ ├── OpsController.cs # /api/sl/ops/{execute,audits}
|
||||
│ └── HealthController.cs # /api/health
|
||||
├── Configs/ # 与 ARCHITECTURE.md §9 一一对齐的强类型 record
|
||||
│ ├── SystemConfig.cs
|
||||
│ ├── ExternalIntegrations.cs
|
||||
│ ├── RoutingPolicy.cs
|
||||
│ ├── VehicleMaintenancePolicy.cs
|
||||
│ ├── ChargePolicy.cs
|
||||
│ ├── TaskAllocationPolicy.cs
|
||||
│ ├── TrafficRule.cs
|
||||
│ ├── DeviceManagementConfig.cs
|
||||
│ ├── FleetLifecycleConfig.cs
|
||||
│ ├── ScenarioTemplateConfig.cs
|
||||
│ ├── LocationManagement.cs
|
||||
│ ├── OpsConfig.cs
|
||||
│ ├── CustomWidget.cs
|
||||
│ ├── EffectivePermissions.cs
|
||||
│ └── ConfigStore.cs # 内存 + JSON 文件持久化(占位)
|
||||
├── data/ # 运行时生成的 config-{section}.json
|
||||
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
||||
```
|
||||
|
||||
## 独立启动(仅当你不通过 SimpleLite 自启时)
|
||||
|
||||
```pwsh
|
||||
# 1) 还原 + 编译 + 运行
|
||||
cd MiGu.Server
|
||||
dotnet run
|
||||
|
||||
# 等价的 build + run 一键脚本:
|
||||
.\build-and-run.bat # Debug
|
||||
.\build-and-run.bat release # Release
|
||||
|
||||
# 2) 验证
|
||||
# 打开 http://localhost:8080/ 欢迎页 / 平台前端
|
||||
# 打开 http://localhost:8080/swagger API 文档
|
||||
# 打开 http://localhost:8080/api/health 健康检查
|
||||
# 打开 http://localhost:8080/api/config/system 系统级配置
|
||||
```
|
||||
|
||||
或在 IDE 中以 `MiGu.Server` 作为启动项目。
|
||||
|
||||
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...` 或 `WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
|
||||
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`。
|
||||
> 仓库根 `build-platform-frontend.bat` 仍可用于「打部署包」场景(把 dist robocopy /MIR 同步到 wwwroot)。
|
||||
> SimpleLite 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist;
|
||||
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
|
||||
|
||||
## 与前端联调
|
||||
|
||||
- 开发模式(前端在 Simple 仓库):
|
||||
- 前端:`cd ../Simple/frontends && pnpm dev`(`:5173`),Vite 代理 `/api` 至 `http://127.0.0.1:8080`;
|
||||
- 后端:`cd MiGu.Server && dotnet run`(`:8080`);
|
||||
- 浏览器访问 `http://localhost:5173/login`。
|
||||
- 生产/同源模式(本仓库 `wwwroot/`):
|
||||
- 默认使用 `MiGu.Server/wwwroot/`(已随仓库提交构建产物);
|
||||
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
||||
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
||||
|
||||
## YARP 与 SimpleLite
|
||||
|
||||
YARP 路由配置见 `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"sl-route": { "Match": { "Path": "/api/sl/{**catch-all}" }, "ClusterId": "sl-cluster" },
|
||||
"vrender-route": { "Match": { "Path": "/vr/{**catch-all}" }, "ClusterId": "vrender-cluster" }
|
||||
},
|
||||
"Clusters": {
|
||||
"sl-cluster": { "Destinations": { "sl1": { "Address": "http://127.0.0.1:8222/" } } },
|
||||
"vrender-cluster": { "Destinations": { "vr1": { "Address": "http://127.0.0.1:8223/" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
||||
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`,SimpleLite 未启时会得到 502/连接被拒。
|
||||
|
||||
## iframe 嵌入 webVRender 的两种方式
|
||||
|
||||
1. **直连**(默认):前端 iframe 指向 `http://localhost:8223/?scope=...&token=...&ro=...`,跨源;
|
||||
2. **同源代理**(可选):iframe 指向 `http://localhost:8080/vr/?scope=...`,由本工程 YARP 反代到 8223,
|
||||
可规避 X-Frame-Options 等限制。前端可通过 `Workspace3D` 组件的 `host` prop 传 `localhost:8080/vr` 切换。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
|
||||
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Yarp": "Debug"
|
||||
}
|
||||
},
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Yarp": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173"
|
||||
]
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "REPLACE_ME",
|
||||
"Issuer": "migu.server",
|
||||
"Audience": "migu.client",
|
||||
"LifetimeMinutes": 1440
|
||||
},
|
||||
"Internal": {
|
||||
"Token": "REPLACE_ME"
|
||||
},
|
||||
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"Arguments": "",
|
||||
"ReadinessTimeoutMs": 15000,
|
||||
"ReadinessPollIntervalMs": 250,
|
||||
"ProjectionPort": 8222,
|
||||
"FollowParent": false
|
||||
},
|
||||
"Auth": {
|
||||
"Users": {
|
||||
"admin": { "Password": "admin" },
|
||||
"ops": { "Password": "ops" }
|
||||
}
|
||||
},
|
||||
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method',Method 必须是 SimpleLite 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
|
||||
"Ops": {
|
||||
"Dispatch": {
|
||||
}
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"sl-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "AnyAuthed",
|
||||
"Match": { "Path": "/api/sl/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"vrender-route": {
|
||||
"ClusterId": "vrender-cluster",
|
||||
"Match": { "Path": "/vr/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/vr" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"Clusters": {
|
||||
"sl-cluster": {
|
||||
"Destinations": {
|
||||
"sl1": { "Address": "http://127.0.0.1:8222/" }
|
||||
}
|
||||
},
|
||||
"vrender-cluster": {
|
||||
"Destinations": {
|
||||
"vr1": { "Address": "http://127.0.0.1:8223/" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal ENABLEDELAYEDEXPANSION
|
||||
|
||||
REM ==========================================================
|
||||
REM MiGu.Server build and run (one-click)
|
||||
REM - default Debug; pass "release" / "-r" / "/r" for Release
|
||||
REM - listens on http://0.0.0.0:8080 (see Properties\launchSettings.json)
|
||||
REM - YARP proxies: /api/sl/* -> :8222 ; /vr/* -> :8223
|
||||
REM ==========================================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "CONFIG=Debug"
|
||||
if /I "%~1"=="release" set "CONFIG=Release"
|
||||
if /I "%~1"=="-r" set "CONFIG=Release"
|
||||
if /I "%~1"=="/r" set "CONFIG=Release"
|
||||
|
||||
echo ============================================================
|
||||
echo MiGu.Server 生成 + 启动 ( Config = %CONFIG% )
|
||||
echo 目录: %CD%
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
where dotnet >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 dotnet ^( .NET 8 SDK ^)。请先安装 .NET 8 SDK 并重新打开终端。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo === [1/2] dotnet build ===
|
||||
dotnet build "MiGu.Server.csproj" -c %CONFIG% --nologo
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo *** 构建失败,已停止。请检查上方编译错误。 ***
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === [2/2] dotnet run ===
|
||||
echo [提示] Ctrl+C 终止;浏览器访问 http://localhost:8080/ ^(Swagger: /swagger^)
|
||||
echo.
|
||||
|
||||
dotnet run --project "MiGu.Server.csproj" -c %CONFIG% --no-build --no-restore
|
||||
set "RUN_EC=%errorlevel%"
|
||||
|
||||
echo.
|
||||
if not "%RUN_EC%"=="0" (
|
||||
echo *** MiGu.Server 退出,errorlevel=%RUN_EC%
|
||||
) else (
|
||||
echo MiGu.Server 正常退出。
|
||||
)
|
||||
endlocal & exit /b %RUN_EC%
|
||||
Reference in New Issue
Block a user