fix(platform): 代码审查整改——反代按域拆分授权、根除探测副作用与死代码清理

- YARP: map-edit/ai-config 全方法、reflection 写方法挂 PlatformScope,
  reflection/selection 单独放行(运营端 3D 高亮),堵住运营账号直达地图编辑/反射调用
- goto-site 探测改用不存在的 car/-1(消除健康检查真实派车风险)并加 60s 缓存
- Config PUT 按 scope 收紧:RCSMonitor 仅可写 ops 节;wizard 写操作与
  simplelite/restart-for-update 限 PlatformScope;/api/health 去除虚假端口表
- 修复 wms 模块菜单裁剪失效(admin-config-location → admin-config-facility)
- vrHost 默认 location.hostname:8223(新增 utils/vrender.ts),远程访问 3D 视口可用
- /status 页改接真实 /api/health* 诊断;uploadAsset 移除矛盾 multipart 头;
  mapsApi.merge 对齐 save 的 409 冲突处理;JWT 验签参数改启动期 DI 一次性配置
- 清理死代码:ProjectionController、DataTablePro、useClipboard、CadToolbarView、
  AppShell 未用导入;lint 脚本替换为 typecheck;日志窗口 List 改 Queue
This commit is contained in:
zhaowei.huang
2026-06-12 23:00:47 +08:00
parent d857cda071
commit 20f98db6da
24 changed files with 266 additions and 320 deletions
+5 -2
View File
@@ -27,11 +27,14 @@ public static class DeploymentCatalog
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
};
/// <summary>功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。</summary>
/// <summary>
/// 功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。
/// 注意:Key 必须是 PageCatalog 当前有效 Key(不能用 LegacyKeyAliases 里的旧名,否则裁剪悄然失效)。
/// </summary>
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["wms"] = new[] { "admin-config-location" },
["wms"] = new[] { "admin-config-facility" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
+13 -4
View File
@@ -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(地图监控动作)备份字段。
/// <summary>RCSMonitor scope 允许写入的 section 白名单(地图监控动作备份等运营自有配置)。</summary>
private static readonly string[] MonitorWritableSections = { "ops" };
// Platform scope 可写任意 sectionRCSMonitor 仅允许写 ops(保留运营端
// 「地图监控动作 ops.monitor 备份」既有功能),其余 sectionrouting/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
{
+6 -12
View File
@@ -13,39 +13,33 @@ public class HealthController : ControllerBase
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
[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
});
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// 含服务器本地路径等敏感信息,要求登录。
/// </summary>
[HttpGet("simplelite")]
[Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[Authorize]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
+4 -4
View File
@@ -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<LogEntry> Recent { get; } = new();
public Queue<LogEntry> Recent { get; } = new();
}
}
@@ -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
});
}
@@ -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 }
});
}
+4 -2
View File
@@ -12,8 +12,8 @@ namespace MiGu.Server.Controllers;
/// <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>
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>(仅 Platform scope。</item>
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(仅 Platform scope)。</item>
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
@@ -64,6 +64,7 @@ public class WizardController : ControllerBase
}
[HttpPut("profile")]
[Authorize(Policy = "PlatformScope")]
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
{
if (req == null)
@@ -92,6 +93,7 @@ public class WizardController : ControllerBase
}
[HttpPost("reset")]
[Authorize(Policy = "PlatformScope")]
public IActionResult Reset()
{
var reset = _store.GetDeployment() with { Configured = false };
+8 -1
View File
@@ -50,13 +50,20 @@ public static class SimpleLiteBuildSync
return true;
}
/// <summary>
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
/// 检查都会真实派车,属严重副作用,严禁回退到真实 id。
/// </summary>
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)
+22 -1
View File
@@ -392,12 +392,33 @@ public sealed class SimpleLiteLauncher : IDisposable
return result;
}
private bool? _gotoSiteProbeCache;
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
/// <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;
// 探测结果缓存 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;
+7 -14
View File
@@ -156,15 +156,6 @@ 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 =>
@@ -175,13 +166,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<JwtIssuer>();
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
return Task.CompletedTask;
}
};
});
// TokenValidationParameters 由 JwtIssuerDI 单例)启动期一次性提供,
// 替代旧的「每请求在 OnMessageReceived 里改写共享 Options」写法(并发坏味道)。
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<JwtIssuer>((opt, issuer) => opt.TokenValidationParameters = issuer.BuildValidationParameters());
builder.Services.AddAuthorization(opts =>
{
@@ -206,8 +198,9 @@ builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
{
// 对 sl-route 注入 internal tokenvrender-routewebVRender iframe 静态资源)不需要。
if (tctx.Route.RouteId != "sl-route") return;
// 对全部 sl-* 路由(兜底 + map-edit/ai-config/reflection 管理面拆分路由)注入
// internal tokenvrender-routewebVRender iframe 静态资源)不需要。
if (!tctx.Route.RouteId.StartsWith("sl-", StringComparison.Ordinal)) return;
tctx.AddRequestTransform(rt =>
{
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
+40
View File
@@ -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",