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

将 MiGu.Server 相关忽略规则迁移至 backends/MiGu.Server,并新增对 .tmp-build* 和 /.cursor/rules 的忽略,优化敏感数据与临时文件的管理。
This commit is contained in:
ArtoriasWu
2026-06-22 08:55:55 +08:00
parent 45f0261ed7
commit 87629a8537
46 changed files with 10 additions and 3 deletions
-4
View File
@@ -1,4 +0,0 @@
bin/
obj/
*.user
data/*.json
-82
View File
@@ -1,82 +0,0 @@
using System.Security.Cryptography;
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <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 都不同)。");
}
}
}
-100
View File
@@ -1,100 +0,0 @@
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);
}
}
-99
View File
@@ -1,99 +0,0 @@
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();
}
-82
View File
@@ -1,82 +0,0 @@
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);
-551
View File
@@ -1,551 +0,0 @@
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) { }
}
-18
View File
@@ -1,18 +0,0 @@
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)
});
}
-236
View File
@@ -1,236 +0,0 @@
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 列表/空字符串规整为安全默认值,
/// 供 AuthControllerNeedsWizard/ 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");
}
-17
View File
@@ -1,17 +0,0 @@
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" })
});
}
-74
View File
@@ -1,74 +0,0 @@
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();
}
}
-65
View File
@@ -1,65 +0,0 @@
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;
}
}
@@ -1,38 +0,0 @@
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")
}));
}
@@ -1,45 +0,0 @@
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);
@@ -1,16 +0,0 @@
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) });
}
@@ -1,23 +0,0 @@
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));
}
-21
View File
@@ -1,21 +0,0 @@
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)
});
}
-128
View File
@@ -1,128 +0,0 @@
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);
}
}
}
-28
View File
@@ -1,28 +0,0 @@
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[]>()));
}
-22
View File
@@ -1,22 +0,0 @@
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) });
}
@@ -1,25 +0,0 @@
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));
}
-12
View File
@@ -1,12 +0,0 @@
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" }));
}
@@ -1,12 +0,0 @@
namespace MiGu.Server.Configs;
public record TaskAllocationPolicy(
string Mode,
bool LoadBalance,
int MaxQueuePerCar)
{
public static TaskAllocationPolicy Default() => new(
Mode: "leastLoad",
LoadBalance: true,
MaxQueuePerCar: 3);
}
-16
View File
@@ -1,16 +0,0 @@
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") });
}
@@ -1,17 +0,0 @@
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));
}
-249
View File
@@ -1,249 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
/// <summary>
/// 登录请求体。
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
/// <summary>
/// 登录响应。
/// 会话 N+1 增量字段:
/// <list type="bullet">
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
public record LoginResponse(
string Token,
AuthUserDto User,
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
string? LaunchStatus = null,
string? LaunchWarning = null,
bool NeedsWizard = false);
public record AuthUserDto(string Id, string Username, string DisplayName, List<string> Roles);
/// <summary>
/// 当前会话身份的轻量摘要。
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
/// </summary>
public record MeResponse(
AuthUserDto User,
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
bool NeedsWizard = false);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly ConfigStore _config;
private readonly ILogger<AuthController> _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
{
_rbac = rbac;
_jwt = jwt;
_launcher = launcher;
_config = config;
_log = log;
}
/// <summary>是否需要进入配置向导(部署画像尚未完成)。登录 / me / switchScope 三处一致回填。</summary>
private bool NeedsWizard() => !_config.GetDeployment().Configured;
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req)
{
if (string.IsNullOrWhiteSpace(req.Username))
return BadRequest(new { message = "用户名不能为空" });
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
var user = _rbac.VerifyCredentials(req.Username, req.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
// scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
var launchMode = NormalizeLaunchMode(req.LaunchMode);
SimpleLiteLauncher.LaunchResult? launchResult = null;
try
{
// M1waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
}
catch (Exception ex)
{
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
LaunchStatus: launchResult?.Status,
LaunchWarning: launchResult?.Warning,
NeedsWizard: NeedsWizard()));
}
[HttpPost("logout")]
[AllowAnonymous]
public IActionResult Logout()
{
Response.Cookies.Delete(CookieName);
return Ok(new { ok = true });
}
/// <summary>用本地持有的 token / Cookie 重新拉一次当前身份。失败由 [Authorize] 自动回 401。</summary>
[HttpGet("me")]
[Authorize]
public ActionResult<MeResponse> Me()
{
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
var scope = User.FindFirstValue("scope");
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope))
return Unauthorized(new { message = "身份无效" });
if (scope is not ("Platform" or "RCSMonitor"))
return Unauthorized(new { message = "无效 scope" });
var user = _rbac.FindUser(username);
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
var (perm, roleNames, _) = BuildSession(user, scope);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new MeResponse(dto, scope, InferRunMode(), perm, NeedsWizard()));
}
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
[HttpPost("switch-scope")]
[Authorize]
public ActionResult<LoginResponse> SwitchScope([FromBody] SwitchScopeRequest req)
{
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(username))
return Unauthorized(new { message = "身份无效" });
var user = _rbac.FindUser(username);
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, InferRunMode(), perm,
NeedsWizard: NeedsWizard()));
}
public record SwitchScopeRequest(string Scope);
// ───────────────────────── 内部工具 ─────────────────────────
/// <summary>
/// 计算指定 scope 下的有效权限(页面 / 操作 / 控件),并颁发携带该 scope 与 ops 的 JWT。
/// 这是登录 / me / switchScope 的公共核心,确保三条路径权限计算完全一致。
/// </summary>
private (EffectivePermissions perm, List<string> roleNames, string token) BuildSession(RbacUser user, string scope)
{
var eff = _rbac.ComputeEffective(user, scope);
// 按部署画像裁剪可见页:未启用的功能/模块对应的配置页从菜单隐藏(向导未完成则不裁剪)。
var pages = DeploymentCatalog.FilterPagesByDeployment(eff.Pages, _config.GetDeployment());
var perm = new EffectivePermissions(user.Id, 1, eff.Ops, eff.Widgets, pages);
var roleNames = _rbac.RoleNamesOf(user);
// ops claim 写入有效操作码(含可能的 "*"),供 RbacAdmin policy 判定管理权限。
var token = _jwt.Issue(user.Id, user.Username, scope, roleNames, eff.Ops);
return (perm, roleNames, token);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
private string InferRunMode()
{
var last = _launcher.LastLaunchMode;
if (string.IsNullOrEmpty(last)) return "Detached";
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
{
if (result is not { Started: true })
return "Detached";
if (result.Value.Status == "ReusingExisting")
return "WebEnabled";
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
{
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
return "WebOnly";
if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase))
return "WebEnabled";
}
return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled";
}
private static string NormalizeLaunchMode(string? raw)
{
return raw?.Trim().ToLowerInvariant() switch
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
};
}
private void SetAuthCookie(string token)
{
Response.Cookies.Append(CookieName, token, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(24)
});
}
}
@@ -1,68 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
namespace MiGu.Server.Controllers;
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
[ApiController]
[Authorize]
[Route("api/config")]
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
public ConfigController(ConfigStore store)
{
_store = store;
}
[HttpGet]
public IActionResult List()
{
var envs = _store.List().Select(e => new
{
section = e.Section,
version = e.Version,
updatedAt = e.UpdatedAt
});
return Ok(envs);
}
[HttpGet("{section}")]
public IActionResult Get(string section)
{
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
return NotFound(new { message = $"未知 section: {section}" });
var env = _store.Get(section);
return Ok(new
{
section = env.Section,
version = env.Version,
updatedAt = env.UpdatedAt,
payload = env.Payload
});
}
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
[HttpPut("{section}")]
[Authorize]
public IActionResult Put(string section, [FromBody] JsonElement payload)
{
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
return NotFound(new { message = $"未知 section: {section}" });
var env = _store.Put(section, payload);
return Ok(new
{
section = env.Section,
version = env.Version,
updatedAt = env.UpdatedAt,
payload = env.Payload
});
}
}
@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
[ApiController]
[Route("api/health")]
public class HealthController : ControllerBase
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private readonly SimpleLiteLauncher _launcher;
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
[HttpGet]
public IActionResult Get()
{
return Ok(new
{
status = "ok",
mode = "WebEnabled",
startTime = StartTime,
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
ports = new
{
webApi = 7001,
webSocket = 7002,
platform = 8080,
vrender = 8223,
vehicle = 8222
},
architecture = "v1.5"
});
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// </summary>
[HttpGet("simplelite")]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[Authorize]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
return Ok(new { restart = result, diagnostics = diag });
}
}
-836
View File
@@ -1,836 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// <c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
/// 其中 tag 为 <c>/</c> 表示无标签(滚动记录)。不匹配该模式的行视为上一条的续行(多行内容)。
///
/// 鉴权:class 级 <c>[Authorize(Policy = "PlatformScope")]</c> —— 日志为内核落盘文件(可能含文件路径 /
/// 内部运行状态等敏感信息),仅平台后台用户(scope=Platform)可读 / 下载,排除 RCSMonitor 监控大屏 token。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/logs")]
public sealed class LogsController : ControllerBase
{
/// <summary>文件列表默认上限(与 LogViewer.cs 的 MaxFilesShown=500 对齐)。</summary>
private const int DefaultFileLimit = 500;
/// <summary>单次解析的条目硬上限,防超大日志(实测单文件可达 46MB)撑爆内存。</summary>
private const int MaxEntries = 200_000;
/// <summary>单次扫描字节上限(超过则截断并标记 truncated)。</summary>
private const long MaxScanBytes = 96L * 1024 * 1024;
/// <summary>合订/某天聚合时最多遍历的文件数,避免一次扫描整月日志。</summary>
private const int MaxDigestFiles = 64;
/// <summary>跨文件聚合(analyze/digest 的 day 模式)的总条目上限,防某天多个大文件 entries 累加撑爆内存。</summary>
private const int MaxAggregateEntries = 300_000;
/// <summary>Diagnosis 落盘行:<c>[head] &gt;tag: content</c>head 内含可选 prefix + 时间戳。</summary>
private static readonly Regex LineRegex =
new(@"^\[(?<head>[^\]]*)\]\s*>(?<tag>.*?):\s?(?<content>.*)$", RegexOptions.Compiled);
/// <summary>从 head 里抠出时间戳(prefix = 时间戳之前的部分)。</summary>
private static readonly Regex TimeRegex =
new(@"\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}", RegexOptions.Compiled);
private const string TimeFormat = "yyyy/MM/dd-HH:mm:ss.fff";
/// <summary>
/// 内容里的「数值字段」:<c>key=value</c> 或 <c>key: value</c>value 为数字(可带小数/负号)。
/// key 必须以字母/下划线/中文开头,避免把 <c>12:30</c> 这类时间误判为字段。供「日志分析器」识别可绘图字段。
/// </summary>
private static readonly Regex NumericFieldRegex =
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
private readonly SimpleLiteLauncher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
{
_launcher = launcher;
_config = config;
_log = log;
}
// ─────────────────────────────────────────────────────────── 概览 ──
/// <summary>日志根概览:工作目录、根路径、是否存在、文件/字节总量、按天分组统计。</summary>
[HttpGet("overview")]
public IActionResult Overview()
{
var (root, workdir, error) = ResolveLogRoot();
if (root == null)
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
if (!Directory.Exists(root))
return Ok(new
{
exists = false, root, workingDirectory = workdir,
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
});
var files = EnumerateLogFiles(root);
var days = files
.GroupBy(f => f.Day)
.Select(g => new { day = g.Key, files = g.Count(), bytes = g.Sum(x => x.Bytes) })
.OrderByDescending(d => d.day, StringComparer.Ordinal)
.ToList();
return Ok(new
{
exists = true, root, workingDirectory = workdir,
totalFiles = files.Count,
totalBytes = files.Sum(f => f.Bytes),
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
days
});
}
// ────────────────────────────────────────────────────── 文件列表 ──
/// <summary>列出落盘日志文件(递归 log/),可按天 / 文件名关键字过滤,按修改时间降序。</summary>
[HttpGet("files")]
public IActionResult Files([FromQuery] string? day, [FromQuery] string? keyword, [FromQuery] int limit = DefaultFileLimit)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
if (!Directory.Exists(root)) return Ok(new { root, total = 0, returned = 0, files = Array.Empty<object>() });
var clamp = Math.Clamp(limit, 1, 5000);
IEnumerable<FileMeta> q = EnumerateLogFiles(root);
if (!string.IsNullOrWhiteSpace(day))
q = q.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(keyword))
q = q.Where(f => f.Rel.Contains(keyword, StringComparison.OrdinalIgnoreCase));
var all = q.OrderByDescending(f => f.Mtime).ToList();
var page = all.Take(clamp).Select(f => new
{
rel = f.Rel, name = f.Name, day = f.Day, dir = f.Dir, bytes = f.Bytes, mtime = f.Mtime
}).ToList();
return Ok(new { root, total = all.Count, returned = page.Count, files = page });
}
// ──────────────────────────────────────────────────── 目录浏览 ──
/// <summary>
/// 文件夹 / 文件浏览器:列出 <c>log/</c> 下指定相对目录的<b>直接</b>子项(子文件夹 + 文件),可逐层进入。
/// <c>path</c> 为空 = 日志根。对齐用户诉求「直接显示 log 下所有文件夹和文件,可进入文件夹、打开某个日志文件」。
/// 子文件夹附带其下一层的子目录/文件计数,文件附带大小、修改时间与是否 .log。
/// </summary>
[HttpGet("browse")]
public IActionResult Browse([FromQuery] string? path)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var normRoot = Path.GetFullPath(root);
if (!Directory.Exists(normRoot))
return Ok(new
{
root = normRoot, exists = false, path = "", parent = (string?)null,
dirCount = 0, fileCount = 0,
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
});
var target = SafeResolveDir(normRoot, path);
if (target == null) return BadRequest(new { message = "非法的目录路径" });
if (!Directory.Exists(target)) return NotFound(new { message = "目录不存在" });
var rel = NormalizeRel(Path.GetRelativePath(normRoot, target));
var parent = string.IsNullOrEmpty(rel) ? (string?)null : NormalizeRel(Path.GetDirectoryName(rel) ?? "");
var dirs = new List<object>();
var files = new List<object>();
try
{
var di = new DirectoryInfo(target);
foreach (var sub in di.GetDirectories().OrderByDescending(x => x.LastWriteTime))
{
int childDirs = 0, childFiles = 0;
try { childDirs = sub.GetDirectories().Length; } catch { /* 无权限/并发删除:计数视为 0 */ }
try { childFiles = sub.GetFiles().Length; } catch { /* 同上 */ }
dirs.Add(new
{
name = sub.Name,
rel = NormalizeRel(Path.GetRelativePath(normRoot, sub.FullName)),
mtime = sub.LastWriteTime,
dirCount = childDirs,
fileCount = childFiles
});
}
foreach (var f in di.GetFiles().OrderByDescending(x => x.LastWriteTime))
{
files.Add(new
{
name = f.Name,
rel = NormalizeRel(Path.GetRelativePath(normRoot, f.FullName)),
bytes = f.Length,
mtime = f.LastWriteTime,
isLog = f.Extension.Equals(".log", StringComparison.OrdinalIgnoreCase)
});
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "浏览日志目录失败 path={Path}", path);
return Problem($"读取目录失败:{ex.Message}", statusCode: 500);
}
return Ok(new
{
root = normRoot, exists = true, path = rel, parent,
dirCount = dirs.Count, fileCount = files.Count, dirs, files
});
}
// ──────────────────────────────────────────────────── 条目(分页)──
/// <summary>
/// 解析单个日志文件为结构化条目(时间 / 标签 / 内容),支持关键字 / 标签 / 仅带标签过滤、
/// 升降序与分页。超大文件按 <see cref="MaxScanBytes"/> / <see cref="MaxEntries"/> 截断并回 truncated。
/// </summary>
[HttpGet("entries")]
public IActionResult Entries(
[FromQuery] string file,
[FromQuery] string? keyword,
[FromQuery] string? tag,
[FromQuery] bool onlyTagged = false,
[FromQuery] string order = "desc",
[FromQuery] int limit = 300,
[FromQuery] int offset = 0)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var full = SafeResolve(root, file);
if (full == null) return BadRequest(new { message = "非法的文件路径" });
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
var parsed = ParseFile(full);
IEnumerable<LogEntry> q = parsed.Entries;
if (onlyTagged) q = q.Where(e => !string.IsNullOrEmpty(e.Tag));
if (!string.IsNullOrWhiteSpace(tag))
q = q.Where(e => e.Tag.Contains(tag, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(keyword))
q = q.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|| e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase));
var filtered = q.ToList();
if (!string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
filtered.Reverse();
var clampLimit = Math.Clamp(limit, 1, 2000);
var clampOffset = Math.Max(0, offset);
var page = filtered.Skip(clampOffset).Take(clampLimit).Select(Project).ToList();
return Ok(new
{
file, bytes = parsed.Bytes, scannedLines = parsed.ScannedLines,
truncated = parsed.Truncated, total = filtered.Count,
offset = clampOffset, limit = clampLimit, order = order.ToLowerInvariant(),
entries = page
});
}
// ─────────────────────────────────────────────────────── 合订本 ──
/// <summary>
/// 「合订本」:把带标签的 Post/Toast 按标签聚合成一册(条数 + 时间范围 + 最新内容 + 最近若干条),
/// 无标签的归入「滚动记录」。对齐用户诉求「post 和 toast 如果有标签需要是合订本的形式」。
/// 范围二选一:<c>file</c>(单文件)或 <c>day</c>(某天全部文件,最多 <see cref="MaxDigestFiles"/> 个)。
/// </summary>
[HttpGet("digest")]
public IActionResult Digest(
[FromQuery] string? file,
[FromQuery] string? day,
[FromQuery] string? keyword,
[FromQuery] int maxPerTag = 100)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var targets = new List<string>();
string sourceLabel;
if (!string.IsNullOrWhiteSpace(file))
{
var full = SafeResolve(root, file);
if (full == null) return BadRequest(new { message = "非法的文件路径" });
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
targets.Add(full);
sourceLabel = file;
}
else if (!string.IsNullOrWhiteSpace(day))
{
if (!Directory.Exists(root)) return Ok(EmptyDigest("day", day));
targets = EnumerateLogFiles(root)
.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f.Mtime)
.Take(MaxDigestFiles)
.Select(f => f.Full)
.ToList();
sourceLabel = day;
}
else
{
return BadRequest(new { message = "请提供 file 或 day 之一作为合订范围" });
}
var clampPerTag = Math.Clamp(maxPerTag, 1, 1000);
var books = new Dictionary<string, Book>(StringComparer.Ordinal);
var untagged = new Book { Tag = "" };
bool truncated = false;
long scanned = 0;
foreach (var f in targets)
{
var parsed = ParseFile(f);
truncated |= parsed.Truncated;
scanned += parsed.Bytes;
foreach (var e in parsed.Entries)
{
if (!string.IsNullOrWhiteSpace(keyword)
&& !e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
&& !e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrEmpty(e.Tag))
{
Accumulate(untagged, e, clampPerTag);
}
else
{
if (!books.TryGetValue(e.Tag, out var b)) { b = new Book { Tag = e.Tag }; books[e.Tag] = b; }
Accumulate(b, e, clampPerTag);
}
}
// 跨文件总字节熔断:合订各 Book 已有 maxPerTag 上限,这里再防 64 个大文件把 CPU 拉满。
if (scanned >= MaxScanBytes) { truncated = true; break; }
}
var bookList = books.Values
.OrderByDescending(b => b.LastTime ?? DateTime.MinValue)
.Select(b => ToBookDto(b))
.ToList();
return Ok(new
{
source = string.IsNullOrWhiteSpace(file) ? "day" : "file",
target = sourceLabel,
files = targets.Count,
truncated,
tagCount = bookList.Count,
untaggedCount = untagged.Count,
books = bookList,
untagged = ToBookDto(untagged)
});
}
// ─────────────────────────────────────────────────────── 日志分析 ──
/// <summary>
/// 「日志分析器」:解析单文件(<c>file</c>)或某天(<c>day</c>)日志,产出图表所需的聚合数据:
/// <list type="number">
/// <item><b>标签分布</b> tags:各标签条数 + 占比(含「滚动记录」即无标签);</item>
/// <item><b>日志量直方图</b> volume:按 <c>granularity</c>(second/minute/hour) 分桶的总量 + Top6 标签拆分(稀疏桶,仅含有数据的时刻);</item>
/// <item><b>数值字段识别</b> fields:从内容里抽取 <c>key=value</c>/<c>key:value</c> 的数值字段,给出样本数/最小/最大/均值/最后值;</item>
/// <item><b>字段时序</b> series:当指定 <c>field</c> 时,返回该字段的 (时间, 值) 点序列(超量自动抽稀)。</item>
/// </list>
/// <c>keyword</c> 过滤全部统计;<c>tag</c> 仅聚焦「字段识别 + 字段时序」(不影响标签分布/直方图,便于先看全貌再下钻)。
/// </summary>
[HttpGet("analyze")]
public IActionResult Analyze(
[FromQuery] string? file,
[FromQuery] string? day,
[FromQuery] string granularity = "minute",
[FromQuery] string? tag = null,
[FromQuery] string? keyword = null,
[FromQuery] string? field = null)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var gran = (granularity ?? "minute").ToLowerInvariant() switch
{
"second" or "sec" or "s" => Gran.Second,
"hour" or "h" => Gran.Hour,
_ => Gran.Minute
};
var granName = gran.ToString().ToLowerInvariant();
var targets = new List<string>();
string label;
string source;
if (!string.IsNullOrWhiteSpace(file))
{
var full = SafeResolve(root, file);
if (full == null) return BadRequest(new { message = "非法的文件路径" });
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
targets.Add(full);
label = file; source = "file";
}
else if (!string.IsNullOrWhiteSpace(day))
{
if (!Directory.Exists(root)) return Ok(EmptyAnalysis("day", day, granName));
targets = EnumerateLogFiles(root)
.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f.Mtime).Take(MaxDigestFiles).Select(f => f.Full).ToList();
label = day; source = "day";
}
else
{
return BadRequest(new { message = "请提供 file 或 day 之一作为分析范围" });
}
var all = new List<LogEntry>();
bool truncated = false;
long aggBytes = 0;
foreach (var f in targets)
{
var parsed = ParseFile(f);
truncated |= parsed.Truncated;
aggBytes += parsed.Bytes;
// 跨文件聚合熔断:总条数 / 总字节达上限即停止纳入并标记 truncated(图表基于已采样数据),
// 避免某天多个大文件把全部 entries 堆进内存。
var room = MaxAggregateEntries - all.Count;
if (room <= 0) { truncated = true; break; }
if (parsed.Entries.Count > room)
{
all.AddRange(parsed.Entries.GetRange(0, room));
truncated = true;
break;
}
all.AddRange(parsed.Entries);
if (aggBytes >= MaxScanBytes) { truncated = true; break; }
}
if (!string.IsNullOrWhiteSpace(keyword))
all = all.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|| e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
var total = all.Count;
// ── 1) 标签分布(全量)──
var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var e in all)
{
var key = e.Tag ?? "";
tagCounts.TryGetValue(key, out var c);
tagCounts[key] = c + 1;
}
var tags = tagCounts.OrderByDescending(kv => kv.Value)
.Select(kv => new
{
tag = kv.Key,
count = kv.Value,
percent = total > 0 ? Math.Round(kv.Value * 100.0 / total, 2) : 0
}).ToList();
// ── 2) 时间范围 + 日志量直方图(稀疏桶 + Top6 标签拆分)──
var timed = all.Where(e => e.Time != null).Select(e => e.Time!.Value).ToList();
DateTime? start = timed.Count > 0 ? timed.Min() : (DateTime?)null;
DateTime? end = timed.Count > 0 ? timed.Max() : (DateTime?)null;
var topTagNames = tags.Where(t => t.tag != "").Take(6).Select(t => t.tag).ToList();
var topSet = new HashSet<string>(topTagNames, StringComparer.Ordinal);
var totalBuckets = new SortedDictionary<DateTime, int>();
var tagBuckets = topTagNames.ToDictionary(t => t, _ => new Dictionary<DateTime, int>(), StringComparer.Ordinal);
foreach (var e in all)
{
if (e.Time == null) continue;
var b = TruncateTime(e.Time.Value, gran);
totalBuckets.TryGetValue(b, out var c);
totalBuckets[b] = c + 1;
var tg = e.Tag ?? "";
if (topSet.Contains(tg))
{
var d = tagBuckets[tg];
d.TryGetValue(b, out var c2);
d[b] = c2 + 1;
}
}
var bucketTimes = totalBuckets.Keys.ToList();
var volume = new
{
granularity = granName,
buckets = bucketTimes,
total = bucketTimes.Select(t => totalBuckets[t]).ToList(),
topTags = topTagNames.Select(tg => new
{
tag = tg,
counts = bucketTimes.Select(t => tagBuckets[tg].TryGetValue(t, out var v) ? v : 0).ToList()
}).ToList()
};
// ── 3) 数值字段识别(受 tag 聚焦影响)──
IEnumerable<LogEntry> scope = all;
if (!string.IsNullOrWhiteSpace(tag))
scope = all.Where(e => string.Equals(e.Tag, tag, StringComparison.Ordinal)).ToList();
var fieldAgg = new Dictionary<string, FieldStat>(StringComparer.Ordinal);
foreach (var e in scope)
{
foreach (Match m in NumericFieldRegex.Matches(e.Content))
{
if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
var k = m.Groups["k"].Value;
if (!fieldAgg.TryGetValue(k, out var fs)) { fs = new FieldStat(); fieldAgg[k] = fs; }
fs.N++; fs.Sum += v; fs.Last = v;
if (v < fs.Min) fs.Min = v;
if (v > fs.Max) fs.Max = v;
}
}
var fields = fieldAgg.OrderByDescending(kv => kv.Value.N).Take(40).Select(kv => new
{
name = kv.Key,
samples = kv.Value.N,
min = kv.Value.Min,
max = kv.Value.Max,
avg = kv.Value.N > 0 ? Math.Round(kv.Value.Sum / kv.Value.N, 4) : 0,
last = kv.Value.Last
}).ToList();
// ── 4) 选定字段时序(超量抽稀,保留分布形态)──
object? series = null;
if (!string.IsNullOrWhiteSpace(field))
{
var pts = new List<(DateTime t, double v)>();
foreach (var e in scope)
{
if (e.Time == null) continue;
foreach (Match m in NumericFieldRegex.Matches(e.Content))
{
if (!string.Equals(m.Groups["k"].Value, field, StringComparison.Ordinal)) continue;
if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
pts.Add((e.Time.Value, v));
}
}
const int cap = 8000;
if (pts.Count > cap)
{
var stride = (int)Math.Ceiling(pts.Count / (double)cap);
pts = pts.Where((_, i) => i % stride == 0).ToList();
}
series = new
{
field,
tag = tag ?? "",
count = pts.Count,
points = pts.Select(p => new { t = p.t, v = p.v }).ToList()
};
}
return Ok(new
{
source, target = label, files = targets.Count, truncated,
total,
timeRange = new { start, end },
granularity = granName,
tags, volume, fields, series
});
}
// ──────────────────────────────────────────────── 原文 / 下载 ──
/// <summary>返回日志文件尾部 N 行原文(默认 2000 行),用于「查看原始日志」视图。</summary>
[HttpGet("raw")]
public IActionResult Raw([FromQuery] string file, [FromQuery] int tail = 2000)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var full = SafeResolve(root, file);
if (full == null) return BadRequest(new { message = "非法的文件路径" });
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
var clamp = Math.Clamp(tail, 1, 50000);
try
{
var lines = System.IO.File.ReadLines(full).TakeLast(clamp).ToList();
var text = string.Join("\n", lines);
return Content(text, "text/plain; charset=utf-8");
}
catch (Exception ex)
{
_log.LogWarning(ex, "读取日志原文失败 file={File}", file);
return Problem($"读取失败:{ex.Message}", statusCode: 500);
}
}
/// <summary>下载原始日志文件。</summary>
[HttpGet("download")]
public IActionResult Download([FromQuery] string file)
{
var (root, _, error) = ResolveLogRoot();
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
var full = SafeResolve(root, file);
if (full == null) return BadRequest(new { message = "非法的文件路径" });
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
var downloadName = Path.GetFileName(full);
return PhysicalFile(full, "application/octet-stream", downloadName);
}
// ─────────────────────────────────────────────────────── helpers ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
if (!string.IsNullOrWhiteSpace(overrideRoot))
{
var r = Path.GetFullPath(overrideRoot);
return (r, Path.GetDirectoryName(r), null);
}
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null,
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory" +
"或显式设置 Logs:Root 指向日志根目录。");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
}
/// <summary>把相对路径安全解析到日志根内(防 ../ 目录穿越),并要求落在 root 子级。</summary>
private static string? SafeResolve(string root, string? rel)
{
if (string.IsNullOrWhiteSpace(rel)) return null;
var normRoot = Path.GetFullPath(root);
var full = Path.GetFullPath(Path.Combine(normRoot, rel));
if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return null;
var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
}
/// <summary>把相对目录安全解析到日志根内(允许根自身;防 ../ 目录穿越)。用于目录浏览。</summary>
private static string? SafeResolveDir(string root, string? rel)
{
var normRoot = Path.GetFullPath(root);
if (string.IsNullOrWhiteSpace(rel) || rel == "/" || rel == ".") return normRoot;
var full = Path.GetFullPath(Path.Combine(normRoot, rel));
if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return normRoot;
var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
}
/// <summary>相对路径标准化:反斜杠转正斜杠、去尾斜杠、根目录归一为空串。</summary>
private static string NormalizeRel(string rel)
{
if (string.IsNullOrEmpty(rel) || rel == ".") return "";
return rel.Replace('\\', '/').TrimEnd('/');
}
private static List<FileMeta> EnumerateLogFiles(string root)
{
var list = new List<FileMeta>();
IEnumerable<string> files;
try { files = Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories); }
catch { return list; }
foreach (var f in files)
{
try
{
var fi = new FileInfo(f);
var rel = Path.GetRelativePath(root, f).Replace('\\', '/');
var slash = rel.IndexOf('/');
var day = slash > 0 ? rel[..slash] : "(根目录)";
var dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
list.Add(new FileMeta(rel, fi.Name, day, dir, fi.Length, fi.LastWriteTime, f));
}
catch { /* 个别文件读元数据失败跳过 */ }
}
return list;
}
/// <summary>流式解析日志文件为条目;非标准行作为上一条的续行(多行内容)。</summary>
private LogParseResult ParseFile(string full)
{
var entries = new List<LogEntry>();
long bytes = 0;
int lineNo = 0;
long scanned = 0;
bool truncated = false;
try { bytes = new FileInfo(full).Length; } catch { /* ignore */ }
try
{
foreach (var raw in System.IO.File.ReadLines(full))
{
lineNo++;
scanned += raw.Length + 2; // 估算含换行
var line = raw.TrimEnd('\r');
var m = LineRegex.Match(line);
if (m.Success)
{
var head = m.Groups["head"].Value;
var tm = TimeRegex.Match(head);
if (tm.Success
&& DateTime.TryParseExact(tm.Value, TimeFormat, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var dt))
{
var tag = m.Groups["tag"].Value;
if (tag == "/") tag = "";
entries.Add(new LogEntry
{
LineNo = lineNo,
Time = dt,
Prefix = head[..tm.Index],
Tag = tag,
Content = m.Groups["content"].Value
});
if (entries.Count >= MaxEntries) { truncated = true; break; }
}
else
{
AppendContinuation(entries, line);
}
}
else
{
AppendContinuation(entries, line);
}
if (scanned >= MaxScanBytes) { truncated = true; break; }
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "解析日志文件失败 file={File}", full);
}
return new LogParseResult(entries, bytes, lineNo, truncated);
}
private static void AppendContinuation(List<LogEntry> entries, string line)
{
if (entries.Count == 0)
{
// 文件开头就是无时间戳行:作为一条无标签、无时间的内容保留。
entries.Add(new LogEntry { LineNo = 1, Time = null, Tag = "", Content = line });
return;
}
var last = entries[^1];
last.Content = string.IsNullOrEmpty(last.Content) ? line : last.Content + "\n" + line;
}
private static void Accumulate(Book b, LogEntry e, int maxPerTag)
{
b.Count++;
if (e.Time != null)
{
if (b.FirstTime == null || e.Time < b.FirstTime) b.FirstTime = e.Time;
if (b.LastTime == null || e.Time > b.LastTime) b.LastTime = e.Time;
}
b.Latest = e.Content;
b.LatestTime = e.Time;
b.Recent.Add(e);
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆。
if (b.Recent.Count > maxPerTag) b.Recent.RemoveAt(0);
}
private static object ToBookDto(Book b) => new
{
tag = b.Tag,
count = b.Count,
firstTime = b.FirstTime,
lastTime = b.LastTime,
latest = b.Latest,
latestTime = b.LatestTime,
entries = b.Recent.Select(Project).ToList()
};
private static object EmptyDigest(string source, string target) => new
{
source, target, files = 0, truncated = false,
tagCount = 0, untaggedCount = 0,
books = Array.Empty<object>(),
untagged = new { tag = "", count = 0, entries = Array.Empty<object>() }
};
/// <summary>把时间戳截断到指定粒度的桶起点(用于直方图分桶)。</summary>
private static DateTime TruncateTime(DateTime t, Gran g) => g switch
{
Gran.Second => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, t.Kind),
Gran.Hour => new DateTime(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Kind),
_ => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, 0, t.Kind)
};
private static object EmptyAnalysis(string source, string target, string granName) => new
{
source, target, files = 0, truncated = false, total = 0,
timeRange = new { start = (DateTime?)null, end = (DateTime?)null },
granularity = granName,
tags = Array.Empty<object>(),
volume = new { granularity = granName, buckets = Array.Empty<DateTime>(), total = Array.Empty<int>(), topTags = Array.Empty<object>() },
fields = Array.Empty<object>(),
series = (object?)null
};
private static object Project(LogEntry e) => new
{
lineNo = e.LineNo, time = e.Time, prefix = e.Prefix, tag = e.Tag, content = e.Content
};
// ─────────────────────────────────────────────────────── 内部类型 ──
private enum Gran { Second, Minute, Hour }
/// <summary>「日志分析器」数值字段的累计统计。</summary>
private sealed class FieldStat
{
public long N;
public double Sum;
public double Min = double.MaxValue;
public double Max = double.MinValue;
public double Last;
}
private readonly record struct FileMeta(
string Rel, string Name, string Day, string Dir, long Bytes, DateTime Mtime, string Full);
private sealed record LogParseResult(List<LogEntry> Entries, long Bytes, int ScannedLines, bool Truncated);
private sealed class LogEntry
{
public int LineNo { get; set; }
public DateTime? Time { get; set; }
public string Prefix { get; set; } = "";
public string Tag { get; set; } = "";
public string Content { get; set; } = "";
}
private sealed class Book
{
public string Tag { get; set; } = "";
public int Count { get; set; }
public DateTime? FirstTime { get; set; }
public DateTime? LastTime { get; set; }
public string Latest { get; set; } = "";
public DateTime? LatestTime { get; set; }
public List<LogEntry> Recent { get; } = new();
}
}
@@ -1,108 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
/// </summary>
[ApiController]
[Authorize]
[Route("api/maps")]
public class MapsContentController : ControllerBase
{
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly ILogger<MapsContentController> _log;
public MapsContentController(
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
ILogger<MapsContentController> log)
{
_httpFactory = httpFactory;
_internalToken = internalToken;
_sl = sl.Value;
_log = log;
}
[HttpGet("{name}/content")]
public async Task<IActionResult> GetContent(string name, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(name)
|| name.Contains("..", StringComparison.Ordinal)
|| name.IndexOfAny(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) >= 0)
{
return BadRequest(new { message = "地图名称非法" });
}
try
{
var directory = await FetchMapsDirectoryAsync(ct);
if (string.IsNullOrWhiteSpace(directory))
return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" });
var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
var root = Path.GetFullPath(directory);
if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { message = "路径校验失败" });
if (!System.IO.File.Exists(fullPath))
return NotFound(new { message = $"地图文件不存在:{fileName}" });
var content = await System.IO.File.ReadAllTextAsync(fullPath, ct);
return Ok(new
{
name,
fileName,
path = fullPath,
content
});
}
catch (Exception ex)
{
_log.LogWarning(ex, "读取地图 JSON 失败 name={Name}", name);
return StatusCode(500, new { message = $"读取地图 JSON 失败:{ex.Message}" });
}
}
private async Task<string?> FetchMapsDirectoryAsync(CancellationToken ct)
{
var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/map-edit/maps";
using var msg = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
using var resp = await client.SendAsync(msg, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"SimpleLite maps 列表返回 {(int)resp.StatusCode}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.TryGetProperty("success", out var ok) && ok.ValueKind == JsonValueKind.False)
{
var message = root.TryGetProperty("message", out var m) ? m.GetString() : "maps 列表失败";
throw new InvalidOperationException(message ?? "maps 列表失败");
}
JsonElement data = root;
if (root.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object)
data = d;
if (data.TryGetProperty("directory", out var dir) && dir.ValueKind == JsonValueKind.String)
return dir.GetString();
return null;
}
}
-194
View File
@@ -1,194 +0,0 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 运维白名单网关。运营端(RCSMonitor)通过本控制器执行受控运维动作。
///
/// AR-4[Authorize] 要求登录,再按 op 白名单 + JWT ops claim 双校验。
///
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发。
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
public OpsController(
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IConfiguration config,
ILogger<OpsController> log)
{
_audits = audits;
_httpFactory = httpFactory;
_internalToken = internalToken;
_sl = sl.Value;
_log = log;
_dispatch = LoadDispatch(config);
}
/// <summary>从 appsettings <c>Ops:Dispatch</c> 读取 opCode → "kind:Method" 映射(忽略空值与 _ 注释键)。</summary>
private static IReadOnlyDictionary<string, (string, string)> LoadDispatch(IConfiguration config)
{
var map = new Dictionary<string, (string, string)>(StringComparer.Ordinal);
foreach (var kv in config.GetSection("Ops:Dispatch").GetChildren())
{
var op = kv.Key;
var spec = kv.Value;
if (op.StartsWith('_') || string.IsNullOrWhiteSpace(spec)) continue;
var parts = spec.Split(':', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && parts[0].Length > 0 && parts[1].Length > 0)
map[op] = (parts[0], parts[1]);
}
return map;
}
[HttpPost("execute")]
public async Task<ActionResult<ExecuteResponse>> Execute([FromBody] ExecuteRequest req)
{
if (req is null || string.IsNullOrWhiteSpace(req.OpCode))
return BadRequest(new { message = "opCode 不能为空" });
if (!Whitelist.Contains(req.OpCode))
return BadRequest(new { message = $"非白名单 op{req.OpCode}" });
// AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。
var opsClaim = User.FindFirst("ops")?.Value ?? "";
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
if (!string.IsNullOrWhiteSpace(req.IdempotencyKey))
{
var dup = _audits.FindSuccessByIdempotencyKey(req.IdempotencyKey);
if (dup is not null)
return Ok(new ExecuteResponse(true, dup.Id, dup.Message ?? "幂等命中:已执行过相同请求,未重复下发"));
}
// monitor.note.write:运营备注,非内核动作,仅审计。
if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
var numericId = ExtractNumericId(req.TargetId);
if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
string result;
string? message;
try
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
result = success ? "ok" : "failed";
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}{ExtractMessage(body)}";
}
catch (Exception ex)
{
result = "failed";
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
}
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
}
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
{
var entry = _audits.Append(user, scope, req.OpCode, req.TargetId, result, message ?? req.Reason, req.IdempotencyKey);
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
/// </summary>
private static int? ExtractNumericId(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var m = System.Text.RegularExpressions.Regex.Match(raw, @"\d+");
return m.Success && int.TryParse(m.Value, out var n) ? n : null;
}
private static bool ParseSuccess(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
}
catch { return false; }
}
private static string ExtractMessage(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
return m.GetString() ?? "";
}
catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…";
}
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MiGu.Server.Controllers;
/// <summary>
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
///
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
/// </summary>
[ApiController]
[Authorize]
[Route("api/projection")]
public class ProjectionController : ControllerBase
{
[HttpGet("sites")]
public IActionResult Sites() => Ok(new[]
{
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
});
[HttpGet("tracks")]
public IActionResult Tracks() => Ok(new[]
{
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
});
[HttpGet("cars")]
public IActionResult Cars() => Ok(new[]
{
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
});
[HttpGet("missions")]
public IActionResult Missions() => Ok(new[]
{
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
});
}
-137
View File
@@ -1,137 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
namespace MiGu.Server.Controllers;
/// <summary>
/// RBAC 管理端:用户 / 角色 / 权限页面分配。整个控制器要求 <c>RbacAdmin</c> 策略
/// JWT 的 ops claim 含 <c>*</c> 或 <c>auth.manage</c>),即只有「超级管理员」类账号可访问。
///
/// 对应前端「平台配置中心 → 权限与角色」页(/admin/config/auth)。
/// </summary>
[ApiController]
[Authorize(Policy = "RbacAdmin")]
[Route("api/rbac")]
public class RbacController : ControllerBase
{
public sealed record OpDef(string Code, string Label);
public sealed record WidgetDef(string Id, string Label);
/// <summary>可分配的操作码候选(管理端配置角色时下拉/勾选用)。</summary>
private static readonly OpDef[] KnownOps =
{
new("*", "全部操作(通配)"),
new("ops.car.pause", "车辆 · 暂停"),
new("ops.car.resume", "车辆 · 恢复"),
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
/// <summary>可配置可见性的控件候选。</summary>
private static readonly WidgetDef[] KnownWidgets =
{
new("MapEditor", "地图编辑器"),
new("CadToolbar", "CAD 工具栏"),
new("CarPanel", "车辆面板"),
new("MissionEditor", "任务编辑器"),
new("OpsActionPanel", "运维操作面板"),
new("ConfigCenter", "配置中心"),
};
private readonly RbacStore _store;
private readonly ILogger<RbacController> _log;
public RbacController(RbacStore store, ILogger<RbacController> log)
{
_store = store;
_log = log;
}
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog() => Ok(new
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
{
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
}
});
// ───────────────────────── 角色 ─────────────────────────
[HttpGet("roles")]
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
{
_store.DeleteRole(id);
return Ok(new { ok = true });
});
// ───────────────────────── 用户 ─────────────────────────
[HttpGet("users")]
public IActionResult ListUsers() => Ok(_store.ListUsers());
[HttpPost("users")]
public IActionResult CreateUser([FromBody] CreateUserRequest req) => Guard(() => Ok(_store.CreateUser(req)));
[HttpPut("users/{id}")]
public IActionResult UpdateUser(string id, [FromBody] UpdateUserRequest req) => Guard(() =>
{
// 自我保护:禁止把当前登录账号自己停用,避免管理员把自己锁在门外。
if (id == CurrentUserId() && req.Enabled == false)
return (IActionResult)BadRequest(new { message = "不能停用当前登录的账号" });
return Ok(_store.UpdateUser(id, req));
});
[HttpPut("users/{id}/password")]
public IActionResult SetPassword(string id, [FromBody] SetPasswordRequest req) => Guard(() =>
{
_store.SetPassword(id, req.Password);
return Ok(new { ok = true });
});
[HttpDelete("users/{id}")]
public IActionResult DeleteUser(string id) => Guard(() =>
{
if (id == CurrentUserId())
return (IActionResult)BadRequest(new { message = "不能删除当前登录的账号" });
_store.DeleteUser(id);
return Ok(new { ok = true });
});
// ───────────────────────── 工具 ─────────────────────────
/// <summary>统一把 <see cref="RbacException"/> 翻译成 400 + message,其余异常向上抛。</summary>
private IActionResult Guard(Func<IActionResult> action)
{
try { return action(); }
catch (RbacException ex) { return BadRequest(new { message = ex.Message }); }
}
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
}
-143
View File
@@ -1,143 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 配置向导 API。登录后若 <c>deployment.Configured=false</c>(见 <c>LoginResponse.NeedsWizard</c>),
/// 前端进入向导:
/// <list type="bullet">
/// <item><c>GET /api/wizard/options</c>:可选项目录(导航方式 / 模块 / 业务场景模板)。</item>
/// <item><c>GET /api/wizard/profile</c>:回显当前部署画像(含由导航选型推导的激活场景 id)。</item>
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>。</item>
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(保留草稿)。</item>
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
/// </summary>
[ApiController]
[Authorize]
[Route("api/wizard")]
public class WizardController : ControllerBase
{
private readonly ConfigStore _store;
private readonly SimpleLiteLauncher _launcher;
private readonly ILogger<WizardController> _log;
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
{
_store = store;
_launcher = launcher;
_log = log;
}
[HttpGet("options")]
public IActionResult Options()
{
return Ok(new
{
navigationKinds = DeploymentCatalog.NavigationKinds,
modules = DeploymentCatalog.Modules,
scenarios = _store.Get("scenario").Payload
});
}
[HttpGet("profile")]
public IActionResult GetProfile() => Ok(Project(_store.GetDeployment()));
/// <summary>当前部署画像对菜单的裁剪结果(供前端做面板/能力级裁剪与排查)。</summary>
[HttpGet("effective-pages")]
public IActionResult EffectivePages()
{
var dp = _store.GetDeployment();
return Ok(new
{
configured = dp.Configured,
tailorablePages = DeploymentCatalog.TailorablePages(),
enabledPages = DeploymentCatalog.EnabledPages(dp),
hiddenPages = DeploymentCatalog.HiddenPages(dp)
});
}
[HttpPut("profile")]
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
{
if (req == null)
return BadRequest(new { message = "请求体不能为空" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var profile = new DeploymentProfile(
Configured: true,
PlatformType: string.IsNullOrWhiteSpace(req.PlatformType) ? "standard" : req.PlatformType.Trim(),
Modules: Clean(req.Modules),
NavigationKinds: Clean(req.NavigationKinds),
Scenarios: Clean(req.Scenarios),
UpdatedBy: user);
_store.PutDeployment(profile);
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
var sceneIds = profile.ToActiveSceneIds();
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
user, string.Join(",", profile.NavigationKinds), string.Join(",", sceneIds), write.Ok);
return Ok(Project(profile, write));
}
[HttpPost("reset")]
public IActionResult Reset()
{
var reset = _store.GetDeployment() with { Configured = false };
_store.PutDeployment(reset);
return Ok(Project(reset));
}
/// <summary>
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
/// </summary>
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
{
configured = dp.Configured,
platformType = dp.PlatformType,
modules = dp.Modules,
navigationKinds = dp.NavigationKinds,
scenarios = dp.Scenarios,
updatedBy = dp.UpdatedBy,
activeSceneIds = dp.ToActiveSceneIds(),
hiddenPages = DeploymentCatalog.HiddenPages(dp),
activeScenesWrite = write == null ? null : new
{
ok = write.Value.Ok,
path = write.Value.Path,
error = write.Value.Error
}
};
/// <summary>去空白、去重(大小写不敏感)、保持顺序。</summary>
private static List<string> Clean(List<string>? items)
{
var result = new List<string>();
if (items == null) return result;
foreach (var s in items)
{
var t = s?.Trim();
if (!string.IsNullOrEmpty(t) && !result.Contains(t, StringComparer.OrdinalIgnoreCase))
result.Add(t!);
}
return result;
}
public record SaveWizardRequest(
string? PlatformType,
List<string>? Modules,
List<string>? NavigationKinds,
List<string>? Scenarios);
}
-58
View File
@@ -1,58 +0,0 @@
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;
}
}
}
-120
View File
@@ -1,120 +0,0 @@
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;
}
}
-806
View File
@@ -1,806 +0,0 @@
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>外部已存在的 SimpleLiteMiGu.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+2MiGu.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>:本次实际生效的 displayModeweb / 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);
}
}
}
-60
View File
@@ -1,60 +0,0 @@
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.iniCWD 选错会出意外。</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;
}
-25
View File
@@ -1,25 +0,0 @@
<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>
-314
View File
@@ -1,314 +0,0 @@
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.htmldotnet 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>()
}
});
});
// CORSVite 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-5JWT + 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 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
{
// 只对 sl-route 注入 internal tokenvrender-routewebVRender 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 子进程拉起器接入 DIAuthController 登录成功后按 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>();
// 主动实例化 SimpleLiteLauncherFollowParent=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();
@@ -1,14 +0,0 @@
{
"$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"
}
}
}
}
-244
View File
@@ -1,244 +0,0 @@
# 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 内部 APIRV-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/.../distassets/ 与 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) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
-17
View File
@@ -1,17 +0,0 @@
{
"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
}
}
-78
View File
@@ -1,78 +0,0 @@
{
"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.csOnlineCar/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/" }
}
}
}
}
}
-55
View File
@@ -1,55 +0,0 @@
@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%