diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index 2a7d20f..41eaeb6 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -40,6 +40,7 @@ public static class PageCatalog new("admin-processes", "进程管理", "设计与编排", ScopePlatform), new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform), new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform), + new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform), // ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ── new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform), diff --git a/MiGu.Server/Auth/RbacStore.cs b/MiGu.Server/Auth/RbacStore.cs index 11efef2..32a5cdd 100644 --- a/MiGu.Server/Auth/RbacStore.cs +++ b/MiGu.Server/Auth/RbacStore.cs @@ -120,6 +120,12 @@ public sealed class RbacStore && !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase) && hasProcessAndScript) r.Pages.Add("admin-task-templates"); + + // Simple 字段管理:与任务编排同属设计与编排,有任务编排权限时自动补齐。 + if (!r.Pages.Contains(PageCatalog.Wildcard) + && !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase) + && r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)) + r.Pages.Add("admin-simple-fields"); } private RbacSnapshot SeedDefault(IConfiguration config) @@ -206,6 +212,15 @@ public sealed class RbacStore lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); } } + public RbacUser? FindUserById(string id) + { + lock (_gate) + { + var u = _snapshot.Users.FirstOrDefault(x => x.Id == id); + return u is null ? null : Clone(u); + } + } + /// 当前用户可登录的 scope 集合(其角色覆盖的 scope,* 角色覆盖全部)。 public List UsableScopes(RbacUser user) { diff --git a/MiGu.Server/Configs/DeploymentCatalog.cs b/MiGu.Server/Configs/DeploymentCatalog.cs index ccde459..424f925 100644 --- a/MiGu.Server/Configs/DeploymentCatalog.cs +++ b/MiGu.Server/Configs/DeploymentCatalog.cs @@ -27,11 +27,14 @@ public static class DeploymentCatalog new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"), }; - /// 功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。 + /// + /// 功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。 + /// 注意:Key 必须是 PageCatalog 当前有效 Key(不能用 LegacyKeyAliases 里的旧名,否则裁剪悄然失效)。 + /// public static readonly IReadOnlyDictionary ModuleToPages = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["wms"] = new[] { "admin-config-warehouse" }, + ["wms"] = new[] { "admin-config-facility" }, }; /// 所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。 diff --git a/MiGu.Server/Configs/DeploymentProfile.cs b/MiGu.Server/Configs/DeploymentProfile.cs index 47408e0..5128002 100644 --- a/MiGu.Server/Configs/DeploymentProfile.cs +++ b/MiGu.Server/Configs/DeploymentProfile.cs @@ -35,15 +35,17 @@ public record DeploymentProfile( UpdatedBy: ""); /// - /// 导航方式 → SimpleLite 场景 id 映射。与 scene.json.idSimpleCore.Navigation.NavKind + /// 导航方式 → SimpleLite 场景 id 映射。与 *.scene.json.idSimpleCore.Navigation.NavKind /// 以及内核 active-scenes.json.activeScenes 一致;这是平台与内核之间「导航选型」的契约约定。 + /// 两平台制(StandardScene 拆分落地):磁导航 → scene.mag; + /// 二维码与激光共用融合平台 scene.qrlidar(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。 /// public static readonly IReadOnlyDictionary NavKindToSceneId = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["magnetic"] = "scene.magnetic", - ["qrcode"] = "scene.qrcode", - ["laser"] = "scene.laser", + ["magnetic"] = "scene.mag", + ["qrcode"] = "scene.qrlidar", + ["laser"] = "scene.qrlidar", }; /// diff --git a/MiGu.Server/Controllers/ConfigController.cs b/MiGu.Server/Controllers/ConfigController.cs index 0ae4897..ca5010b 100644 --- a/MiGu.Server/Controllers/ConfigController.cs +++ b/MiGu.Server/Controllers/ConfigController.cs @@ -6,7 +6,7 @@ using MiGu.Server.Configs; namespace MiGu.Server.Controllers; // AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。 -// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。 +// GET (List/Get) 只要登录就放;PUT 按 scope 收紧:Platform 任意节,RCSMonitor 仅 ops 白名单。 [ApiController] [Authorize] [Route("api/config")] @@ -47,15 +47,24 @@ public class ConfigController : ControllerBase }); } - // 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope - // 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。 + /// RCSMonitor scope 允许写入的 section 白名单(地图监控动作备份等运营自有配置)。 + private static readonly string[] MonitorWritableSections = { "ops" }; + + // Platform scope 可写任意 section;RCSMonitor 仅允许写 ops(保留运营端 + // 「地图监控动作 ops.monitor 备份」既有功能),其余 section(routing/auth/system 等)一律 403。 [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 scope = User.FindFirst("scope")?.Value; + if (!string.Equals(scope, "Platform", StringComparison.OrdinalIgnoreCase) + && !MonitorWritableSections.Contains(section, StringComparer.OrdinalIgnoreCase)) + { + return StatusCode(403, new { message = $"当前账号无权修改配置节 {section}(需要 Platform 管理端权限)" }); + } + var env = _store.Put(section, payload); return Ok(new { diff --git a/MiGu.Server/Controllers/HealthController.cs b/MiGu.Server/Controllers/HealthController.cs index 8ccdd84..425b0ef 100644 --- a/MiGu.Server/Controllers/HealthController.cs +++ b/MiGu.Server/Controllers/HealthController.cs @@ -13,39 +13,33 @@ public class HealthController : ControllerBase 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" + uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds }); } /// /// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。 /// 配置位置:MiGu.Server/appsettings.jsonSimpleLite 节点。 + /// 含服务器本地路径等敏感信息,要求登录。 /// [HttpGet("simplelite")] + [Authorize] public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics()); /// /// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。 + /// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。 /// [HttpPost("simplelite/restart-for-update")] - [Authorize] + [Authorize(Policy = "PlatformScope")] public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly") { var result = _launcher.RestartForUpdate(launchMode); diff --git a/MiGu.Server/Controllers/LogsController.cs b/MiGu.Server/Controllers/LogsController.cs index 2ac880b..4302380 100644 --- a/MiGu.Server/Controllers/LogsController.cs +++ b/MiGu.Server/Controllers/LogsController.cs @@ -747,9 +747,9 @@ public sealed class LogsController : ControllerBase } b.Latest = e.Content; b.LatestTime = e.Time; - b.Recent.Add(e); - // 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆。 - if (b.Recent.Count > maxPerTag) b.Recent.RemoveAt(0); + b.Recent.Enqueue(e); + // 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆(Queue 头部出队 O(1))。 + if (b.Recent.Count > maxPerTag) b.Recent.Dequeue(); } private static object ToBookDto(Book b) => new @@ -831,6 +831,6 @@ public sealed class LogsController : ControllerBase public DateTime? LastTime { get; set; } public string Latest { get; set; } = ""; public DateTime? LatestTime { get; set; } - public List Recent { get; } = new(); + public Queue Recent { get; } = new(); } } diff --git a/MiGu.Server/Controllers/MapsContentController.cs b/MiGu.Server/Controllers/MapsContentController.cs index 9c0c278..878f8be 100644 --- a/MiGu.Server/Controllers/MapsContentController.cs +++ b/MiGu.Server/Controllers/MapsContentController.cs @@ -59,11 +59,11 @@ public class MapsContentController : ControllerBase return NotFound(new { message = $"地图文件不存在:{fileName}" }); var content = await System.IO.File.ReadAllTextAsync(fullPath, ct); + // 不返回 fullPath:避免向前端泄露服务器目录结构。 return Ok(new { name, fileName, - path = fullPath, content }); } diff --git a/MiGu.Server/Controllers/WizardController.cs b/MiGu.Server/Controllers/WizardController.cs index fbaca58..56354a4 100644 --- a/MiGu.Server/Controllers/WizardController.cs +++ b/MiGu.Server/Controllers/WizardController.cs @@ -12,8 +12,8 @@ namespace MiGu.Server.Controllers; /// /// GET /api/wizard/options:可选项目录(导航方式 / 模块 / 业务场景模板)。 /// GET /api/wizard/profile:回显当前部署画像(含由导航选型推导的激活场景 id)。 -/// PUT /api/wizard/profile:保存并置 Configured=true -/// POST /api/wizard/reset:把 Configured 置回 false 以重新引导(保留草稿)。 +/// PUT /api/wizard/profile:保存并置 Configured=true(仅 Platform scope)。 +/// POST /api/wizard/reset:把 Configured 置回 false 以重新引导(仅 Platform scope)。 /// /// 说明:保存时即把选型固化为单一事实来源 deployment section,并同步联动 Launcher —— /// WriteActiveScenesplugins/active-scenes.json / 透传 --scenes, @@ -64,6 +64,7 @@ public class WizardController : ControllerBase } [HttpPut("profile")] + [Authorize(Policy = "PlatformScope")] public IActionResult SaveProfile([FromBody] SaveWizardRequest req) { if (req == null) @@ -82,7 +83,10 @@ public class WizardController : ControllerBase // 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载; // 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。 - var sceneIds = profile.ToActiveSceneIds(); + // scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合; + // scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按 + // 清单 requiresCore 自动 alwaysLoad,无需在此声明。 + var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList(); var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile"); _log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}", @@ -92,6 +96,7 @@ public class WizardController : ControllerBase } [HttpPost("reset")] + [Authorize(Policy = "PlatformScope")] public IActionResult Reset() { var reset = _store.GetDeployment() with { Configured = false }; diff --git a/MiGu.Server/Launcher/SimpleLiteBuildSync.cs b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs index be72cf2..fed4e82 100644 --- a/MiGu.Server/Launcher/SimpleLiteBuildSync.cs +++ b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs @@ -50,13 +50,20 @@ public static class SimpleLiteBuildSync return true; } + /// + /// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。 + /// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON + /// success:false(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。 + /// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康 + /// 检查都会真实派车,属严重副作用,严禁回退到真实 id。 + /// 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", + $"http://127.0.0.1:{port}/projection/reflection/car/-1/goto-site?siteId=-1", null).GetAwaiter().GetResult(); var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) diff --git a/MiGu.Server/Launcher/SimpleLiteLauncher.cs b/MiGu.Server/Launcher/SimpleLiteLauncher.cs index 7038752..d9055c5 100644 --- a/MiGu.Server/Launcher/SimpleLiteLauncher.cs +++ b/MiGu.Server/Launcher/SimpleLiteLauncher.cs @@ -261,6 +261,9 @@ public sealed class SimpleLiteLauncher : IDisposable private string BuildArguments(string displayMode, string extra) { var args = $"--display-mode={displayMode}"; + // 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel), + // 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。 + if (_opts.EmbeddedCanvas) args += " --migu"; // 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。 var sceneArg = ReadActiveScenesArg(); if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg; @@ -392,12 +395,33 @@ public sealed class SimpleLiteLauncher : IDisposable return result; } + private bool? _gotoSiteProbeCache; + private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue; + private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60); + /// 启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。 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; + // 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。 + bool? gotoSite = null; + if (projectionUp) + { + if (_gotoSiteProbeCache is bool cached && DateTimeOffset.UtcNow - _gotoSiteProbeAt < GotoSiteProbeTtl) + { + gotoSite = cached; + } + else + { + gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort); + if (gotoSite is not null) + { + _gotoSiteProbeCache = gotoSite; + _gotoSiteProbeAt = DateTimeOffset.UtcNow; + } + } + } var deployHint = gotoSite == false ? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update" : null; diff --git a/MiGu.Server/Launcher/SimpleLiteOptions.cs b/MiGu.Server/Launcher/SimpleLiteOptions.cs index bf61d34..ba84c9e 100644 --- a/MiGu.Server/Launcher/SimpleLiteOptions.cs +++ b/MiGu.Server/Launcher/SimpleLiteOptions.cs @@ -57,4 +57,15 @@ public sealed class SimpleLiteOptions /// 一般只在临时联调期 / CI 流水线想自动清理时打开。 /// public bool FollowParent { get; set; } = false; + + /// + /// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 --migu),默认 true。 + /// + /// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel, + /// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。 + /// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。 + /// + /// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。 + /// + public bool EmbeddedCanvas { get; set; } = true; } diff --git a/MiGu.Server/MiGu.Server.csproj b/MiGu.Server/MiGu.Server.csproj index 46fda33..5a3e57d 100644 --- a/MiGu.Server/MiGu.Server.csproj +++ b/MiGu.Server/MiGu.Server.csproj @@ -25,6 +25,8 @@ + + diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs index 488326e..361f332 100644 --- a/MiGu.Server/Persistence/PlatformDbContext.cs +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MiGu.Server.Dashboard; using MiGu.Server.Wms; +using MiGu.Server.SimpleFields; namespace MiGu.Server.Persistence; @@ -16,6 +18,8 @@ public sealed class PlatformDbContext : DbContext public DbSet ContainerMaterials => Set(); public DbSet ContainerLocationHistories => Set(); public DbSet ContainerMaterialHistories => Set(); + public DbSet SimpleFields => Set(); + public DbSet UserDashboardShortcuts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -55,6 +59,46 @@ public sealed class PlatformDbContext : DbContext modelBuilder.Entity().Property(x => x.Quantity).HasPrecision(18, 4); modelBuilder.Entity().Property(x => x.QuantityDelta).HasPrecision(18, 4); + + ConfigureSimpleField(modelBuilder); + ConfigureUserDashboardShortcut(modelBuilder); + } + + private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("user_dashboard_shortcuts"); + e.HasKey(x => new { x.UserId, x.Scope }); + e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64); + e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32); + e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text"); + var dateTime = new ValueConverter( + v => v.UtcDateTime.ToString("O"), + v => DateTimeOffset.Parse(v)); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40); + } + + private static void ConfigureSimpleField(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("simple_fields"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64); + e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64); + e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128); + e.Property(x => x.Value).HasColumnName("value"); + e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128); + e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false); + e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false); + e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512); + e.Property(x => x.IsDefault).HasColumnName("is_default"); + var dateTime = new ValueConverter( + v => SimpleFieldDateTime.ToStorage(v), + v => SimpleFieldDateTime.FromStorage(v)); + e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19); + e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19); + e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique(); } public override int SaveChanges(bool acceptAllChangesOnSuccess) diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs index 4b09c7d..1fe3a42 100644 --- a/MiGu.Server/Persistence/PlatformPersistence.cs +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Data.Sqlite; using MiGu.Server.Wms; +using MiGu.Server.SimpleFields; namespace MiGu.Server.Persistence; @@ -38,6 +40,8 @@ public static class PlatformPersistence services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } @@ -46,6 +50,122 @@ public static class PlatformPersistence using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); + // EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。 + await EnsureSimpleFieldsTableAsync(db); + await EnsureUserDashboardShortcutsTableAsync(db); + } + + /// 为已存在的数据库补建 simple_fields 表(幂等)。 + private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS simple_fields ( + id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY, + car_type TEXT NOT NULL DEFAULT '', + field_type TEXT NOT NULL, + "key" TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + data_type TEXT NOT NULL DEFAULT '', + chinese TEXT, + english TEXT, + other TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL, + create_time TEXT NOT NULL, + update_time TEXT NOT NULL + ); + """); + // 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。 + await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;"); + await db.Database.ExecuteSqlRawAsync(""" + UPDATE simple_fields SET car_type = other + WHERE (car_type IS NULL OR car_type = '') AND other <> ''; + """); + await db.Database.ExecuteSqlRawAsync(""" + UPDATE simple_fields SET other = '' + WHERE other <> '' AND other = car_type; + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key + ON simple_fields (car_type, field_type, "key"); + """); + return; + } + + // 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。 + if (!await TableExistsAsync(db, "simple_fields")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + + /// 为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。 + private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts ( + user_id TEXT NOT NULL, + scope TEXT NOT NULL, + keys_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope) + ); + """); + return; + } + + if (!await TableExistsAsync(db, "user_dashboard_shortcuts")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + + /// + /// 检查表是否存在 + /// + /// 数据库上下文 + /// 表名 + /// 表是否存在 + private static async Task TableExistsAsync(PlatformDbContext db, string table) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + await conn.OpenAsync(); + try + { + await using var cmd = conn.CreateCommand(); + if (db.Database.IsSqlServer()) + { + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t"; + var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); + } + else if (db.Database.IsNpgsql()) + { + cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t"; + var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); + } + else if (db.Database.IsMySql()) + { + cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t"; + var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); + } + else + { + return false; + } + var result = await cmd.ExecuteScalarAsync(); + return result != null; + } + finally + { + if (conn.State == System.Data.ConnectionState.Open) + await conn.CloseAsync(); + } } private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider) diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index bb07449..b26ffdb 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models; using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; +using MiGu.Server.OpenApi; using MiGu.Server.Persistence; using Yarp.ReverseProxy.Transforms; @@ -98,7 +99,13 @@ builder.Services.AddControllers() builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" }); + c.SwaggerDoc("v1", new() + { + Title = "MiGu.Server + SimpleLite", + Version = "v1", + Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。" + }); + c.DocumentFilter(); // Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。 c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { @@ -150,15 +157,6 @@ builder.Services.AddSingleton(); 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 => @@ -169,13 +167,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) var cookie = ctx.Request.Cookies["simple.auth.token"]; if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie; } - // 用真实 JwtIssuer 参数替换占位 ValidationParameters。 - var issuer = ctx.HttpContext.RequestServices.GetRequiredService(); - ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters(); return Task.CompletedTask; } }; }); +// TokenValidationParameters 由 JwtIssuer(DI 单例)启动期一次性提供, +// 替代旧的「每请求在 OnMessageReceived 里改写共享 Options」写法(并发坏味道)。 +builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((opt, issuer) => opt.TokenValidationParameters = issuer.BuildValidationParameters()); builder.Services.AddAuthorization(opts => { @@ -200,8 +199,9 @@ builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) .AddTransforms(tctx => { - // 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。 - if (tctx.Route.RouteId != "sl-route") return; + // 对全部 sl-* 路由(兜底 + map-edit/ai-config/reflection 管理面拆分路由)注入 + // internal token;vrender-route(webVRender iframe 静态资源)不需要。 + if (!tctx.Route.RouteId.StartsWith("sl-", StringComparison.Ordinal)) return; tctx.AddRequestTransform(rt => { var store = rt.HttpContext.RequestServices.GetRequiredService(); diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index 4f6b949..770f3e9 100644 --- a/MiGu.Server/appsettings.json +++ b/MiGu.Server/appsettings.json @@ -44,8 +44,48 @@ "Dispatch": { } }, + "_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。", "ReverseProxy": { "Routes": { + "sl-mapedit-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "PlatformScope", + "Order": -2, + "Match": { "Path": "/api/sl/projection/map-edit/{**catch-all}" }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, + "sl-aiconfig-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "PlatformScope", + "Order": -2, + "Match": { "Path": "/api/sl/projection/ai-config/{**catch-all}" }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, + "sl-reflection-selection-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "AnyAuthed", + "Order": -3, + "Match": { "Path": "/api/sl/projection/reflection/selection/{**catch-all}" }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, + "sl-reflection-write-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "PlatformScope", + "Order": -1, + "Match": { + "Path": "/api/sl/projection/reflection/{**catch-all}", + "Methods": [ "POST", "PUT", "PATCH", "DELETE" ] + }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, "sl-route": { "ClusterId": "sl-cluster", "AuthorizationPolicy": "AnyAuthed", diff --git a/MiGu.Server/data/platform.db b/MiGu.Server/data/platform.db index 3eff1fb..1d1e6fe 100644 Binary files a/MiGu.Server/data/platform.db and b/MiGu.Server/data/platform.db differ diff --git a/frontends/apps/simple-platform-vue/package.json b/frontends/apps/simple-platform-vue/package.json index 3fba515..8b0ceb0 100644 --- a/frontends/apps/simple-platform-vue/package.json +++ b/frontends/apps/simple-platform-vue/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint . --ext .ts,.vue --fix" + "typecheck": "vue-tsc --noEmit" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", diff --git a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts index 9f11eb1..a0adbef 100644 --- a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts +++ b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts @@ -204,11 +204,9 @@ export const mapEditApi = { dashboardSummary: () => unwrap(http.get(`${BASE}/dashboard/summary`)), - // 资产上传:传 base64 + // 资产上传:JSON body 传 base64(后端按 JSON 解析;勿手动设 multipart 头 —— body 并非 multipart)。 uploadAsset: (filename: string, dataBase64: string) => - unwrap(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 }, { - headers: { 'Content-Type': 'multipart/form-data' } - })), + unwrap(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 })), // AI 生图 aiMapGenerate: (req: AiMapGenerateRequest) => @@ -335,7 +333,6 @@ export interface MapSaveResult { export interface MapContentResult { name: string fileName: string - path: string content: string } @@ -426,13 +423,25 @@ export const mapsApi = { * 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。 */ async merge(sources: string[], target: string, overwrite = false): Promise { - const { data } = await http.post>(`${BASE}/maps/merge`, { - sources, - target, - overwrite - }) - if (data?.success) return { ok: true, data: data.data as MapMergeResult } - const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在') - return { ok: false, conflict, message: data?.message ?? '合并失败' } + try { + const { data } = await http.post>(`${BASE}/maps/merge`, { + sources, + target, + overwrite + }) + if (data?.success) return { ok: true, data: data.data as MapMergeResult } + const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在') + return { ok: false, conflict, message: data?.message ?? '合并失败' } + } catch (err) { + // 与 save() 对齐:后端以 HTTP 409 状态码返回时同样翻译为 conflict, + // 让调用方能弹「是否替换」确认而非直接报错。 + const ax = err as AxiosError> + const body = ax.response?.data + if (ax.response?.status === 409 || body?.code === 409) { + const message = body?.message ?? '目标地图已存在' + return { ok: false, conflict: message.includes('已存在'), message } + } + throw err + } } } diff --git a/frontends/apps/simple-platform-vue/src/api/reflection.ts b/frontends/apps/simple-platform-vue/src/api/reflection.ts index 5dee0e2..e5da923 100644 --- a/frontends/apps/simple-platform-vue/src/api/reflection.ts +++ b/frontends/apps/simple-platform-vue/src/api/reflection.ts @@ -568,6 +568,11 @@ export const reflectionApi = { ? Promise.resolve({ globalDefault: defaultCarStyleDto(), types: [] }) : get('/car-style/types'), + /** 车型编码字段元数据(site/track/plan/car 四类字段及默认值)。 */ + getCarTypeCoderFields: () => MOCK + ? Promise.resolve([]) + : get('/car-types/coder-fields'), + getCarStyle: (typeFullName: string) => MOCK ? Promise.resolve(defaultCarStyleDto()) : get(`/car-style/${encodeURIComponent(typeFullName)}`), @@ -741,6 +746,31 @@ export interface CarStyleTypesPayload { types: CarStyleTypeRow[] } +export interface CoderFieldDef { + name: string + typeName: string + defaultValue: unknown +} + +export interface CoderFieldGroup { + typeName: string + shortName: string + assemblyName: string + baseTypeName?: string + fields: CoderFieldDef[] +} + +export interface CarTypeCoderFieldsRow { + typeName: string + shortName: string + label: string + assemblyName: string + siteFields: CoderFieldGroup + trackFields: CoderFieldGroup + planFields: CoderFieldGroup + carFields: CoderFieldGroup +} + export interface AlarmColorEntry { key: string colorArgb: number diff --git a/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue b/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue index fa44148..9ffcb20 100644 --- a/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue +++ b/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue @@ -36,6 +36,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue' import type { Scope } from '@/types/auth' +import { defaultVrHost } from '@/utils/vrender' interface PickEvent { x: number; y: number } @@ -78,7 +79,7 @@ const lastPick = ref(null) const lastSelect = ref([]) const iframeSrc = ref('') -const resolvedHost = computed(() => props.host ?? (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223') +const resolvedHost = computed(() => props.host ?? defaultVrHost()) const vrUrl = computed(() => { const qs = new URLSearchParams() diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue b/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue index 798cdf7..3b04b29 100644 --- a/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue @@ -1,18 +1,25 @@