Compare commits

..
18 Commits
Author SHA1 Message Date
黄兆尉andCursor 0788fad74a 地图监控改为固定双栏并可收起侧栏,编辑对齐改走内核 CAD,并接通 428 确认票。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:30:26 +08:00
黄兆尉andCursor 3968f5dc1f 加固地图编辑拾取循环,避免多路 pick 竞争与选中回环。
统一 stop/restart pick 代际控制,并小幅整理编辑工具栏交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:37 +08:00
黄兆尉andCursor c9e835aaf7 运维网关支持按车辆配置方法真实下发,并按用户过滤审计。
新增 ops.car.execute 路径与前端运维操作/选中面板联动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:34 +08:00
黄兆尉andCursor c5d5f7a726 重构页面目录与 RBAC:扁平配置入口并对齐管理/运营页映射。
下线运营总览幽灵页,补 ExpandKeysForScope / PlatformToMonitor,同步导航与角色配置 UI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:02 +08:00
黄兆尉andCursor 59ccd7934a 登录固定 WebOnly,并增强部署向导流程。
移除启动模式切换与 ScopeSwitcher;向导拆分子组件并补充 setup 工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:51 +08:00
黄兆尉andCursor 79c9e36d89 在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:25 +08:00
黄兆尉andCursor 4180140ae4 Signal 数据中心改为运行时加载 schema,解除 StandardScene 工程编译耦合。
加固 Config/Signal 路径解析,前端 DataCenter 表单类型与 API 对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:47:15 +08:00
黄兆尉andCursor 3686abdc78 将调度内核标识从 SimpleLite 全面重命名为 Simple3。
配置段/环境变量、Launcher、健康检查 API、OpenAPI 与前后端文案同步;兼容探测旧 SimpleLite 进程名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:46:52 +08:00
黄兆尉 912c44c9bb Merge remote-tracking branch 'origin/main' 2026-08-26 13:56:20 +08:00
wei.wu 4962d768d4 Merge branch 'main' of http://it.fairylandtech.com:8666/Third_Dev_Dept/Migu2.0 2026-08-25 11:21:21 +08:00
wei.wu 95205cbcde 新增WCS任务模板引擎原型
新增“WCS模板原型”入口及自检脚本,支持无代码拖拽流程设计、选位试算、实例运行、预占管理等。实现了DSL类型定义、控件库、画布节点、节点属性面板、试算台等组件。补充了流程编排、参数组装、表达式求值、选位与打分、预占管理、模板校验等核心逻辑。提供假数据、演示脚本及README说明,便于后续对接真实系统。
2026-08-25 11:21:11 +08:00
wei.wu 4ee6379c26 在 .gitignore 中添加 /.cursor/plans 路径
新增 /.cursor/plans 到 .gitignore,避免该目录被 Git 追踪。
2026-08-25 11:17:36 +08:00
ykkokluo c5c1578696 修改交管和信号交互插件加载时机 2026-08-25 08:59:00 +08:00
ykkokluo 134c7480b3 新增磁导航内部交管和信号交互界面 2026-08-24 16:16:58 +08:00
黄兆尉andCursor 5d277cf17d merge: 合并 origin/main(MiGu.DB 持久层拆分与车辆/OTA 更新)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:34:44 +08:00
wei.wu 125372b157 移除历史状态归一化及相关遗留逻辑
- 移除 LegacyStatusNormalizationMigrator,仅保留 AreaLayoutModeDefaultMigrator
- 删除 WmsDispatchStatus 枚举、常量和全局 using
- 统一所有 DbContext 注入为 MiGuDbContext,移除 PlatformDbContext
- 服务实体操作统一用 IEditableRepository<T>,移除本地实现
- 移除 WmsService 的 MigrateLegacyAsync 方法
- 精简接口模型,移除部分字段和请求体
- 前端保存容器时 status 字段取自已有数据,移除写死默认值
- 更新文档,去除旧说明,统一数据库初始化流程
2026-08-03 09:41:03 +08:00
wei.wu 1f72488a8d 新增车辆表及OTA权限与逻辑优化
新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
2026-07-27 15:12:14 +08:00
zhaowei.huang 7af29fe07f fix: improve map editor and monitor operations 2026-07-26 14:14:19 +08:00
209 changed files with 14765 additions and 3531 deletions
+4 -2
View File
@@ -13,7 +13,7 @@ MiGu.Server/data/*.db
MiGu.Server/data/*.db-shm
MiGu.Server/data/*.db-wal
MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录*.json 上面那行不递归)
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
MiGu.Server/data/ota/
# 前端构建产物
@@ -34,10 +34,12 @@ _build_out.txt
*.swp
Thumbs.db
Desktop.ini
/.cursor/rules
/.cursor/
/MiGu.Server/wwwroot
/MiGu.Server/data/platform.db
# 临时脚本 / 工具产物(不应入库)
.codex-temp/
frontends/apps/simple-platform-vue/imgui.ini
/.cursor/plans
/tmp-*.json
+116 -116
View File
@@ -1,10 +1,10 @@
# SimpleLite 三端系统架构设计
# Simple3 三端系统架构设计
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。SimpleLite 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。Simple3 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> 文档版本:v1.7.0
> 更新日期:2026-05-20
> 范围:SimpleLite(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 SimpleLite**
> 范围:Simple3(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 Simple3**
> v1.7.0 变更(多主题色板 · 工业紫默认):
> - **6 套可切换主题**`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
@@ -37,13 +37,13 @@
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
> v1.4 变更(保留):
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 SimpleLite 内置 `SystemMission``StartPlatformMission`)拉起、被 SimpleLite Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 Simple3 内置 `SystemMission``StartPlatformMission`)拉起、被 Simple3 Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **整体定位章节重写**:明确"两类后端可执行文件 + 一个进程编排者"的关系,避免读者误以为 Platform 是用 Windows Service / systemd 独立部署的服务。
> v1.3 变更(保留):
> 1. **取消 RCSMonitor.Server**`rcsmonitor-vue` 不再有独立后端进程,直接连 MiGu.Server;由 JWT 中的 `scope=RCSMonitor` + 角色权限码 + `WidgetGrant` 三层限制可见控件与可调用 API。
> 2. **3D 渲染基座定为 webVRender**:复用 SimpleLite WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → SimpleLite WebAPI 的反代;带鉴权与审计中间件。
> 2. **3D 渲染基座定为 webVRender**:复用 Simple3 WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → Simple3 WebAPI 的反代;带鉴权与审计中间件。
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
> 5. **Web-Enabled 下严禁同时本地操作**CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
@@ -79,43 +79,43 @@
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 SimpleLite 统一编排:**
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 Simple3 统一编排:**
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|----|----------|----------------|------------|------|--------|
| **SimpleLite 桌面端** | CycleGUI 客户端 | `SimpleLite.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 SimpleLite Watchdog 重启) | **SimpleLite 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 SimpleLite |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 SimpleLite 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
| **Simple3 桌面端** | CycleGUI 客户端 | `Simple3.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **Simple3 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 Simple3 Watchdog 重启) | **Simple3 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 Simple3 |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 Simple3 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
**关键澄清(v1.4 重点):**
- **后端可执行文件只有 2 个**`SimpleLite.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `SimpleLite.exe`MiGu.Server 由 SimpleLite 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 SimpleLite**`SystemMission` 是 SimpleLite 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **后端可执行文件只有 2 个**`Simple3.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `Simple3.exe`MiGu.Server 由 Simple3 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 Simple3**`SystemMission` 是 Simple3 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **Vue 两份产物,同一进程托管**MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`
- **鉴权与限权完全在 MiGu.Server 完成**RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
- **Desktop-Only 模式下,SystemMission 不触发**MiGu.Server 根本不会启动,Vue 端无法访问。
> 一句话:**用户点 SimpleLite.exe,由 SimpleLite 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
> 一句话:**用户点 Simple3.exe,由 Simple3 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
### 1.2 核心拆分原则
1. **Crash-Isolation(搞不崩主服务)**
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 SimpleLite`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 Simple3`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
2. **共享数据模型,独立运行时**
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 SimpleLite 里跑,Platform 只持有"投影"。
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 Simple3 里跑,Platform 只持有"投影"。
3. **库分离 + Provider 抽象**(详见 §7
- `simple_main` 由 SimpleLite 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- `simple_main` 由 Simple3 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- 持久层基于 **EF Core****通过 Provider 切换底层数据库**:默认 **SQLite**(边缘单机,配 ROSE HA);可选 **MySQL / PostgreSQL / SQL Server**,匹配现场已有 IT 基础设施与企业级 HA 能力(详见 §7.4–§7.7)。
- 高可用方案随 Provider 不同:SQLite 用 ROSE HAMySQL 用 InnoDB ClusterPostgreSQL 用 PatroniSQL Server 用 Always On AG。
4. **统一协议层**
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 SimpleLite 的设计/调度 API。
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 Simple3 的设计/调度 API。
5. **统一身份**
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),Simple3 在 Desktop-Only 下用本地用户表降级。
6. **运行模式互斥(强约束)**
- **Desktop-Only**CycleGUI 客户端独占;WebAPI / Platform **不启动**
@@ -139,7 +139,7 @@ flowchart TB
end
subgraph Host["主控宿主机 / 边缘服务器"]
subgraph SL["SimpleLite 主进程 (CycleGUI + ASP.NET Core 8)"]
subgraph SL["Simple3 主进程 (CycleGUI + ASP.NET Core 8)"]
direction TB
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
@@ -155,7 +155,7 @@ flowchart TB
direction TB
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
PL_API["Platform WebAPI<br/>(配置中心 / 外部对接 / 库位 / 账号)"]
PL_YARP["YARP 反代<br/>/api/sl/* → SimpleLite WebAPI<br/>+ Auth/审计中间件"]
PL_YARP["YARP 反代<br/>/api/sl/* → Simple3 WebAPI<br/>+ Auth/审计中间件"]
PL_Auth["SimpleAuth<br/>(JWT 签发 + Scope 校验)"]
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
PL_DB[("SQLite: platform.db<br/>WAL")]
@@ -203,11 +203,11 @@ flowchart TB
**要点:**
- **只有两个后端可执行文件**`SimpleLite.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 SimpleLite 的 SystemMission 拉起**:用户只启动 SimpleLiteWeb-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **只有两个后端可执行文件**`Simple3.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
- **`PL_YARP``/api/sl/*` 反代到 SimpleLite**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 SimpleLite 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **`PL_YARP``/api/sl/*` 反代到 Simple3**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 Simple3 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **Desktop-Only 模式**`SL_API``PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
- **Web-Enabled 模式**CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
@@ -219,8 +219,8 @@ flowchart TB
```mermaid
flowchart TB
Start([SimpleLite.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>SimpleLite Boot Login]
Start([Simple3.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>Simple3 Boot Login]
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
Login --> Form
AutoLogin --> ModeSwitch
@@ -269,7 +269,7 @@ flowchart TB
| 元素 | 控件 | 说明 |
|------|------|------|
| 标题 | Label | "SimpleLite · 启动登录" |
| 标题 | Label | "Simple3 · 启动登录" |
| 用户名 | TextInput | 默认上次登录用户 |
| 密码 | TextInput (password) | — |
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
@@ -281,7 +281,7 @@ flowchart TB
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
| 退出按钮 | Button | 不进入主程序,退出进程 |
> 实现位置:新增 `SimpleLite/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
> 实现位置:新增 `Simple3/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
#### simple.json 增量字段
@@ -310,18 +310,18 @@ flowchart TB
| 场景 | 行为 |
|------|------|
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
| Desktop-Only → Web-Enabled | **必须退出 SimpleLite 重新登录**;不支持热切(避免半启动状态) |
| Desktop-Only → Web-Enabled | **必须退出 Simple3 重新登录**;不支持热切(避免半启动状态) |
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `SimpleLite.exe --re-login` 强制弹出登录窗 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `Simple3.exe --re-login` 强制弹出登录窗 |
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset``rememberRunMode` 置为 `false`**仅在下次重启时生效** |
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
```text
┌──────────────────────────────────────────┐
│ SimpleLite Service Status │
│ Simple3 Service Status │
├──────────────────────────────────────────┤
│ 模式: Web-Enabled │
│ 启动时间: 2026-05-18 09:00:00 │
@@ -348,7 +348,7 @@ flowchart TB
```mermaid
flowchart LR
A["SimpleLite.exe<br/>启动 + 登录"] --> Mode{运行模式}
A["Simple3.exe<br/>启动 + 登录"] --> Mode{运行模式}
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
D --> SchedD[调度引擎]
@@ -360,7 +360,7 @@ flowchart LR
WHost --> WV[WebTerminal :8223<br/>webVRender]
WHost --> StatusPanel[CycleGUI 状态窗]
subgraph SysMis["SimpleLite SystemMission 体系"]
subgraph SysMis["Simple3 SystemMission 体系"]
E["StartPlatformMission<br/>(SystemMission 子类)"]
SM_Reg["SystemMissionRegistry"]
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
@@ -380,24 +380,24 @@ flowchart LR
### 4.2 SystemMission 体系(v1.4 新增章节)
SimpleLite 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
Simple3 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
**关键类:**
| 类型 | 类型 / 命名空间 | 职责 |
|------|------------------|------|
| `SystemMission`(抽象) | `SimpleLite.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `SimpleLite.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `SimpleLite.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `SimpleLite.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `SimpleLite.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `SimpleLite.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `SimpleLite.Bootstrap` | `DesktopOnly` / `WebEnabled` |
| `SystemMission`(抽象) | `Simple3.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `Simple3.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `Simple3.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `Simple3.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `Simple3.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `Simple3.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `Simple3.Bootstrap` | `DesktopOnly` / `WebEnabled` |
**生命周期:**
```text
SimpleLite 启动 (Web-Enabled)
Simple3 启动 (Web-Enabled)
├─ Kestrel Up :7001/7002
├─ WebTerminal Up :8223
├─ SchedulerRuntime.Start()
@@ -414,7 +414,7 @@ SimpleLite 启动 (Web-Enabled)
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
SimpleLite 优雅停机:
Simple3 优雅停机:
SystemMissionRegistry.StopAll()
└─ StartPlatformMission.Stop()
├─ 发送 SIGTERM 等价(命名管道 "shutdown" 消息)
@@ -424,26 +424,26 @@ SimpleLite 优雅停机:
**实现要点:**
- **位置**:新建 `SimpleLite/Bootstrap/``SimpleLite/RCS/SystemMissions/`
- **位置**:新建 `Simple3/Bootstrap/``Simple3/RCS/SystemMissions/`
- **不写入主库 Missions 表**SystemMission 是进程级配置,不参与项目序列化;存储在 `simple_main.db.SystemMissionConfig`(或直接读 `simple.json`)。
- **崩溃隔离**Platform 异常不影响 SimpleLite 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 SimpleLite 唯一;MiGu.Server 启动时校验"父进程是否为 SimpleLite",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **崩溃隔离**Platform 异常不影响 Simple3 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 Simple3 唯一;MiGu.Server 启动时校验"父进程是否为 Simple3",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **静态资源**MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`
---
## 5. 三端职责矩阵
| 维度 | SimpleLite 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
| 维度 | Simple3 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
|------|---------------------|------------------------------------------|------------------------------------------|
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌SimpleLite地图) | 只读投影,不可编辑 |
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌Simple3地图) | 只读投影,不可编辑 |
| 任务编排(Mission/Recipe | **读写** | **全功能读写**(含 Demand / Recipe | 基础动作:暂停/取消/重派/优先级 |
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
| CAD 工具 | **可用** | **可用**Vue 调用 `/api/sl/cad/*` | 不可用 |
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 Simple3 webVRender**(全功能交互) | **嵌入 Simple3 webVRender**(只读 + 圈选) |
| 系统级配置 | 局部(Desktop-Only | **全局编辑 + 下发** | 不可编辑 |
| 外部对接(MES/WMS/RCS | 不涉及 | **唯一入口** | 不涉及 |
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
@@ -549,7 +549,7 @@ Platform 将典型业务场景抽象为标准模板,支持开箱即用和后
```mermaid
flowchart TB
subgraph 顶层["顶层(按端隔离)"]
L1A["SimpleLite (CycleGUI + WebAPI Host)"]
L1A["Simple3 (CycleGUI + WebAPI Host)"]
L1B["MiGu.Server (ASP.NET Core + YARP)"]
L1B2["platform-vue (SPA, 管理员)"]
L1B3["rcsmonitor-vue (SPA, 运营)"]
@@ -608,17 +608,17 @@ flowchart TB
| `SimpleShared.Persistence` | net8.0 | EF Core 8 多 Provider 抽象(**SQLite / MySQL / PostgreSQL / SQL Server**+ 迁移 + `DbProviderOptions` + 连接串构建器 + `WalSqliteOptions`(仅 SQLite |
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 SimpleLite** + 静态托管两份 Vue dist;**启动时校验父进程为 SimpleLite,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 SimpleLite 所有控件 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 Simple3** + 静态托管两份 Vue dist;**启动时校验父进程为 Simple3,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 Simple3 所有控件 |
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
| `packages/sl-controls`pnpm workspace | Vue 3 组件库 | Workspace3D(嵌 webVRender/ 地图列表 / 任务表 / CAD 工具栏等共享组件 |
### 6.1 Platform Vue 控件复刻清单(与 SimpleLite CycleGUI 对照)
### 6.1 Platform Vue 控件复刻清单(与 Simple3 CycleGUI 对照)
| SimpleLite CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
| Simple3 CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
|--------------------------|--------------------------------|------|
| 主菜单栏 / 工具栏 | `<AppShell>` 顶栏 + 侧栏 | platform-vue 全功能;rcsmonitor-vue 仅显示运营菜单 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 SimpleLite webVRender**;交互事件用 `postMessage` 桥接 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 Simple3 webVRender**;交互事件用 `postMessage` 桥接 |
| 地图面板(Sites / Tracks | `<MapEditor>` + `<TrackTable>` | 表格 + 拓扑视图 |
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
@@ -674,7 +674,7 @@ function onReady() {
</script>
```
`SimpleLite/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
`Simple3/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
- `scope=RCSMonitor``ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
@@ -690,7 +690,7 @@ builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(ctx =>
{
// 透传当前 JWT 的 userId / scope 给 SimpleLite
// 透传当前 JWT 的 userId / scope 给 Simple3
ctx.AddRequestTransform(async tc =>
{
var ident = tc.HttpContext.User;
@@ -767,7 +767,7 @@ app.Run();
```mermaid
flowchart TB
subgraph App["应用代码 (SimpleLite / MiGu.Server)"]
subgraph App["应用代码 (Simple3 / MiGu.Server)"]
DOM["领域服务 / Repository"]
EFC["EF Core 8 DbContext"]
OPT["DbProviderOptions<br/>{ provider, conn, ha }"]
@@ -813,7 +813,7 @@ flowchart TB
| 逻辑库 | 拥有进程 | 主要表/数据 |
|--------|----------|-------------|
| **simple_main** | SimpleLite | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **simple_main** | Simple3 | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **platform** | Platform | Users, Roles, RolePermissions, WidgetGrants, SystemConfig, ExternalAdapters, RoutingPolicies, VehicleMaintenancePolicies, ChargePolicies, TaskAllocationPolicies, TrafficRules, Locations, Inventory, OpsLogs(含 RCSMonitor 来源), Annotations, Snapshots, Versions, CustomWidgets |
> 原 v1.2 中的 `rcsmonitor_local` 取消;其表(OpsLogs / Annotations / Snapshots / WidgetLayoutCache)合并到 `platform`,加 `source='RCSMonitor'` 字段区分。
@@ -824,12 +824,12 @@ flowchart TB
```mermaid
flowchart TB
subgraph A["主节点 NODE-A (Active)"]
SL_A["SimpleLite.exe"]
SL_A["Simple3.exe"]
PL_A["MiGu.Server.exe"]
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
end
subgraph B["备节点 NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
D_B[("ROSE 镜像盘 R:\")]
end
@@ -860,7 +860,7 @@ flowchart TB
|------|------|------|
| `RES_DISK_R` | Mirror Disk | — |
| `RES_VIP_LAN` | Virtual IP | RES_DISK_R |
| `RES_SVC_SL` | Service: SimpleLite (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_SL` | Service: Simple3 (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_PL` | Service: MiGu.Server | RES_DISK_R, RES_VIP_LAN, RES_SVC_SL |
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
@@ -871,7 +871,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层 (VIP + 重试)"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Router["MySQL Router (或 ProxySQL)"]
@@ -911,7 +911,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph LB["HAProxy / PgBouncer"]
@@ -953,7 +953,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Listener["AG Listener (DNS + VIP)"]
@@ -983,7 +983,7 @@ flowchart TB
- **EF Core Provider**`Microsoft.EntityFrameworkCore.SqlServer`;支持 SQL Server 2019+(建议 Enterprise 版以启用同步提交副本数 ≥3 + Read-Only Routing)。Standard 版可降级为 2 副本基本可用性组。
- **HA 形态****WSFCWindows Server Failover Cluster+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimpleLite 桌面端可走 Windows 集成认证。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimple3 桌面端可走 Windows 集成认证。
- **TempDB**:每节点分摊;MultipleActiveResultSetsMARS)建议关闭,EF Core 已能避免。
- **备份**`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
- **RTO / RPO**RTO ≈ 1030sAG 自动 failover);RPO ≈ 0(同步提交副本)。
@@ -1091,7 +1091,7 @@ flowchart TB
"database": "simple_main",
"user": "simple_app",
"password": "${SECRET_MSSQL_PWD}",
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=SimpleLite"
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=Simple3"
},
"platform": {
"server": "tcp:rcs-ag.lan,1433",
@@ -1158,7 +1158,7 @@ classDiagram
**落地四件套:**
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 SimpleLite 本地签发短期 Token(24h)。
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 Simple3 本地签发短期 Token(24h)。
2. **授权**:所有 WebAPI + WS 调用走 `IPermissionEvaluator`;权限码 `<area>.<action>`
3. **范围(Scope**`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope``/admin/*``Platform``/monitor/*``RCSMonitor`)。
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
@@ -1173,7 +1173,7 @@ sequenceDiagram
participant U as 运营用户
participant RV as rcsmonitor-vue
participant P as MiGu.Server
participant L as SimpleLite (YARP 后侧)
participant L as Simple3 (YARP 后侧)
participant DBp as platform.db
U->>RV: 访问 https://host:8080/monitor
@@ -1255,7 +1255,7 @@ flowchart LR
UI11 --> Sch
Sch --> Val --> Ver --> Pub
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["SimpleLite (主服务)"]
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["Simple3 (主服务)"]
Pub -- "WS broadcast: config.updated" --> RMVue["rcsmonitor-vue"]
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
```
@@ -1316,7 +1316,7 @@ public record EffectivePermissions(
List<WidgetGrantDto> VisibleWidgets);
```
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 SimpleLite,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 Simple3,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
---
@@ -1327,14 +1327,14 @@ public record EffectivePermissions(
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite (CycleGUI)
participant L as Simple3 (CycleGUI)
participant BL as BootLoginPanel
participant K as Kestrel (WebAPI)
participant P as MiGu.Server (YARP)
participant V as platform-vue
participant RV as rcsmonitor-vue
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读取 simple.json
alt rememberRunMode = true
L->>L: 直接采用 runMode (仍鉴权)
@@ -1373,10 +1373,10 @@ sequenceDiagram
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite
participant L as Simple3
participant BL as BootLoginPanel
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
L->>BL: 必要时弹出登录窗
U->>BL: 输入凭据 + 选 Desktop-Only
@@ -1387,13 +1387,13 @@ sequenceDiagram
L-->>U: 进入设计端主界面
```
### 10.3 platform-vue 编辑地图站点 → SimpleLite 落库
### 10.3 platform-vue 编辑地图站点 → Simple3 落库
```mermaid
sequenceDiagram
participant V as platform-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (WebAPI)
participant L as Simple3 (WebAPI)
participant DBl as simple_main.db
participant RV as rcsmonitor-vue (订阅)
@@ -1406,7 +1406,7 @@ sequenceDiagram
P-->>V: 201 + siteId
L->>L: WS broadcast { type: "map.site.created", payload }
L-->>P: WS event
P-->>V: WS event (前端 Workspace3D iframe 收到 SimpleLite 推送的 PutModelObject 同步)
P-->>V: WS event (前端 Workspace3D iframe 收到 Simple3 推送的 PutModelObject 同步)
P-->>RV: WS event (RCSMonitor 接收并按权限展示)
```
@@ -1416,7 +1416,7 @@ sequenceDiagram
sequenceDiagram
participant V as platform-vue
participant P as Platform
participant L as SimpleLite
participant L as Simple3
participant E as DispatchLoop
participant RV as rcsmonitor-vue
@@ -1440,7 +1440,7 @@ sequenceDiagram
participant U as 运营人员
participant RV as rcsmonitor-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (Ops 网关)
participant L as Simple3 (Ops 网关)
participant E as DispatchLoop
participant DBp as platform.db
participant DBl as simple_main.db
@@ -1476,10 +1476,10 @@ sequenceDiagram
VIP->>NA: 路由到 Active
Note over NA: 节点故障
ROSE->>NA: 心跳超时 → 触发切换
ROSE->>NA: 强制停止 SimpleLite / Platform
ROSE->>NA: 强制停止 Simple3 / Platform
ROSE->>NA: 卸载 R: 卷
ROSE->>NB: 挂载 R: 卷
ROSE->>NB: 启动 SimpleLite (WAL 重放 + 主库自检)
ROSE->>NB: 启动 Simple3 (WAL 重放 + 主库自检)
ROSE->>NB: 启动 MiGu.Server
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
CLI--xVIP: 短暂中断
@@ -1495,12 +1495,12 @@ sequenceDiagram
| 通道 | 协议 | 编码 | 端点 | 用途 |
|------|------|------|------|------|
| MiGu.Server (YARP) ↔ SimpleLite | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| MiGu.Server (YARP) ↔ Simple3 | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| platform-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/admin` | UI 操作、事件推送 |
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor``scope=RCSMonitor`) | UI 操作、事件推送(受限) |
| Vue Workspace3D ↔ SimpleLite WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| Vue Workspace3D ↔ Simple3 WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
| Vehicle ↔ SimpleLite | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| Vehicle ↔ Simple3 | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
@@ -1510,7 +1510,7 @@ sequenceDiagram
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
- 幂等:写操作支持 `Idempotency-Key` 请求头
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
- SwaggerPlatform / SimpleLite 都暴露 `/swagger`(生产可关)
- SwaggerPlatform / Simple3 都暴露 `/swagger`(生产可关)
- **YARP Scope 白名单**`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*``/api/sl/projection/*` 命中;其他路径直接 403
---
@@ -1521,12 +1521,12 @@ sequenceDiagram
flowchart TB
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
subgraph NA["NODE-A (Active)"]
SL_A["SimpleLite.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
SL_A["Simple3.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
PL_A["MiGu.Server.exe<br/>:8080/8081<br/>(YARP + 双 Vue dist)"]
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
end
subgraph NB["NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
end
@@ -1556,12 +1556,12 @@ flowchart TB
| 端口 | 服务 | 启用条件 |
|------|------|----------|
| 7001 | SimpleLite WebAPI | Web-Enabled |
| 7002 | SimpleLite WebSocket | Web-Enabled |
| 7001 | Simple3 WebAPI | Web-Enabled |
| 7002 | Simple3 WebSocket | Web-Enabled |
| 8080 | Platform WebAPI + platform-vue 静态(/admin+ rcsmonitor-vue 静态(/monitor | Web-Enabled |
| 8081 | Platform WebSocket | Web-Enabled |
| 8222 | SimpleLite 车端 / API(现有) | 所有模式 |
| 8223 | SimpleLite WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
| 8223 | Simple3 WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
@@ -1588,7 +1588,7 @@ flowchart LR
SHH["SimpleShared.WebApiHost"]
end
subgraph apps["两端后端"]
SL["SimpleLite (CycleGUI + WebAPI)"]
SL["Simple3 (CycleGUI + WebAPI)"]
PSrv["MiGu.Server (WebAPI + YARP)"]
end
subgraph frontends["前端 (pnpm workspace)"]
@@ -1709,7 +1709,7 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|--------|----------|----------|
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProviderEF Core + SQLite + 迁移 |
| **M1 - 启动登录 + 运行模式** | 1 周 | `BootLoginPanel``RunMode` 枚举;CycleGUI 状态窗;`simple.json.runMode` 持久化;Web-Enabled 业务面板硬隐藏 |
| **M2 - WebAPI 协议层** | 1.5 周 | SimpleLite 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M2 - WebAPI 协议层** | 1.5 周 | Simple3 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`Workspace3D(嵌入 webVRender+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.ServerWebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist+ 配置中心 11 个 schema + 用户/角色/控件权限 |
| **M5 - platform-vue (管理员)** | 2.5 周 | 全功能套壳:登录、地图设计、车辆/任务、CAD、字段/图层、配置中心、库位、自定义控件、回放、设备管理、车队生命周期、场景模板 |
@@ -1726,14 +1726,14 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|------|------|
| 1 | 运行模式持久化 | **写入 `simple.json.runMode` / `rememberRunMode`**(不使用 %APPDATA% |
| 2 | Web-Enabled 下是否允许同时本地操作 | **不允许**CycleGUI 仅状态窗;不提供"临时启用本地 UI"开关 |
| 3 | 3D 引擎 | **复用 SimpleLite WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 3 | 3D 引擎 | **复用 Simple3 WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 4 | 反向代理 | **YARP**MiGu.Server 用 YARP 反代 `/api/sl/*` |
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 SimpleLite |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 Simple3 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 Simple3 |
| 11 | **持久层多 Providerv1.5** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF MigrationsCI 全矩阵跑通;运行期由 `simple.json.database.provider` 决定 |
| 12 | **跨 Provider 切换策略** | 不支持热切;切换走停服窗口 + `tools/db/migrate.ps1`;旧文件作为回退包保留 |
@@ -1743,22 +1743,22 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
| 现有文件 | 改动方向 |
|----------|----------|
| `SimpleLite/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `SimpleLite/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `SimpleLite/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `SimpleLite/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `SimpleLite/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `SimpleLite/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `SimpleLite/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `SimpleLite/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `SimpleLite/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `SimpleLite/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `SimpleLite/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `SimpleLite/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `SimpleLite/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `SimpleLite/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `Simple3/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `Simple3/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `Simple3/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `Simple3/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `Simple3/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `Simple3/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `Simple3/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `Simple3/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `Simple3/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `Simple3/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `Simple3/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `Simple3/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `Simple3/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `Simple3/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `SimpleScheduler/WebApi.cs` | 调用方从 Nancy 切到 ASP.NET Core Controller;保持 `SchedulerRuntime` 外观稳定 |
| `SimpleLite/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Simple3/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Plugins/Sample` | 示例插件读取 `IConfigSnapshotProvider`;不再读 `Configuration.conf` |
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
| 新增 `tools/db/*` | 跨 Provider 迁移脚本(`migrate.ps1` / `dump.ps1` / `restore.ps1`);按目标 DB 校验 schema 与索引 |
@@ -1909,12 +1909,12 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|-----|------|------|
| B1 | Kestrel + Swagger + CORS | ✅ | `Program.cs` |
| B2 | Auth / Health / Config / Ops / Projection 控制器 | ✅ 骨架 | Mock/内存存储 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 SimpleLite 实际监听 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 Simple3 实际监听 |
| B4 | 静态托管 + SPA fallback | ✅ | 单一 `wwwroot/index.html` |
| B5 | JWT 真签发 + 库持久化 | ☐ | 仍为 Mock |
| B6 | 父进程 SimpleLite 校验 | ☐ | §4、§15 #10 |
| B6 | 父进程 Simple3 校验 | ☐ | §4、§15 #10 |
### 18.3 SimpleLite / 共享内核(ARCHITECTURE 主体)
### 18.3 Simple3 / 共享内核(ARCHITECTURE 主体)
| # | 项 | 状态 | 备注 |
|---|-----|------|------|
@@ -1942,5 +1942,5 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
## 关联文档
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — SimpleLite 功能对齐执行计划
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — Simple3 功能对齐执行计划
- [SIMPLELITE_COMPOSER_PARITY_PLAN.md](./SIMPLELITE_COMPOSER_PARITY_PLAN.md) — 与 SimpleComposer 差距分析
+19 -19
View File
@@ -1,19 +1,19 @@
# SimpleLite 面向咪咕平台数据 API 文档
# Simple3 面向咪咕平台数据 API 文档
> **版本**:与 SimpleLite 源码同步
> **版本**:与 Simple3 源码同步
> **更新日期**2026-06-18
> **服务栈**EmbedIO WebApi(非 ASP.NET Core
> **OpenAPI**`Docs/openapi/simplelite-projection.json`(已在 MiGu.Server Swagger UI 中展示)
> **OpenAPI**`Docs/openapi/simple3-projection.json`(已在 MiGu.Server Swagger UI 中展示)
---
## 1. 架构与访问路径
SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 SimpleLite
Simple3 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 Simple3
| 层级 | 前缀 | 说明 |
|------|------|------|
| 直连 SimpleLite | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 直连 Simple3 | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 |
```
@@ -23,7 +23,7 @@ SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1
MiGu.Server :8080 (/api/sl/*)
│ 追加 X-Platform-Internal-Token
SimpleLite :8222 (/projection/*)
Simple3 :8222 (/projection/*)
```
### 1.1 鉴权
@@ -31,12 +31,12 @@ SimpleLite :8222 (/projection/*)
| 场景 | 要求 |
|------|------|
| 经 MiGu.Server 访问 | 需登录 JWT`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` |
| 直连 SimpleLite | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
| 直连 Simple3 | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
Token 解析优先级(SimpleLite 侧):
Token 解析优先级(Simple3 侧):
1. `simple.json``platform.internalToken`
2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN`
2. 环境变量 `SIMPLE3__PLATFORM__INTERNALTOKEN`
3. `Platform.Server/data/.internal-token``MiGu.Server/data/.internal-token`
### 1.2 通用响应格式
@@ -60,8 +60,8 @@ Token 解析优先级(SimpleLite 侧):
开发环境下启动 MiGu.Server 后访问:
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi
- SimpleLite 相关接口标签前缀为 `SimpleLite/`
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + Simple3 全部 WebApi
- Simple3 相关接口标签前缀为 `Simple3/`
---
@@ -112,7 +112,7 @@ Token 解析优先级(SimpleLite 侧):
{
"id": "C01",
"name": "AGV-1",
"typeName": "SimpleLite.RCS.CarTypes.Car",
"typeName": "Simple3.RCS.CarTypes.Car",
"rawId": 1,
"x": 1200.5,
"y": 800.0,
@@ -285,7 +285,7 @@ Token 解析优先级(SimpleLite 侧):
同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields``IOArea``Speed` 等基类字段)。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("SimpleLite")` 向 SimpleLite 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("Simple3")` 向 Simple3 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
---
@@ -467,7 +467,7 @@ Token 解析优先级(SimpleLite 侧):
咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`
| 文件 | 对应 SimpleLite 前缀 |
| 文件 | 对应 Simple3 前缀 |
|------|----------------------|
| `reflection.ts` | `/api/sl/projection/reflection` |
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
@@ -480,16 +480,16 @@ Token 解析优先级(SimpleLite 侧):
| 服务 | 默认端口 | 路径 |
|------|----------|------|
| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite |
| SimpleLite 投影 API | 8222 | `/projection/*` |
| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
| MiGu.Server | 8080 | `/api/sl/*` → Simple3 |
| Simple3 投影 API | 8222 | `/projection/*` |
| Simple3 webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
**健康检查**`GET http://localhost:8080/api/health/simplelite`
**健康检查**`GET http://localhost:8080/api/health/simple3`
**本地联调**
```powershell
# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起)
# 1. 启动 Simple3(或登录 MiGu.Server 后自动拉起)
# 2. 启动 MiGu.Server
# 3. 浏览器打开 http://localhost:8080/swagger
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
+66 -66
View File
@@ -6,10 +6,10 @@
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 Simple3 子进程拉起。
- 平台前端:`frontends/apps/simple-platform-vue`Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`
调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。
调度内核 `Simple3` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/Simple3`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `Simple3` 协作。
## 2. 技术栈总览
@@ -21,7 +21,7 @@
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
- 代理:YARP Reverse Proxy。
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`
- 进程编排:`Simple3Launcher` 拉起或复用 `Simple3.exe`
### 前端
@@ -34,9 +34,9 @@
### 外部/相邻系统
- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- `Simple3.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
- Simple3 Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
## 3. 顶层目录职责
@@ -64,33 +64,33 @@ flowchart LR
Server --> Auth["Auth/RBAC/JWT"]
Server --> Config["ConfigStore<br/>data/config-*.json"]
Server --> Wizard["部署向导<br/>deployment profile"]
Server --> Launcher["SimpleLiteLauncher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"]
Server --> Launcher["Simple3Launcher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["Simple3.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["Simple3 :8222"]
Server -->|YARP /vr/*| VR["webVRender :8223"]
Vue -->|iframe 或 /vr 代理| VR
```
关键说明:
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local``--display-mode=web`
- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite
- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 Simple3
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 Simple3 命令行 `--display-mode=web+local``--display-mode=web`
- `Simple3Launcher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 Simple3
- 如果 `8222` 已经有 Simple3 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
## 5. 文档蓝图与当前代码的差异
仓库中存在两类叙述:
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `Simple3`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“Simple3 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
当前代码落地状态:
- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
- 已落地:`MiGu.Server` 主入口、登录拉起 Simple3、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、Simple3 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 Simple3 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
## 6. 后端模块
@@ -101,14 +101,14 @@ flowchart LR
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`
- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、SimpleLiteLauncher。
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、Simple3Launcher。
- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。
- 托管静态资源和 SPA fallback。
核心关系:
- 调用 `JwtIssuer` 颁发/验签 JWT。
- 调用 `InternalTokenStore` 管理转发给 SimpleLite`X-Platform-Internal-Token`
- 调用 `InternalTokenStore` 管理转发给 Simple3`X-Platform-Internal-Token`
- YARP `sl-route` 会自动注入内部 token。
- `app.MapControllers()` 处理平台自身 API`app.MapReverseProxy()` 处理 `/api/sl/*``/vr/*`
@@ -116,7 +116,7 @@ flowchart LR
职责:
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 SimpleLite,签发 JWT 和 Cookie。
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 Simple3,签发 JWT 和 Cookie。
- `POST /api/auth/logout`:清理 Cookie。
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
- `POST /api/auth/switch-scope`:同一用户在 `Platform``RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
@@ -125,7 +125,7 @@ flowchart LR
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
- `JwtIssuer`:签发 JWT。
- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite
- `Simple3Launcher`:登录后拉起或复用 Simple3
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
### 6.3 RBAC 权限模块:`Auth/`
@@ -136,7 +136,7 @@ flowchart LR
- `RbacModels.cs`:用户、角色、DTO。
- `PageCatalog.cs`:页面权限目录。
- `JwtIssuer.cs`JWT 颁发和验签参数。
- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。
- `InternalTokenStore.cs`:平台与 Simple3 内部通信 token。
职责:
@@ -182,32 +182,32 @@ flowchart LR
- 前端 `useConfigStore` 统一读写这些 section。
- 许多配置页只是不同 section 的编辑视图。
- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。
- `deployment` section 还会参与菜单裁剪与 Simple3 插件选择。
注意:
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
### 6.5 SimpleLite 拉起与诊断:`Launcher/`
### 6.5 Simple3 拉起与诊断:`Launcher/`
主要文件:
- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`
- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。
- `Simple3Launcher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `Simple3Options.cs`:绑定 `appsettings.json:Simple3`
- `Simple3BuildSync.cs`:辅助同步/探测 Simple3 构建与 API。
职责:
- 将业务启动模式翻译为 SimpleLite 内部 display mode
- 将业务启动模式翻译为 Simple3 内部 display mode
- `WebOnly` -> `--display-mode=web`
- `DesktopAndWeb` -> `--display-mode=web+local`
- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite
- 拉起前检查 `8222` 是否已有 Simple3,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `Simple3.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 Simple3
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite
- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 Simple3
- `GET /api/health/simple3` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
@@ -217,7 +217,7 @@ flowchart LR
- 二次校验 JWT 的 `ops` claim。
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
- `monitor.note.write` 仅写审计。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 Simple3 反射 API。
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
默认白名单:
@@ -229,9 +229,9 @@ flowchart LR
重要设计:
- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。
- 真实下发路径为 SimpleLite 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
- 真实下发路径为 Simple3 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
### 6.7 投影与 SimpleLite 代理
### 6.7 投影与 Simple3 代理
模块组成:
@@ -249,8 +249,8 @@ flowchart LR
职责:
- `GET /api/health`:平台后端健康检查,返回启动时间、运行时长、端口规划。
- `GET /api/health/simplelite`SimpleLite 路径和端口诊断。
- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。
- `GET /api/health/simple3`Simple3 路径和端口诊断。
- `POST /api/health/simple3/restart-for-update`:授权用户可触发关闭 Simple3、同步最新 DLL 并重新拉起。
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
@@ -258,7 +258,7 @@ flowchart LR
- 首次部署时收集导航方式、功能模块、业务场景。
- 保存后写入 `deployment` 配置 section。
- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 将导航方式映射为 Simple3 场景插件 ID,并调用 `Simple3Launcher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`
对外 API
@@ -273,7 +273,7 @@ flowchart LR
职责:
- 读取 SimpleLite 工作目录下的 `log/` 文件。
- 读取 Simple3 工作目录下的 `log/` 文件。
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
@@ -290,14 +290,14 @@ flowchart LR
职责:
- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。
- 先调用 Simple3 `/projection/map-edit/maps` 获取地图目录。
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
关系:
- 依赖 SimpleLite 与 MiGu.Server 同机部署。
- 依赖 `SimpleLiteOptions.ProjectionPort``InternalTokenStore`
- 依赖 Simple3 与 MiGu.Server 同机部署。
- 依赖 `Simple3Options.ProjectionPort``InternalTokenStore`
## 7. 前端模块
@@ -328,7 +328,7 @@ flowchart LR
职责:
- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。
- 登录页采集用户名、密码、scope 和 Simple3 启动模式。
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
@@ -346,7 +346,7 @@ flowchart LR
- `api/config.ts`:配置中心读写。
- `api/projection.ts`:站点、路径、车辆、任务投影。
- `api/ops.ts`:运维白名单执行和审计。
- `api/reflection.ts`SimpleLite 反射对象、字段、方法执行。
- `api/reflection.ts`Simple3 反射对象、字段、方法执行。
- `api/mapEdit.ts`:地图编辑相关 API。
- `api/wizard.ts`:部署向导。
- `api/logs.ts`:日志管理。
@@ -354,7 +354,7 @@ flowchart LR
关系:
- 所有平台 API 都以 `/api` 为 baseURL。
- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite
- `/api/sl/*` 由 MiGu.Server 反代给 Simple3
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
### 7.4 Shell、主题和权限控制组件
@@ -418,7 +418,7 @@ flowchart LR
- `useHistory`:撤销/重做命令栈。
- `useSelection`:对象选择状态。
- `useAlignment``useBatchGenerate``useClipboard`:编辑辅助能力。
- `mapEditApi``reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。
- `mapEditApi``reflectionApi`:与 Simple3 的地图编辑和反射 API 通信。
#### 地图监控
@@ -431,7 +431,7 @@ flowchart LR
- `VehicleMonitorPanel`:车辆监控台。
- `MissionListPanel`:任务列表。
- `MonitorSelectionPanel`:选中对象详情和动作。
- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。
- `useProjectionStream`:订阅 Simple3/平台 SSE 事件。
### 7.7 运营端页面:`/monitor/*`
@@ -469,13 +469,13 @@ flowchart LR
- 选择导航方式:磁导航、二维码导航、激光导航。
- 选择功能模块:当前包含 WMS、PTL。
- 选择业务场景模板。
- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
- 预览将激活的 Simple3 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
保存后:
- 调用 `PUT /api/wizard/profile`
- 后端写入 `deployment` section。
- 后端写入 SimpleLite `plugins/active-scenes.json`
- 后端写入 Simple3 `plugins/active-scenes.json`
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
## 8. 数据与持久化
@@ -491,9 +491,9 @@ flowchart LR
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
### SimpleLite 侧文件
### Simple3 侧文件
MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
MiGu.Server 会直接或间接使用 Simple3 工作目录:
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
- `log/**/*.log`:日志管理模块读取并分析。
@@ -511,7 +511,7 @@ MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
- `/api/maps/{name}/content`:地图 JSON 预览。
- `/api/health/*`:健康检查。
### 平台后端到 SimpleLite
### 平台后端到 Simple3
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`
@@ -533,7 +533,7 @@ flowchart TB
RBAC["RbacStore / JwtIssuer"]
Config["ConfigStore"]
Wizard["WizardController"]
Launcher["SimpleLiteLauncher"]
Launcher["Simple3Launcher"]
Ops["OpsController"]
Logs["LogsController"]
Maps["MapsContentController"]
@@ -551,8 +551,8 @@ flowchart TB
WizardVue["WizardView.vue"]
end
subgraph SimpleLite["SimpleLite 相邻仓库"]
SLExe["SimpleLite.exe"]
subgraph Simple3["Simple3 相邻仓库"]
SLExe["Simple3.exe"]
Projection["Projection API :8222"]
VRender["webVRender :8223"]
LogsFile["log/**/*.log"]
@@ -591,14 +591,14 @@ flowchart TB
## 11. 典型业务链路
### 11.1 登录并启动 SimpleLite
### 11.1 登录并启动 Simple3
1. 用户打开 `/login`
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`
3. `AuthController``RbacStore.VerifyCredentials()` 校验密码。
4. `AuthController` 检查用户是否可使用请求的 scope。
5. `AuthController``SimpleLiteLauncher.MaybeStart()`
6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`
5. `AuthController``Simple3Launcher.MaybeStart()`
6. `Simple3Launcher` 复用或拉起 `Simple3.exe`
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
@@ -608,7 +608,7 @@ flowchart TB
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 SimpleLite
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 Simple3
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
### 11.3 运营端执行运维动作
@@ -618,7 +618,7 @@ flowchart TB
3. 用户确认后调用 `/api/sl/ops/execute`
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。
6. 已配置映射时调用 Simple3 反射 execute;未配置映射时返回未下发。
7. 审计落盘,前端显示执行结果。
### 11.4 首次部署配置向导
@@ -628,7 +628,7 @@ flowchart TB
3. 前端加载 `/api/wizard/options``/api/wizard/profile`
4. 用户选择导航方式、模块、场景。
5. 保存后端写 `deployment` section。
6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`
6. 后端将导航方式转换为场景插件 ID,并写 Simple3 `plugins/active-scenes.json`
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
## 12. 构建与运行
@@ -645,7 +645,7 @@ dotnet run
- 平台:`http://localhost:8080/login`
- 健康检查:`http://localhost:8080/api/health`
- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite`
- Simple3 诊断:`http://localhost:8080/api/health/simple3`
- Swagger:开发环境 `/swagger`
### 前端开发
@@ -679,7 +679,7 @@ pnpm dev
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simplelite` 的真实数据。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simple3` 的真实数据。
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
@@ -687,8 +687,8 @@ pnpm dev
## 14. 快速索引
- 后端启动:`MiGu.Server/Program.cs`
- 登录和启动 SimpleLite`MiGu.Server/Controllers/AuthController.cs`
- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs`
- 登录和启动 Simple3`MiGu.Server/Controllers/AuthController.cs`
- Simple3 拉起器:`MiGu.Server/Launcher/Simple3Launcher.cs`
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
- 权限 API`MiGu.Server/Controllers/RbacController.cs`
@@ -1,9 +1,8 @@
namespace MiGu.Server.Fleet;
namespace MiGu.DB.Domains.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// 以任务 Id 为主键;Simple3/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史
/// </summary>
public sealed class CdmTaskRecord
{
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MiGu.DB.Domains.Fleet;
public sealed class CdmTaskRecordConfiguration : IEntityTypeConfiguration<CdmTaskRecord>
{
public void Configure(EntityTypeBuilder<CdmTaskRecord> e)
{
e.ToTable("cdm_tasks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64);
e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false);
e.Property(x => x.MissionId).HasColumnName("mission_id");
e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128);
e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128);
e.Property(x => x.SrcSiteId).HasColumnName("src_site_id");
e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256);
e.Property(x => x.DstSiteId).HasColumnName("dst_site_id");
e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256);
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32);
e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32);
e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false);
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false);
e.Property(x => x.Priority).HasColumnName("priority");
e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false);
e.Property(x => x.Overdue).HasColumnName("overdue");
e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at");
e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at");
e.HasIndex(x => x.StatusCode);
e.HasIndex(x => x.CreateTime);
}
}
public sealed class VehicleAlarmRecordConfiguration : IEntityTypeConfiguration<VehicleAlarmRecord>
{
public void Configure(EntityTypeBuilder<VehicleAlarmRecord> e)
{
e.ToTable("vehicle_alarms");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36);
e.Property(x => x.CarId).HasColumnName("car_id");
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128);
e.Property(x => x.Info).HasColumnName("info").HasColumnType("text");
e.Property(x => x.Level).HasColumnName("level");
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16);
e.Property(x => x.FirstAt).HasColumnName("first_at");
e.Property(x => x.LastAt).HasColumnName("last_at");
e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false);
e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false);
e.Property(x => x.Acknowledged).HasColumnName("acknowledged");
e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false);
e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false);
e.HasIndex(x => new { x.CarId, x.Status });
e.HasIndex(x => x.Status);
e.HasIndex(x => x.FirstAt);
}
}
@@ -1,10 +1,8 @@
namespace MiGu.Server.Fleet;
namespace MiGu.DB.Domains.Fleet;
/// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// 出现报警→开一条 active 记录;文案/级别变化→先 clear 再建新 active;消失→cleared。
/// </summary>
public sealed class VehicleAlarmRecord
{
+4 -23
View File
@@ -29,35 +29,16 @@ public sealed class SimpleFieldsCarTypeBackfillMigrator : IDataMigrator
}
}
/// <summary>
/// 把库内旧状态字符串刷成规范枚举名(Available/Idle→EmptyOccupied→FullContainer)。
/// 仅 Sqlite 表名/列名;绕过枚举 HasConversionExecuteUpdate + 字符串比较会 InvalidCast)。
/// </summary>
public sealed class LegacyStatusNormalizationMigrator : IDataMigrator
/// <summary>库区 LayoutMode 空值补 Flatraw UPDATE,避开枚举 converter)。</summary>
public sealed class AreaLayoutModeDefaultMigrator : IDataMigrator
{
public int Order => 15;
public string Name => "Wms.LegacyStatusNormalization";
public string Name => "Wms.AreaLayoutModeDefault";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext) return;
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'Empty'
WHERE Status IN ('Available', 'Idle')
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'FullContainer'
WHERE Status = 'Occupied'
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_areas
@@ -98,7 +79,7 @@ public static class DataMigratorRegistration
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
{
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
services.AddSingleton<IDataMigrator, LegacyStatusNormalizationMigrator>();
services.AddSingleton<IDataMigrator, AreaLayoutModeDefaultMigrator>();
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
return services;
}
@@ -96,9 +96,3 @@ public static class WmsReservationStatuses
public const WmsReservationStatus Released = WmsReservationStatus.Released;
public const WmsReservationStatus Expired = WmsReservationStatus.Expired;
}
public static class WmsDispatchStatuses
{
public const WmsDispatchStatus Dispatched = WmsDispatchStatus.Dispatched;
public const WmsDispatchStatus Failed = WmsDispatchStatus.Failed;
}
@@ -27,9 +27,3 @@ public enum WmsReservationStatus
Released,
Expired
}
public enum WmsDispatchStatus
{
Dispatched,
Failed
}
-1
View File
@@ -2,7 +2,6 @@ namespace MiGu.DB.Domains.Wms;
/// <summary>
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion&lt;string&gt;),成员名即库内合法值。
/// 旧别名(Available/Idle/Occupied 等)不进入枚举,由 LegacyStatusNormalizationMigrator 刷库。
/// </summary>
public enum AreaLayoutMode
{
+2 -11
View File
@@ -33,17 +33,8 @@ public static class StorageStatuses
public static readonly HashSet<StorageStatus> All = new()
{ Empty, EmptyContainer, FullContainer, Disabled };
/// <summary>
/// 写路径归一:兼容历史字符串 Available/Idle/Occupied。
/// 库内残留旧值须靠 DataMigrator 刷掉,不能依赖 converter 读侧归一(WHERE 会漏行)。
/// </summary>
public static StorageStatus Normalize(string? status) => status switch
{
"Available" or "Idle" => Empty,
"Occupied" => FullContainer,
_ when Enum.TryParse<StorageStatus>(status, true, out var e) && All.Contains(e) => e,
_ => Empty
};
public static StorageStatus ParseOr(string? value, StorageStatus fallback = StorageStatus.Empty) =>
Enum.TryParse<StorageStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class ContainerStatuses
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.Fleet;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms;
@@ -25,4 +26,6 @@ public partial class MiGuDbContext
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
public DbSet<CdmTaskRecord> CdmTasks => Set<CdmTaskRecord>();
public DbSet<VehicleAlarmRecord> VehicleAlarms => Set<VehicleAlarmRecord>();
}
+3 -3
View File
@@ -94,7 +94,7 @@ dotnet ef migrations add <迁移名称> `
### 3.4 应用到本地库
启动 `MiGu.Server` 即可(`EnsurePlatformDatabaseAsync` `MigrateMiGuDbAsync`),或:
启动 `MiGu.Server` 即可(`Program.cs` 直接调用 `MigrateMiGuDbAsync`),或:
```powershell
dotnet ef database update `
@@ -151,7 +151,7 @@ dotnet ef database update <目标迁移名> `
**[MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)**
摘要:`EnsurePlatformDatabaseAsync``MigrateMiGuDbAsync``Database:SchemaMode``EnsureCreated``Migrate`;之后按需跑 `IDataMigrator`
摘要:`MigrateMiGuDbAsync``Database:SchemaMode``EnsureCreated``Migrate`;之后按需跑 `IDataMigrator`
**Migrate 基线:** 已有表、无 History 时,会把**全部** pending Migration 写入 `__EFMigrationsHistory`(假定 EnsureCreated 库已对齐模型 tip)。发版前若开发期改过模型,须先 `migrations add` 再切 `Migrate`
@@ -165,7 +165,7 @@ dotnet ef database update <目标迁移名> `
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
| 开发机整库清空重来 | 删 `data/platform.db*` 后启动(等同空库 Migrate);**不要**在生产用 EnsureDeleted |
注意:带 `HasConversion` 的枚举列,用 `ExecuteUpdate` + 原始字符串比较可能触发转换异常;旧值刷库需绕过 converter(参见 `LegacyStatusNormalizationMigrator`)。
注意:带 `HasConversion` 的枚举列,用 `ExecuteUpdate` + 原始字符串比较可能触发转换异常;空 LayoutMode 等特例用 raw UPDATE(参见 `AreaLayoutModeDefaultMigrator`)。
---
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiGu.DB.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AddFleetTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cdm_tasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
task_id = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true),
mission_id = table.Column<int>(type: "INTEGER", nullable: false),
mission_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
mission_type = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
src_site_id = table.Column<int>(type: "INTEGER", nullable: false),
src_label = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
dst_site_id = table.Column<int>(type: "INTEGER", nullable: false),
dst_label = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
status_code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
car_id = table.Column<int>(type: "INTEGER", nullable: true),
car_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true),
priority = table.Column<int>(type: "INTEGER", nullable: false),
create_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
start_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
finish_time = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
stuck_reason = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
overdue = table.Column<bool>(type: "INTEGER", nullable: false),
first_seen_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
last_seen_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_cdm_tasks", x => x.id);
});
migrationBuilder.CreateTable(
name: "vehicle_alarms",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
car_id = table.Column<int>(type: "INTEGER", nullable: false),
car_name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
info = table.Column<string>(type: "text", nullable: false),
level = table.Column<int>(type: "INTEGER", nullable: false),
status = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
first_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
last_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
resolved_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
duration_secs = table.Column<long>(type: "INTEGER", nullable: true),
acknowledged = table.Column<bool>(type: "INTEGER", nullable: false),
acknowledged_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
acknowledged_by = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_vehicle_alarms", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_cdm_tasks_create_time",
table: "cdm_tasks",
column: "create_time");
migrationBuilder.CreateIndex(
name: "IX_cdm_tasks_status_code",
table: "cdm_tasks",
column: "status_code");
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_car_id_status",
table: "vehicle_alarms",
columns: new[] { "car_id", "status" });
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_first_at",
table: "vehicle_alarms",
column: "first_at");
migrationBuilder.CreateIndex(
name: "IX_vehicle_alarms_status",
table: "vehicle_alarms",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cdm_tasks");
migrationBuilder.DropTable(
name: "vehicle_alarms");
}
}
}
@@ -45,6 +45,202 @@ namespace MiGu.DB.Migrations.Sqlite
b.ToTable("user_dashboard_shortcuts", (string)null);
});
modelBuilder.Entity("MiGu.DB.Domains.Fleet.CdmTaskRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CarId")
.HasColumnType("INTEGER")
.HasColumnName("car_id");
b.Property<string>("CarName")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("car_name");
b.Property<string>("CreateTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("create_time");
b.Property<string>("DstLabel")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT")
.HasColumnName("dst_label");
b.Property<int>("DstSiteId")
.HasColumnType("INTEGER")
.HasColumnName("dst_site_id");
b.Property<string>("FinishTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("finish_time");
b.Property<string>("FirstSeenAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("first_seen_at");
b.Property<string>("LastSeenAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("last_seen_at");
b.Property<int>("MissionId")
.HasColumnType("INTEGER")
.HasColumnName("mission_id");
b.Property<string>("MissionName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("mission_name");
b.Property<string>("MissionTypeName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("mission_type");
b.Property<bool>("Overdue")
.HasColumnType("INTEGER")
.HasColumnName("overdue");
b.Property<int>("Priority")
.HasColumnType("INTEGER")
.HasColumnName("priority");
b.Property<string>("SrcLabel")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT")
.HasColumnName("src_label");
b.Property<int>("SrcSiteId")
.HasColumnType("INTEGER")
.HasColumnName("src_site_id");
b.Property<string>("StartTime")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("start_time");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("StatusCode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("status_code");
b.Property<string>("StuckReason")
.HasMaxLength(512)
.HasColumnType("TEXT")
.HasColumnName("stuck_reason");
b.Property<string>("TaskId")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("CreateTime");
b.HasIndex("StatusCode");
b.ToTable("cdm_tasks", (string)null);
});
modelBuilder.Entity("MiGu.DB.Domains.Fleet.VehicleAlarmRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(36)
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<string>("AcknowledgedAt")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("acknowledged_by");
b.Property<int>("CarId")
.HasColumnType("INTEGER")
.HasColumnName("car_id");
b.Property<string>("CarName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("car_name");
b.Property<long?>("DurationSecs")
.HasColumnType("INTEGER")
.HasColumnName("duration_secs");
b.Property<string>("FirstAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("first_at");
b.Property<string>("Info")
.IsRequired()
.HasColumnType("text")
.HasColumnName("info");
b.Property<string>("LastAt")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("last_at");
b.Property<int>("Level")
.HasColumnType("INTEGER")
.HasColumnName("level");
b.Property<string>("ResolvedAt")
.HasMaxLength(40)
.HasColumnType("TEXT")
.HasColumnName("resolved_at");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("FirstAt");
b.HasIndex("Status");
b.HasIndex("CarId", "Status");
b.ToTable("vehicle_alarms", (string)null);
});
modelBuilder.Entity("MiGu.DB.Domains.SimpleFields.SimpleField", b =>
{
b.Property<string>("Id")
+1 -2
View File
@@ -17,7 +17,7 @@
```csharp
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
await app.Services.EnsurePlatformDatabaseAsync(); // 内部 MigrateMiGuDbAsync
await app.Services.MigrateMiGuDbAsync();
```
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
@@ -27,7 +27,6 @@ await app.Services.EnsurePlatformDatabaseAsync(); // 内部 Migrat
## 实体与枚举
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
- 旧库值(如 `Available`/`Idle`)由 `LegacyStatusNormalizationMigrator` 一次性刷成规范名。
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`
- 复数辅助类(`StorageStatuses``LocationKinds`…)负责 API 字符串解析与集合校验。
+12 -12
View File
@@ -3,24 +3,24 @@
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
/// 设计动机:Simple3 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
/// (减少 Simple3 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <b>本机回环</b>127.0.0.1 / ::1Simple3 直接放行(开发机直连不受影响);
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 Simple3 8222 时,YARP transform
/// 会自动追加该 header
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
/// - Simple3 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLE3__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 读取(同机部署常见场景)
/// 文件供本机 Simple3 读取(同机部署常见场景)
/// </summary>
public sealed class InternalTokenStore
{
@@ -39,7 +39,7 @@ public sealed class InternalTokenStore
return;
}
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 Simple3 读取
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
var file = Path.Combine(dataDir, ".internal-token");
@@ -71,12 +71,12 @@ public sealed class InternalTokenStore
PersistedFilePath = file;
logger.LogWarning(
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
"若 MiGu.Server 与 Simple3 不在同一台机器,请把同一个值写入 Simple3 端 simple.json:platform.internalToken。",
file);
}
catch (Exception ex)
{
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
logger.LogError(ex, "internal token 落盘失败 —— 远程 Simple3 调用将无法通过鉴权(每次重启 token 都不同)。");
}
}
}
+134 -18
View File
@@ -29,9 +29,14 @@ public static class PageCatalog
{
// ── 管理端 / Platform:概览 ──
new("admin-dashboard", "总览", "概览", ScopePlatform),
new("admin-setup", "初始配置", "概览", ScopePlatform),
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
new("admin-playback", "调度回放", "概览", ScopePlatform),
new("admin-tasks", "任务管理", "概览", ScopePlatform),
new("admin-alarms", "报警管理", "概览", ScopePlatform),
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
new("admin-notes", "运维备注", "概览", ScopePlatform),
new("admin-logs", "日志管理", "概览", ScopePlatform),
// ── 管理端 / Platform:设计与编排 ──
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
@@ -42,23 +47,25 @@ public static class PageCatalog
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
// ── 运营端 / RCSMonitor ──
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
};
private static readonly HashSet<string> _keys =
@@ -71,23 +78,79 @@ public static class PageCatalog
["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-warehouse",
["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-logs"] = "admin-logs",
["admin-config-map-monitor"] = "admin-config-ops-center",
["admin-config-system"] = "admin-config-system-center",
["admin-config-auth"] = "admin-config-system-center",
["admin-config-system-center"] = "admin-config-system",
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
["monitor-dashboard"] = "monitor-map",
["admin-data-center-stations"] = "admin-data-center",
["admin-data-center-docks"] = "admin-data-center",
["admin-data-center-handshake"] = "admin-data-center",
["admin-data-center-release"] = "admin-data-center",
["admin-data-center-mag-control"] = "admin-data-center",
};
/// <summary>
/// 管理端页面 → 运营端对应页面。给运营角色勾页时,只能从「当前管理员已有的管理端页」映射出来。
/// 无对应运营页的管理端入口(如任务/报警)不出现在映射里。
/// </summary>
public static readonly IReadOnlyDictionary<string, string> PlatformToMonitor =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["admin-map-monitor"] = "monitor-map",
["admin-vehicle-hub"] = "monitor-vehicle-hub",
["admin-ops-log"] = "monitor-ops",
["admin-notes"] = "monitor-notes",
};
/// <summary>仅运营端有的页面:不依赖管理端对应页,始终可授给运营角色。</summary>
public static readonly IReadOnlyList<string> MonitorOnlyPages =
new[] { "monitor-ops", "monitor-notes" };
/// <summary>
/// 把角色里勾选的页面展开到指定登录域:勾了运营监控的「运维记录」时,
/// 管理账号/通用角色进管理壳也能拿到对应的 admin-ops-log。
/// </summary>
public static HashSet<string> ExpandKeysForScope(IEnumerable<string> keys, string scope)
{
var scopeKeys = KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in keys ?? Array.Empty<string>())
{
if (string.Equals(raw, Wildcard, StringComparison.OrdinalIgnoreCase))
{
foreach (var k in scopeKeys) set.Add(k);
return set;
}
var key = NormalizeKey(raw);
if (scopeKeys.Contains(key)) set.Add(key);
var counterpart = CounterpartForScope(key, scope);
if (counterpart != null && scopeKeys.Contains(counterpart)) set.Add(counterpart);
}
return set;
}
/// <summary>管理页 ↔ 运营页互认:monitor-ops → admin-ops-log(当前域是 Platform 时)。</summary>
public static string? CounterpartForScope(string key, string targetScope)
{
if (string.Equals(targetScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
{
foreach (var kv in PlatformToMonitor)
{
if (string.Equals(kv.Value, key, StringComparison.OrdinalIgnoreCase))
return kv.Key;
}
return null;
}
if (string.Equals(targetScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase)
&& PlatformToMonitor.TryGetValue(key, out var monitorKey))
return monitorKey;
return null;
}
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
public static bool IsValidKey(string key) => _keys.Contains(key);
@@ -100,4 +163,57 @@ public static class PageCatalog
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Key)
.ToList();
/// <summary>当前管理员在管理端拥有的页面 → 可授给运营端角色的页面集合。</summary>
public static HashSet<string> GrantableMonitorPages(IEnumerable<string>? actorPlatformPages)
{
var set = new HashSet<string>(MonitorOnlyPages, StringComparer.OrdinalIgnoreCase);
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase)
|| HasAllKeys(list, KeysForScope(ScopePlatform)))
{
foreach (var k in KeysForScope(ScopeMonitor)) set.Add(k);
return set;
}
foreach (var p in list)
{
var key = NormalizeKey(p);
if (PlatformToMonitor.TryGetValue(key, out var monitorKey) && IsValidKey(monitorKey))
set.Add(monitorKey);
}
return set;
}
/// <summary>当前管理员可授给管理端角色的页面(不能超权:只能授自己已有的)。</summary>
public static HashSet<string> GrantablePlatformPages(IEnumerable<string>? actorPlatformPages)
{
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
var all = KeysForScope(ScopePlatform);
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || HasAllKeys(list, all))
return all.ToHashSet(StringComparer.OrdinalIgnoreCase);
return list
.Select(NormalizeKey)
.Where(k => IsValidKey(k) && all.Contains(k, StringComparer.OrdinalIgnoreCase))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
/// <summary>按角色归属域,算出当前管理员可勾选的页面 Key。</summary>
public static HashSet<string> GrantablePagesForRoleScope(
string roleScope, IEnumerable<string>? actorPlatformPages)
{
if (string.Equals(roleScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase))
return GrantableMonitorPages(actorPlatformPages);
if (string.Equals(roleScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
return GrantablePlatformPages(actorPlatformPages);
// 通用域:管理端可授 + 运营端可授
var set = GrantablePlatformPages(actorPlatformPages);
set.UnionWith(GrantableMonitorPages(actorPlatformPages));
return set;
}
private static bool HasAllKeys(IEnumerable<string> have, IEnumerable<string> all)
{
var set = have.ToHashSet(StringComparer.OrdinalIgnoreCase);
return all.All(k => set.Contains(k));
}
}
+71 -9
View File
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
///
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
/// admin(超级管理员,scope=*,全部页面 / 操作)
/// ops (运营人员,scope=RCSMonitor,运营页 + 运维操作码)
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
/// </summary>
public sealed class RbacStore
@@ -58,9 +58,14 @@ public sealed class RbacStore
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
if (snap is { Users.Count: > 0 })
{
_snapshot = Normalize(snap);
_snapshot = Normalize(snap, out var migrated);
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
if (migrated)
{
Persist();
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
}
return;
}
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
@@ -82,21 +87,30 @@ public sealed class RbacStore
}
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
private static RbacSnapshot Normalize(RbacSnapshot snap)
/// <param name="migrated">任一角色页面 Key 被别名替换 / 丢弃 / 补齐时为 true,调用方应落盘。</param>
private static RbacSnapshot Normalize(RbacSnapshot snap, out bool migrated)
{
migrated = false;
snap.Roles ??= new();
snap.Users ??= new();
foreach (var r in snap.Roles)
{
r.Pages = (r.Pages ?? new())
var before = r.Pages ?? new();
var hadSystemHub = before.Any(p =>
string.Equals(p, "admin-config-system-center", StringComparison.OrdinalIgnoreCase));
var after = before
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (!SamePages(before, after)) migrated = true;
r.Pages = after;
r.Ops = (r.Ops ?? new()).Distinct().ToList();
r.WidgetGrants ??= new();
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
BackfillKnownPageMigrations(r);
var countBeforeBackfill = r.Pages.Count;
BackfillKnownPageMigrations(r, hadSystemHub);
if (r.Pages.Count != countBeforeBackfill) migrated = true;
}
foreach (var u in snap.Users)
{
@@ -105,7 +119,12 @@ public sealed class RbacStore
return snap;
}
private static void BackfillKnownPageMigrations(RbacRole r)
private static bool SamePages(IReadOnlyList<string> a, IReadOnlyList<string> b) =>
a.Count == b.Count
&& !a.Except(b, StringComparer.OrdinalIgnoreCase).Any()
&& !b.Except(a, StringComparer.OrdinalIgnoreCase).Any();
private static void BackfillKnownPageMigrations(RbacRole r, bool hadSystemHub)
{
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& r.Scope != PageCatalog.Wildcard) return;
@@ -126,6 +145,31 @@ public sealed class RbacStore
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-simple-fields");
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& !r.Pages.Contains("admin-setup", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-setup");
// 日志管理、调度回放从监控配置聚合页拆出:原先有聚合页权限的角色补上新入口。
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& r.Pages.Contains("admin-config-ops-center", StringComparer.OrdinalIgnoreCase))
{
if (!r.Pages.Contains("admin-logs", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-logs");
if (!r.Pages.Contains("admin-playback", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-playback");
}
// 「系统与权限」拆成系统配置 / 权限与角色。
if (!r.Pages.Contains(PageCatalog.Wildcard) && hadSystemHub)
{
if (!r.Pages.Contains("admin-config-system", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-system");
if (!r.Pages.Contains("admin-config-auth", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-auth");
}
}
private RbacSnapshot SeedDefault(IConfiguration config)
@@ -153,7 +197,7 @@ public sealed class RbacStore
{
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
Scope = PageCatalog.ScopeMonitor,
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Pages = new() { "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Ops = new()
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
@@ -244,6 +288,20 @@ public sealed class RbacStore
public bool CanUseScope(RbacUser user, string scope) =>
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 登录按账号角色自动选域:能进管理端则进管理端,否则进运营权限页。
/// 不再接受客户端挑选「运营端 / 管理端」。
/// </summary>
public string? ResolveLoginScope(RbacUser user)
{
var scopes = UsableScopes(user);
if (scopes.Contains(PageCatalog.ScopePlatform, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopePlatform;
if (scopes.Contains(PageCatalog.ScopeMonitor, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopeMonitor;
return null;
}
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
public List<string> RoleNamesOf(RbacUser user)
{
@@ -270,7 +328,7 @@ public sealed class RbacStore
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);
else pages.UnionWith(PageCatalog.ExpandKeysForScope(r.Pages, scope));
foreach (var o in r.Ops)
{
@@ -480,7 +538,11 @@ public sealed class RbacStore
{
if (pages is null) return new();
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
return pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeScope(string? scope) => scope switch
+53 -1
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <summary>
/// 配置中心存储(内存 + JSON 文件持久化占位)。
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3
/// </summary>
public sealed class ConfigStore
{
@@ -99,6 +99,58 @@ public sealed class ConfigStore
return Put("deployment", el);
}
public LogCleanupOptions GetLogCleanup()
{
var env = Get("system");
return env.Payload switch
{
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
JsonElement el => ParseLogCleanup(el),
_ => LogCleanupOptions.CreateDefaults()
};
}
public Envelope PutLogCleanup(LogCleanupOptions options)
{
var opt = (options ?? new LogCleanupOptions()).Clamp();
Dictionary<string, JsonElement> dict;
try
{
var current = Get("system").Payload;
var el = current is JsonElement je
? je
: JsonSerializer.SerializeToElement(current, _jsonOpts);
dict = el.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
: new Dictionary<string, JsonElement>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
dict = new Dictionary<string, JsonElement>();
}
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
return Put("system", merged);
}
private LogCleanupOptions ParseLogCleanup(JsonElement el)
{
try
{
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
return LogCleanupOptions.CreateDefaults();
}
}
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
{
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
+21 -2
View File
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
/// <summary>
/// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入
/// <see cref="DeploymentProfile.SignalSceneId"/>,从而点亮「数据中心」。
/// </summary>
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</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);
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
return set;
}
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
/// <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);
var activeScenes = dp.ToLauncherSceneIds();
foreach (var (sceneId, pages) in SceneToPages)
{
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
foreach (var p in pages) set.Add(p);
}
return set;
}
+35 -3
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <para>它是「按选型裁剪」的单一事实来源:</para>
/// <list type="number">
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 Simple3 场景 id
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
/// </list>
@@ -35,7 +35,7 @@ public record DeploymentProfile(
UpdatedBy: "");
/// <summary>
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 导航方式 → Simple3 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
@@ -49,7 +49,7 @@ public record DeploymentProfile(
};
/// <summary>
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 把已选 <see cref="NavigationKinds"/> 映射为 Simple3 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
/// </summary>
public IReadOnlyList<string> ToActiveSceneIds()
@@ -64,4 +64,36 @@ public record DeploymentProfile(
}
return result;
}
/// <summary>信号交互场景 idPLC 握手)。仅 SPS / Pack 业务场景时加载。</summary>
public const string SignalSceneId = "scene.signal";
/// <summary>设备驱动场景 id(门/充电桩等,各类项目通用)。</summary>
public const string DeviceSceneId = "scene.device";
public const string ScenarioSps = "tpl-sps";
public const string ScenarioPack = "tpl-pack";
/// <summary>是否选了磁导航。</summary>
public bool UsesMagnetic() =>
NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>是否选了需要 scene.signal 的业务场景(SPS 物料配送 / 电池 Pack 产线)。</summary>
public bool UsesSignalPlugin() =>
Scenarios?.Any(s =>
string.Equals(s, ScenarioSps, StringComparison.OrdinalIgnoreCase)
|| string.Equals(s, ScenarioPack, StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>
/// 写入 active-scenes.json 的完整场景集合:导航插件 + SPS/Pack 时并入 scene.signal + scene.device。
/// </summary>
public IReadOnlyList<string> ToLauncherSceneIds()
{
var result = ToActiveSceneIds().ToList();
if (UsesSignalPlugin() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(SignalSceneId);
if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(DeviceSceneId);
return result;
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace MiGu.Server.Configs;
/// <summary>
/// 日志清理与磁盘告警(system.logCleanup)。字段对齐 Simple3 <c>LogCleanupOptions</c>。
/// </summary>
public sealed class LogCleanupOptions
{
/// <summary>是否启用后台自动清理。关闭后仅「立即清理」可手动触发。默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>保留天数:删除 log/ 下 LastWriteTime 早于「现在 N 天」的 *.log。默认 30,最小 1。</summary>
public int RetentionDays { get; set; } = 30;
/// <summary>后台清理间隔(小时)。默认 24;&lt;= 0 归一为 1。</summary>
public int CheckIntervalHours { get; set; } = 24;
/// <summary>启动时先执行一次清理。默认 true。</summary>
public bool RunOnStartup { get; set; } = true;
/// <summary>是否启用磁盘剩余空间不足告警。默认 true。</summary>
public bool DiskAlertEnabled { get; set; } = true;
/// <summary>日志所在盘剩余低于该值(GB)时告警。默认 5。</summary>
public double DiskFreeAlertGB { get; set; } = 5;
/// <summary>磁盘检测间隔(分钟)。默认 30;&lt;= 0 归一为 30。</summary>
public int DiskCheckIntervalMinutes { get; set; } = 30;
public static LogCleanupOptions CreateDefaults() => new();
public LogCleanupOptions Clamp()
{
RetentionDays = Math.Clamp(RetentionDays, 1, 3650);
CheckIntervalHours = Math.Max(1, CheckIntervalHours);
if (DiskFreeAlertGB <= 0) DiskFreeAlertGB = 5;
DiskCheckIntervalMinutes = DiskCheckIntervalMinutes <= 0 ? 30 : DiskCheckIntervalMinutes;
return this;
}
}
+6 -3
View File
@@ -103,12 +103,15 @@ public sealed class OpsAuditStore
}
}
/// <summary>最近的审计(倒序,最新在前)。</summary>
public IReadOnlyList<AuditEntry> Recent()
/// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
public IReadOnlyList<AuditEntry> Recent(string? user = null)
{
lock (_gate)
{
var copy = new List<AuditEntry>(_entries);
IEnumerable<AuditEntry> src = _entries;
if (!string.IsNullOrWhiteSpace(user))
src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase));
var copy = src.ToList();
copy.Reverse();
return copy;
}
+4 -8
View File
@@ -1,12 +1,8 @@
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 record SystemConfig
{
public static SystemConfig Default() => new(
DispatchLoopHz: 50,
Log: new LogPolicy("info", 7, 256),
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
public LogCleanupOptions LogCleanup { get; init; } = new();
public static SystemConfig Default() => new();
}
+46 -34
View File
@@ -12,19 +12,17 @@ namespace MiGu.Server.Controllers;
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 默认行为一致,向后兼容)。
/// 登录请求体。可选 <see cref="LaunchMode"/>Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
public record LoginRequest(string Username, string Password, string? Scope = null, 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>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="Simple3Launcher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
@@ -51,17 +49,18 @@ public class AuthController : ControllerBase
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
bool NeedsWizard = false);
bool NeedsWizard = false,
string? Token = null);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ConfigStore _config;
private readonly ILogger<AuthController> _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger<AuthController> log)
{
_rbac = rbac;
_jwt = jwt;
@@ -79,41 +78,40 @@ public class AuthController : ControllerBase
{
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} 的权限" });
// 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」
var scope = _rbac.ResolveLoginScope(user);
if (scope == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
// 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。
var launchMode = NormalizeLaunchMode(req.LaunchMode);
SimpleLiteLauncher.LaunchResult? launchResult = null;
Simple3Launcher.LaunchResult? launchResult = null;
try
{
// M1waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
// M1waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口
// 就绪(冷启动可能十几秒)。前端拿 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}",
_log.LogInformation("Simple3 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);
_log.LogError(ex, "Simple3 launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
var (perm, roleNames, token) = BuildSession(user, req.Scope);
var (perm, roleNames, token) = BuildSession(user, scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
return Ok(new LoginResponse(token, dto, scope, runMode, perm,
LaunchStatus: launchResult?.Status,
LaunchWarning: launchResult?.Warning,
NeedsWizard: NeedsWizard()));
@@ -144,13 +142,20 @@ public class AuthController : ControllerBase
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
var (perm, roleNames, token) = BuildSession(user, preferred);
string? rotated = null;
if (!string.Equals(preferred, scope, StringComparison.OrdinalIgnoreCase))
{
SetAuthCookie(token);
rotated = token;
}
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()));
return Ok(new MeResponse(dto, preferred, InferRunMode(), perm, NeedsWizard(), rotated));
}
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
@@ -172,6 +177,11 @@ public class AuthController : ControllerBase
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 有管理权限的账号固定走管理入口,不再切到运营壳;请用运营权限账号登录查看运营页。
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == PageCatalog.ScopePlatform && req.Scope == PageCatalog.ScopeMonitor)
return StatusCode(403, new { message = "请使用运营权限账号查看运营页面" });
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
@@ -200,22 +210,23 @@ public class AuthController : ControllerBase
return (perm, roleNames, token);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
private string InferRunMode()
{
var last = _launcher.LastLaunchMode;
if (string.IsNullOrEmpty(last)) return "Detached";
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
if (last == Simple3Launcher.ExternalReuseLaunchMode) return "WebOnly";
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode)
{
if (result is not { Started: true })
return "Detached";
// Simple3 无本地端:复用既有实例也按 WebOnly 展示。
if (result.Value.Status == "ReusingExisting")
return "WebEnabled";
return "WebOnly";
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
{
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
@@ -230,8 +241,9 @@ public class AuthController : ControllerBase
{
return raw?.Trim().ToLowerInvariant() switch
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
"desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb",
// Simple3 默认仅 Web;未传 / 未知也走 WebOnly
_ => "WebOnly",
};
}
+6 -1
View File
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
private readonly LogCleanupService _cleanup;
public ConfigController(ConfigStore store)
public ConfigController(ConfigStore store, LogCleanupService cleanup)
{
_store = store;
_cleanup = cleanup;
}
[HttpGet]
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
}
var env = _store.Put(section, payload);
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
_cleanup.ApplySchedule();
return Ok(new
{
section = env.Section,
+4 -4
View File
@@ -15,9 +15,9 @@ public sealed class FleetController : ControllerBase
private readonly FleetHealthService _health;
private readonly CdmTaskSyncer _cdmSyncer;
private readonly AlarmCollector _alarmCollector;
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, PlatformDbContext db)
public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, MiGuDbContext db)
{
_health = health;
_cdmSyncer = cdmSyncer;
@@ -31,7 +31,7 @@ public sealed class FleetController : ControllerBase
/// <summary>
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// Simple3 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// </summary>
[HttpGet("tasks")]
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
@@ -76,7 +76,7 @@ public sealed class FleetController : ControllerBase
}
/// <summary>
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;Simple3 离线时回退最近记录,
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
/// </summary>
[HttpGet("alarms")]
+16 -16
View File
@@ -9,9 +9,9 @@ namespace MiGu.Server.Controllers;
public class HealthController : ControllerBase
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
public HealthController(Simple3Launcher launcher) => _launcher = launcher;
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
[HttpGet]
@@ -26,28 +26,28 @@ public class HealthController : ControllerBase
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// Simple3 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>Simple3</c> 节点。
/// 含服务器本地路径等敏感信息,要求登录。
/// </summary>
[HttpGet("simplelite")]
[HttpGet("simple3")]
[Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
public IActionResult GetSimple3Diagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/stop")]
/// <summary>关闭本机全部 Simple3 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/stop")]
[Authorize(Policy = "PlatformScope")]
public IActionResult StopSimpleLite()
public IActionResult StopSimple3()
{
var killed = _launcher.StopAll();
var diag = _launcher.GetDiagnostics();
return Ok(new { killed, diagnostics = diag });
}
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/restart")]
/// <summary>关闭并重新拉起 Simple3(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/restart")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3([FromQuery] string launchMode = "webonly")
{
var result = _launcher.Restart(launchMode);
var diag = _launcher.GetDiagnostics();
@@ -55,12 +55,12 @@ public class HealthController : ControllerBase
}
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
/// 关闭 Simple3、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 Simple3 进程并重启,仅 Platform 管理端可调。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[HttpPost("simple3/restart-for-update")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3ForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
+60 -12
View File
@@ -2,18 +2,20 @@ using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// 平台「日志管理」后端:把 Simple3 内核 <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>
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
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 Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
private readonly LogCleanupService _cleanup;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
public LogsController(
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogsController> log,
LogCleanupService cleanup)
{
_launcher = launcher;
_config = config;
_log = log;
_cleanup = cleanup;
}
// ─────────────────────────────────────────────────────────── 概览 ──
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
{
var (root, workdir, error) = ResolveLogRoot();
if (root == null)
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
if (!Directory.Exists(root))
return Ok(new
{
exists = false, root, workingDirectory = workdir,
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
disk = DiskDto()
});
var files = EnumerateLogFiles(root);
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
totalFiles = files.Count,
totalBytes = files.Sum(f => f.Bytes),
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
days
days,
disk = DiskDto()
});
}
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
[HttpGet("cleanup-config")]
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
[HttpPut("cleanup-config")]
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
[HttpPost("cleanup-now")]
public IActionResult CleanupNow()
{
var r = _cleanup.CleanNow();
if (r.Error != null)
return Problem(r.Error, statusCode: 500);
return Ok(new
{
deletedFiles = r.DeletedFiles,
freedBytes = r.FreedBytes,
freedMB = Math.Round(r.FreedMB, 1)
});
}
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
root = normRoot, exists = false, path = "", parent = (string?)null,
dirCount = 0, fileCount = 0,
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
});
var target = SafeResolveDir(normRoot, path);
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
return PhysicalFile(full, "application/octet-stream", downloadName);
}
private object DiskDto()
{
var d = _cleanup.GetDiskStatus();
return new
{
known = d.Known,
drive = d.Drive,
freeGB = Math.Round(d.FreeGB, 1),
alertEnabled = d.AlertEnabled,
alertGB = d.AlertGB,
belowThreshold = d.BelowThreshold
};
}
// ─────────────────────────────────────────────────────── helpers ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null,
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory" +
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory" +
"或显式设置 Logs:Root 指向日志根目录。");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
@@ -8,8 +8,8 @@ using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
/// 读取 Simple3 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 Simple3 拉取 maps 列表拿到 directory,再读本机同路径文件(与 Simple3 同机部署)。
/// </summary>
[ApiController]
[Authorize]
@@ -18,13 +18,13 @@ public class MapsContentController : ControllerBase
{
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<MapsContentController> _log;
public MapsContentController(
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
ILogger<MapsContentController> log)
{
_httpFactory = httpFactory;
@@ -47,7 +47,7 @@ public class MapsContentController : ControllerBase
{
var directory = await FetchMapsDirectoryAsync(ct);
if (string.IsNullOrWhiteSpace(directory))
return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" });
return StatusCode(503, new { message = "无法从 Simple3 获取地图目录" });
var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
@@ -86,7 +86,7 @@ public class MapsContentController : ControllerBase
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}");
throw new InvalidOperationException($"Simple3 maps 列表返回 {(int)resp.StatusCode}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
+302 -44
View File
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 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> 后即真实下发
/// 关于映射:
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public const string CarExecuteOp = "ops.car.execute";
public record ExecuteRequest(
string OpCode,
string TargetId,
string? Reason,
string? IdempotencyKey,
string? Method,
Dictionary<string, string>? Params,
int? SiteId);
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.car.manualCharge", CarExecuteOp,
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
{
"Goto", "GotoSite", "WebGotoSite"
};
/// <summary>
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
/// </summary>
private static readonly HashSet<string> MapMonitorQuickMethods = new(StringComparer.Ordinal)
{
"OnlineCar", "OfflineCar", "DisableCar", "EnableCar",
"ForceStopUI", "ForceStop", "UIIntercept"
};
private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
IConfiguration config,
ILogger<OpsController> log)
{
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
// 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))
if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var user = CurrentUsername();
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason));
if (req.OpCode == CarExecuteOp)
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
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);
// 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(user))
msg.Headers.TryAddWithoutValidation("X-Platform-User", user);
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"));
var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user);
return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok));
}
/// <summary>
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
/// </summary>
private async Task<ExecuteResponse> ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req)
{
var method = (req.Method ?? "").Trim();
if (!IsSafeMethodName(method))
return Done(user, scope, req, "failed", "方法名无效", ok: false);
var numericId = ExtractNumericId(req.TargetId);
if (numericId is null)
return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false);
var allowed = await LoadAllowedCarMethodsAsync(numericId.Value);
if (allowed is null)
return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config", ok: false);
allowed.UnionWith(MapMonitorQuickMethods);
if (allowed.Count == 0)
return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false);
if (!allowed.Contains(method))
return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false);
var siteId = req.SiteId;
if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite)
&& int.TryParse(rawSite, out var parsedSite))
siteId = parsedSite;
if (siteId is int sid && GotoMethods.Contains(method))
{
var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user);
if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase))
return Done(user, scope, req, gotoResult.Result,
CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok),
ok: gotoResult.Ok);
}
var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user);
return Done(user, scope, req, forwarded.Result,
CarAuditMessage(method, forwarded.Message, forwarded.Ok),
ok: forwarded.Ok);
}
private static string CarAuditMessage(string method, string? detail, bool ok)
{
if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail;
return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}{detail}";
}
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
@@ -163,6 +211,14 @@ public class OpsController : ControllerBase
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
private string CurrentUsername() =>
User.FindFirst("unique_name")?.Value
?? User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous";
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
@@ -195,4 +251,206 @@ public class OpsController : ControllerBase
catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…";
}
private static bool CanRunOp(string[] userOps, string opCode, string? scope)
{
if (userOps.Contains("*") || userOps.Contains(opCode)) return true;
if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal)))
return true;
// 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。
return opCode == CarExecuteOp
&& string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase);
}
private static bool IsSafeMethodName(string method) =>
method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');
private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message);
private async Task<HashSet<string>?> LoadAllowedCarMethodsAsync(int carId)
{
try
{
var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}");
var config = await GetLiteJsonAsync("/projection/reflection/monitor-config");
if (bundle is null || config is null) return null;
var typeName = ReadString(bundle, "typeName") ?? "";
var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName;
var map = ReadCarActionByType(config.Value);
return LookupCarMethods(map, fullTypeName, typeName);
}
catch (Exception ex)
{
_log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId);
return null;
}
}
private static Dictionary<string, string[]> ReadCarActionByType(JsonElement envelope)
{
var map = new Dictionary<string, string[]>(StringComparer.Ordinal);
if (!TryData(envelope, out var data)) return map;
if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object)
return map;
if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object)
return map;
foreach (var prop in cat.EnumerateObject())
{
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
map[prop.Name] = prop.Value.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Where(x => x.Length > 0)
.ToArray();
}
return map;
}
private static HashSet<string> LookupCarMethods(Dictionary<string, string[]> map, string fullType, string shortType)
{
var shortName = shortType;
var dot = shortType.LastIndexOf('.');
if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..];
if (map.TryGetValue(fullType, out var exact) && exact.Length > 0)
return new HashSet<string>(exact, StringComparer.Ordinal);
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
return new HashSet<string>(byType, StringComparer.Ordinal);
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
return new HashSet<string>(byShort, StringComparer.Ordinal);
foreach (var kv in map)
{
if (kv.Value.Length == 0) continue;
var k = kv.Key;
if (k == fullType || k == shortType || k == shortName) return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
}
return new HashSet<string>(StringComparer.Ordinal);
}
private static bool TryData(JsonElement envelope, out JsonElement data)
{
if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object)
return true;
data = envelope;
return envelope.ValueKind == JsonValueKind.Object;
}
private static string? ReadString(JsonElement? envelope, string name)
{
if (envelope is null) return null;
var root = envelope.Value;
if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String)
return direct.GetString();
return null;
}
private async Task<JsonElement?> GetLiteJsonAsync(string path)
{
var call = await CallLiteAsync(HttpMethod.Get, path, actor: null);
if (!call.Ok) return null;
using var doc = JsonDocument.Parse(call.Body);
return doc.RootElement.Clone();
}
private async Task<ForwardOutcome> ForwardGotoSiteAsync(int carId, int siteId, string actor)
{
var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}";
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> ForwardExecuteAsync(
string kind, int id, string method, Dictionary<string, string>? query, string actor)
{
var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}";
if (query is { Count: > 0 })
{
var qs = string.Join("&", query
.Where(kv => !string.IsNullOrEmpty(kv.Key))
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}"));
if (qs.Length > 0) path += "?" + qs;
}
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> SendLiteExecuteAsync(string path, string actor)
{
try
{
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
if (TryReadConfirmTicket(call.Body, out var token))
{
call = await CallLiteAsync(HttpMethod.Post, path, actor, new Dictionary<string, string>
{
[ConfirmTokenHeader] = token
});
}
var success = call.Ok && ParseSuccess(call.Body);
return success
? new ForwardOutcome(true, "ok", null)
: new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}{ExtractMessage(call.Body)}");
}
catch (Exception ex)
{
_log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path);
return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}");
}
}
private const string ConfirmTokenHeader = "X-Platform-Confirm-Token";
private static bool TryReadConfirmTicket(string body, out string token)
{
token = "";
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var code = root.TryGetProperty("code", out var c) && c.TryGetInt32(out var n) ? n : 0;
if (code != 428) return false;
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
return false;
token = data.TryGetProperty("confirmToken", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() ?? ""
: "";
return !string.IsNullOrWhiteSpace(token);
}
catch
{
return false;
}
}
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(
HttpMethod method, string path, string? actor, IReadOnlyDictionary<string, string>? extraHeaders = null)
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(method, url);
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
if (method == HttpMethod.Post)
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(actor))
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
if (extraHeaders != null)
{
foreach (var kv in extraHeaders)
{
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null) continue;
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
}
}
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
}
}
+27 -4
View File
@@ -48,7 +48,10 @@ public class OtaController : ControllerBase
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
var ops = User.FindFirst("ops")?.Value ?? "";
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return set.Contains("*") || set.Any(o => o.StartsWith("ops.ota", StringComparison.OrdinalIgnoreCase));
return set.Contains("*")
|| set.Contains("ops.ota")
|| set.Contains("ops.ota.write")
|| set.Any(o => o.StartsWith("ops.ota.", StringComparison.OrdinalIgnoreCase));
}
private bool DenyWrite(out ActionResult denied)
@@ -147,12 +150,32 @@ public class OtaController : ControllerBase
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
var time = DateTime.Now.ToString("yyyyMMddHHmmss");
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
// 等待文件落盘
// 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包
await Task.Delay(1500, ct);
for (var i = 0; i < 40; i++)
var deadline = DateTime.UtcNow.AddSeconds(90);
var idleAfterReceive = TimeSpan.FromSeconds(8);
var lastCount = 0;
DateTime? lastProgressAt = null;
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var info = _store.ScanPackage(pkgId);
if (info.Components.Count > 0) break;
if (info.Components.Count > lastCount)
{
lastCount = info.Components.Count;
lastProgressAt = DateTime.UtcNow;
}
else if (_store.TryGetLastReceiveAt(car.Ip, out var recvAt) &&
(lastProgressAt == null || recvAt > lastProgressAt.Value))
{
lastProgressAt = recvAt;
}
if (lastCount > 0 &&
lastProgressAt != null &&
DateTime.UtcNow - lastProgressAt.Value >= idleAfterReceive)
break;
await Task.Delay(500, ct);
}
_store.ClearActivePull(car.Ip);
@@ -8,11 +8,14 @@ namespace MiGu.Server.Controllers;
/// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
/// </summary>
[ApiController]
[AllowAnonymous]
public class OtaReceiveController : ControllerBase
{
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log;
@@ -40,15 +43,15 @@ public class OtaReceiveController : ControllerBase
[RequestSizeLimit(512_000_000)]
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var ip = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(ip, out _))
{
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip);
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip ?? "unknown");
return BadRequest("no active pull session");
}
var day = DateTime.Now.ToString("yyyy-MM-dd");
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip, "unknown"));
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip ?? "unknown", "unknown"));
Directory.CreateDirectory(dir);
var file = await ReadFirstFileAsync(ct);
if (file == null || file.Length == 0) return BadRequest("empty");
@@ -58,6 +61,7 @@ public class OtaReceiveController : ControllerBase
var path = Path.Combine(dir, safeName);
await using var fs = System.IO.File.Create(path);
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(ip);
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
return Ok(new { ok = true });
}
@@ -66,7 +70,7 @@ public class OtaReceiveController : ControllerBase
{
try
{
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var clientIp = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(clientIp, out _))
{
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown");
@@ -80,8 +84,9 @@ public class OtaReceiveController : ControllerBase
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
await using (var fs = System.IO.File.Create(dest))
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(clientIp);
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length);
return Ok(new { ok = true, path = dest });
return Ok(new { ok = true });
}
catch (Exception ex)
{
@@ -90,6 +95,14 @@ public class OtaReceiveController : ControllerBase
}
}
/// <summary>优先取 ForwardedHeaders 之前写入的 TCP 对端 IP,避免 X-Forwarded-For 投毒。</summary>
private string? ResolveTcpRemoteIp()
{
if (HttpContext.Items.TryGetValue(TcpRemoteIpItemKey, out var boxed) && boxed is string s && !string.IsNullOrWhiteSpace(s))
return s;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
{
if (!Request.HasFormContentType) return null;
@@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Mvc;
namespace MiGu.Server.Controllers;
/// <summary>
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
/// 投影 API 占位:真实落地时由 YARP 反代到 Simple3 WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 Simple3 运行时的开发联调。
///
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
/// </summary>
+73 -12
View File
@@ -29,10 +29,13 @@ public class RbacController : ControllerBase
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("ops.ota", "OTA · 运维读写"),
new("ops.ota.write", "OTA · 写操作"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
@@ -59,18 +62,35 @@ public class RbacController : ControllerBase
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog() => Ok(new
public IActionResult Catalog()
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
var actorPages = ActorPlatformPages();
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
return Ok(new
{
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
}
});
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 = "通用 (全部域)" },
},
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
{
[PageCatalog.ScopePlatform] = grantablePlatform,
[PageCatalog.ScopeMonitor] = grantableMonitor,
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
},
platformToMonitor = PageCatalog.PlatformToMonitor
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
.ToList(),
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
});
}
// ───────────────────────── 角色 ─────────────────────────
@@ -78,10 +98,12 @@ public class RbacController : ControllerBase
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.CreateRole(ClampPages(req))));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.UpdateRole(id, ClampPages(req))));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
@@ -134,4 +156,43 @@ public class RbacController : ControllerBase
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
private List<string> ActorPlatformPages()
{
var id = CurrentUserId();
if (string.IsNullOrEmpty(id)) return new();
var user = _store.FindUserById(id);
if (user is null) return new();
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
}
/// <summary>
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
/// </summary>
private SaveRoleRequest ClampPages(SaveRoleRequest req)
{
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
var pages = req.Pages ?? new List<string>();
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
{
var scopeKeys = scope == PageCatalog.Wildcard
? PageCatalog.All.Select(p => p.Key).ToList()
: PageCatalog.KeysForScope(scope).ToList();
if (scopeKeys.All(k => grantable.Contains(k)))
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
return req with
{
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
};
}
var clamped = pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return req with { Pages = clamped };
}
}
@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Signal;
namespace MiGu.Server.Controllers;
/// <summary>
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
/// 文件落在 Simple3 工作目录 <c>Config/Signal/*.json</c>,不依赖 Simple3 进程是否在跑。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/signal-data")]
public sealed class SignalDataController : ControllerBase
{
private readonly SignalDataStore _store;
private readonly ConfigStore _config;
public SignalDataController(SignalDataStore store, ConfigStore config)
{
_store = store;
_config = config;
}
[HttpGet]
public IActionResult List([FromQuery] bool summary = false)
{
var enabled = SignalEnabled();
var tables = summary
? _store.GetTables().Select(ProjectSummary).ToList()
: _store.GetTables().Select(ProjectTable).ToList();
return Ok(new
{
signalEnabled = enabled,
workingDirectory = _store.ResolveWorkingDirectory(),
tables
});
}
[HttpGet("{id}")]
public IActionResult Get(string id)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
return Ok(ProjectTable(table));
}
[HttpPut("{id}")]
public IActionResult Save(string id, [FromBody] JsonElement body)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return BadRequest(new { message = "请求体需要 rows 数组" });
JsonArray rows;
try
{
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
}
catch (Exception ex)
{
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
}
try
{
_store.SaveRows(table, rows);
}
catch (Exception ex)
{
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
}
return Ok(ProjectTable(table));
}
private object ProjectSummary(SignalTableDef table) => new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName
};
private object ProjectTable(SignalTableDef table)
{
var (path, error) = _store.ResolveFile(table, createDir: false);
var exists = path != null && System.IO.File.Exists(path);
return new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName,
exists,
error,
columns = table.Columns,
rows = _store.LoadRows(table)
};
}
private bool SignalEnabled() =>
_config.GetDeployment().ToLauncherSceneIds()
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
}
@@ -10,7 +10,7 @@ namespace MiGu.Server.Controllers;
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
///
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// 前端主流程为「刷新」读库、「默认」从 Simple3 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// </summary>
[ApiController]
[Authorize]
+8 -9
View File
@@ -17,7 +17,7 @@ namespace MiGu.Server.Controllers;
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
/// 驱动 Simple3 下次启动选择性加载选定的导航场景插件。
/// </summary>
[ApiController]
[Authorize]
@@ -25,10 +25,10 @@ namespace MiGu.Server.Controllers;
public class WizardController : ControllerBase
{
private readonly ConfigStore _store;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ILogger<WizardController> _log;
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
public WizardController(ConfigStore store, Simple3Launcher launcher, ILogger<WizardController> log)
{
_store = store;
_launcher = launcher;
@@ -81,12 +81,11 @@ public class WizardController : ControllerBase
_store.PutDeployment(profile);
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 平台 → 内核联动:把导航选型写入 Simple3 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signalPLC 握手)。
var sceneIds = profile.ToLauncherSceneIds();
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
@@ -108,7 +107,7 @@ public class WizardController : ControllerBase
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
/// </summary>
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
private static object Project(DeploymentProfile dp, Simple3Launcher.ActiveScenesWriteResult? write = null) => new
{
configured = dp.Configured,
platformType = dp.PlatformType,
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
navigationKinds = dp.NavigationKinds,
scenarios = dp.Scenarios,
updatedBy = dp.UpdatedBy,
activeSceneIds = dp.ToActiveSceneIds(),
activeSceneIds = dp.ToLauncherSceneIds(),
hiddenPages = DeploymentCatalog.HiddenPages(dp),
activeScenesWrite = write == null ? null : new
{
-4
View File
@@ -192,10 +192,6 @@ public sealed class WmsController : ControllerBase
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q, [FromQuery] Guid? containerId) =>
_service.ContainerMaterials(q, containerId);
[HttpPost("container-materials")]
public async Task<IActionResult> SaveContainerMaterial([FromBody] BindMaterialRequest req) =>
Ok(await _service.BindMaterial(req, User.ActorName()));
[HttpPost("container-materials/bind")]
public async Task<IActionResult> BindMaterial([FromBody] BindMaterialRequest req) =>
Ok(await _service.BindMaterial(req, User.ActorName()));
@@ -17,16 +17,23 @@ public static class DashboardShortcutCatalog
["platform-config"] = "admin-map-editor",
["mission"] = "admin-task-templates",
["cars"] = "admin-cars",
["auth"] = "admin-config-system-center",
["system"] = "admin-config-system-center",
["auth"] = "admin-config-auth",
["system"] = "admin-config-system",
["admin-config-system-center"] = "admin-config-auth",
["ops"] = "admin-config-ops-center",
["tasks"] = "admin-config-strategy",
["tasks"] = "admin-task-templates",
["admin-config-strategy"] = "admin-task-templates",
};
private static readonly ShortcutDef[] PlatformShortcuts =
[
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
new("admin-playback", "admin-playback", PageCatalog.ScopePlatform),
new("admin-ops-log", "admin-ops-log", PageCatalog.ScopePlatform),
new("admin-notes", "admin-notes", PageCatalog.ScopePlatform),
new("admin-logs", "admin-logs", PageCatalog.ScopePlatform),
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
@@ -36,17 +43,18 @@ public static class DashboardShortcutCatalog
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
new("admin-wcs-template-proto", "admin-wcs-template-proto", PageCatalog.ScopePlatform),
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform),
new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform),
new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform),
new("admin-config-system", "admin-config-system", PageCatalog.ScopePlatform),
new("admin-config-auth", "admin-config-auth", PageCatalog.ScopePlatform),
];
private static readonly ShortcutDef[] MonitorShortcuts =
[
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
@@ -78,16 +86,16 @@ public static class DashboardShortcutCatalog
"admin-map-monitor",
"admin-vehicle-hub",
"admin-task-templates",
"admin-config-system-center",
"admin-config-auth",
"admin-config-ops-center",
"admin-config-strategy"
"admin-config-facility"
];
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
private static readonly HashSet<string> ExcludedKeys =
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard" };
public static bool IsValidKey(string key) =>
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
@@ -7,7 +7,7 @@ namespace MiGu.Server.Dashboard;
public sealed class DashboardShortcutService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly RbacStore _rbac;
private static readonly JsonSerializerOptions JsonOpts = new()
@@ -15,7 +15,7 @@ public sealed class DashboardShortcutService
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public DashboardShortcutService(PlatformDbContext db, RbacStore rbac)
public DashboardShortcutService(MiGuDbContext db, RbacStore rbac)
{
_db = db;
_rbac = rbac;
+30 -23
View File
@@ -8,14 +8,14 @@ using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// 单例:轮询 Simple3 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;Simple3 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -28,7 +28,7 @@ public sealed class AlarmCollector
public AlarmCollector(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<AlarmCollector> log)
{
@@ -76,7 +76,7 @@ public sealed class AlarmCollector
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
await ReconcileAsync(db, current, ct);
}
catch (Exception ex)
@@ -161,36 +161,43 @@ public sealed class AlarmCollector
}
}
private static async Task ReconcileAsync(PlatformDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
private static async Task ReconcileAsync(MiGuDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
// 出现 / 更新
// 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
foreach (var cur in current.Values)
{
if (activeByCar.TryGetValue(cur.CarId, out var rec))
{
rec.Info = cur.Info;
rec.Level = cur.Level;
rec.CarName = cur.CarName;
rec.LastAt = now;
}
else
{
db.VehicleAlarms.Add(new VehicleAlarmRecord
var same =
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
rec.Level == cur.Level;
if (same)
{
CarId = cur.CarId,
CarName = cur.CarName,
Info = cur.Info,
Level = cur.Level,
Status = "active",
FirstAt = now,
LastAt = now
});
rec.CarName = cur.CarName;
rec.LastAt = now;
continue;
}
rec.Status = "cleared";
rec.ResolvedAt = now;
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
}
db.VehicleAlarms.Add(new VehicleAlarmRecord
{
CarId = cur.CarId,
CarName = cur.CarName,
Info = cur.Info,
Level = cur.Level,
Status = "active",
FirstAt = now,
LastAt = now
});
}
// 消失 → 恢复
+6 -6
View File
@@ -32,14 +32,14 @@ public sealed class CdmTaskDto
}
/// <summary>
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// 单例:从 Simple3 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「Simple3 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// </summary>
public sealed class CdmTaskSyncer
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<CdmTaskSyncer> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -53,7 +53,7 @@ public sealed class CdmTaskSyncer
public CdmTaskSyncer(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<CdmTaskSyncer> log)
{
@@ -80,7 +80,7 @@ public sealed class CdmTaskSyncer
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
await UpsertAsync(db, dtos, ct);
}
catch (Exception ex)
@@ -126,7 +126,7 @@ public sealed class CdmTaskSyncer
}
}
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
private static async Task UpsertAsync(MiGuDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
{
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
if (valid.Count == 0) return;
+1 -1
View File
@@ -1,6 +1,6 @@
namespace MiGu.Server.Fleet;
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
/// <summary>与 Simple3 GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
public sealed class FleetHealthRowDto
{
public int CarId { get; set; }
+9 -9
View File
@@ -7,13 +7,13 @@ using MiGu.Server.Ota;
namespace MiGu.Server.Fleet;
/// <summary>
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// </summary>
public sealed class FleetHealthService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly WatchDogClient _wd;
private readonly ILogger<FleetHealthService> _log;
@@ -25,7 +25,7 @@ public sealed class FleetHealthService
public FleetHealthService(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
WatchDogClient wd,
ILogger<FleetHealthService> log)
@@ -39,7 +39,7 @@ public sealed class FleetHealthService
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
{
var rows = await FetchSimpleLiteHealthAsync(ct);
var rows = await FetchSimple3HealthAsync(ct);
if (rows.Count == 0)
rows = await BuildRowsFromCarsAsync(ct);
@@ -50,7 +50,7 @@ public sealed class FleetHealthService
{
if (string.IsNullOrWhiteSpace(row.Ip))
{
// 无 IP 时保留 SimpleLite 原探测结果
// 无 IP 时保留 Simple3 原探测结果
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
return;
}
@@ -75,7 +75,7 @@ public sealed class FleetHealthService
}
else
{
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
// WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
{
row.LatencySource = "onboard";
@@ -93,7 +93,7 @@ public sealed class FleetHealthService
return rows;
}
private async Task<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(CancellationToken ct)
private async Task<List<FleetHealthRowDto>> FetchSimple3HealthAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
@@ -114,7 +114,7 @@ public sealed class FleetHealthService
}
catch (Exception ex)
{
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
_log.LogDebug(ex, "fleet/health from Simple3 failed");
return new();
}
}
+4 -2
View File
@@ -1,5 +1,5 @@
global using PlatformDbContext = MiGu.DB.Kernel.Context.MiGuDbContext;
global using EntityBase = MiGu.DB.Kernel.Entities.AggregateRoot;
global using MiGu.DB.Kernel.Context;
global using MiGu.DB.Kernel.Entities;
global using SimpleField = MiGu.DB.Domains.SimpleFields.SimpleField;
global using UserDashboardShortcut = MiGu.DB.Domains.Dashboard.UserDashboardShortcut;
global using Warehouse = MiGu.DB.Domains.Wms.Warehouse;
@@ -17,3 +17,5 @@ global using WmsTransportRule = MiGu.DB.Domains.Transport.WmsTransportRule;
global using WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
global using CdmTaskRecord = MiGu.DB.Domains.Fleet.CdmTaskRecord;
global using VehicleAlarmRecord = MiGu.DB.Domains.Fleet.VehicleAlarmRecord;
@@ -4,45 +4,46 @@ using Microsoft.Extensions.Logging;
namespace MiGu.Server.Launcher;
/// <summary>
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
/// 将 <c>obj/Debug</c> 下最新编译的 Simple3 同步到 <c>bin/Debug</c>(仅当 Simple3 未运行时)。
/// </summary>
public static class SimpleLiteBuildSync
public static class Simple3BuildSync
{
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");
var binDll = Path.Combine(binDir, "Simple3.dll");
var binExe = Path.Combine(binDir, "Simple3.exe");
if (!File.Exists(objDll))
{
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
log?.LogDebug("[Simple3BuildSync] obj DLL 不存在: {Path}", objDll);
return false;
}
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
if (Process.GetProcessesByName("Simple3").Any(p => !p.HasExited)
|| Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
{
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
log?.LogWarning("[Simple3BuildSync] Simple3 仍在运行,跳过 DLL 同步。请先关闭内核窗口。");
return false;
}
var objTime = File.GetLastWriteTimeUtc(objDll);
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
{
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
log?.LogDebug("[Simple3BuildSync] bin 已是最新,无需同步");
return false;
}
Directory.CreateDirectory(binDir);
File.Copy(objDll, binDll, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
if (File.Exists(objExe))
{
File.Copy(objExe, binExe, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
}
SyncRuntimeDeps(objDll, binDir, log);
@@ -51,7 +52,7 @@ public static class SimpleLiteBuildSync
}
/// <summary>
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
/// 探测 Simple3 是否带「前往站点」路由(区分新旧 DLL)。
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
@@ -96,7 +97,7 @@ public static class SimpleLiteBuildSync
var dst = Path.Combine(binDir, name);
File.Copy(src, dst, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
log?.LogInformation("[Simple3BuildSync] 已同步依赖 {Name}", name);
}
}
@@ -106,11 +107,11 @@ public static class SimpleLiteBuildSync
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");
var sl = Path.Combine(repo, "Simple", "Simple3");
objDll = Path.Combine(sl, "obj", "Debug", "Simple3.dll");
objExe = Path.Combine(sl, "obj", "Debug", "Simple3.exe");
binDir = Path.Combine(sl, "bin", "Debug");
return true;
return Directory.Exists(sl);
}
private static string? FindRepoRoot(string contentRoot)
@@ -118,7 +119,8 @@ public static class SimpleLiteBuildSync
var dir = new DirectoryInfo(contentRoot);
while (dir != null)
{
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "Simple3"))
|| Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
return dir.FullName;
dir = dir.Parent;
}
@@ -7,21 +7,21 @@ using Microsoft.Extensions.Options;
namespace MiGu.Server.Launcher;
/// <summary>
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
/// 平台登录成功后按 LaunchMode 把 Simple3.exe 作为子进程拉起。
///
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
/// 设计要点(与原 <c>Simple3/Platform/PlatformLauncher.cs</c> 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
/// - 默认独立(<see cref="Simple3Options.FollowParent"/> = false):Simple3 与 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。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 Simple3
/// - 命令行透传:把 LaunchMode 翻成 Simple3 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
/// - 启动后阻塞等待 Simple3 Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
/// </summary>
public sealed class SimpleLiteLauncher : IDisposable
public sealed class Simple3Launcher : IDisposable
{
private readonly SimpleLiteOptions _opts;
private readonly ILogger<SimpleLiteLauncher> _log;
private readonly Simple3Options _opts;
private readonly ILogger<Simple3Launcher> _log;
private readonly IHostEnvironment _env;
private readonly object _sync = new();
@@ -30,13 +30,13 @@ public sealed class SimpleLiteLauncher : IDisposable
private string? _lastLaunchMode;
private bool _disposed;
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
public Simple3Launcher(IOptions<Simple3Options> opts, ILogger<Simple3Launcher> log, IHostEnvironment env)
{
_opts = opts.Value;
_log = log;
_env = env;
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— Simple3 是「独立程序」,MiGu.Server 关掉
// 不应该带走 Simple3。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
if (_opts.FollowParent)
{
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
@@ -53,11 +53,11 @@ public sealed class SimpleLiteLauncher : IDisposable
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
/// <summary>外部已存在的 SimpleLiteMiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
/// <summary>外部已存在的 Simple3MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
internal const string ExternalReuseLaunchMode = "external";
/// <summary>
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
/// 按 launchMode 拉起 Simple3(已运行则跳过)。
/// </summary>
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
@@ -67,20 +67,20 @@ public sealed class SimpleLiteLauncher : IDisposable
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。");
_log.LogInformation("[Simple3] auto-start disabled (appsettings: Simple3.Enabled=false); launchMode={Mode} ignored", launchMode);
return new LaunchResult(false, "Disabled", "appsettings:Simple3:Enabled=false", DisplayMode: null,
Warning: "Simple3 自动拉起已被 appsettings:Simple3:Enabled=false 关闭;登录已成功但 Simple3 未启动,/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);
_log.LogInformation("[Simple3] 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 后重新登录。"
? $"Simple3 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 Simple3 后重新登录。"
: null;
return new LaunchResult(true, "AlreadyRunning",
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
@@ -88,44 +88,44 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: warning);
}
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 Simple3 可能仍在跑(因为
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
// 避免「allowMultiple=false 时新 Simple3 检测到多开自杀」+「端口冲突」两类常见崩溃。
//
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
// 注意:探测仅判断「有 Simple3 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 Simple3 再登录。
//
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 Simple3」,避免被某个偶然占
// 用 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));
var probeOk = ProbeSimple3Http("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.",
"[Simple3] projection port :{Port} is occupied, but Simple3 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 配置后重试。");
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 Simple3 Projection 服务。" +
"请关闭占用该端口的进程,或调整 Simple3:ProjectionPort / Simple3 配置后重试。");
}
_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.",
"[Simple3] projection :{Port} already reachable (httpProbe=ok); assume an existing Simple3 is running. " +
"Skip launch. If user picked a different LaunchMode this session, please close the existing Simple3 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)
var reuseWarning = $"检测到 Simple3 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 Simple3 窗口后重新登录。";
if (Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
{
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
reuseWarning += " 当前 Simple3 版本过旧,缺少「前往站点」API;请关闭 Simple3 窗口后调用 POST /api/health/simple3/restart-for-update,或运行 scripts/redeploy-simple3.ps1。";
}
return new LaunchResult(true, "ReusingExisting",
@@ -134,18 +134,18 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: reuseWarning);
}
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
Simple3BuildSync.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}",
"[Simple3] auto-start skipped: Simple3.exe not found. Configure appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server. ContentRoot={Root}",
_env.ContentRootPath);
return new LaunchResult(false, "ExecutableNotFound",
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
"Set appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server.",
DisplayMode: null,
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
Warning: "找不到 Simple3.exe。请在 appsettings:Simple3:ExecutablePath 显式配置,或者把 Simple3.exe 放到 MiGu.Server 同目录。");
}
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
@@ -156,12 +156,12 @@ public sealed class SimpleLiteLauncher : IDisposable
try
{
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
var proc = StartSimple3Process(resolved, arguments, workdir);
if (proc == null)
{
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
_log.LogError("[Simple3] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
Warning: $"启动 Simple3 进程失败;exe={resolved}");
}
WireProcessExitHandler(proc);
@@ -171,20 +171,20 @@ public sealed class SimpleLiteLauncher : IDisposable
if (_opts.FollowParent) AttachToJobObject(proc);
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
_log.LogInformation("[Simple3] 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");
_log.LogError(ex, "[Simple3] auto-start failed");
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
Warning: $"启动 Simple3 时抛异常:{ex.GetType().Name}: {ex.Message}");
}
}
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
// GET /api/health/simplelite 获知就绪状态。
// GET /api/health/simple3 获知就绪状态。
if (!waitForReady)
{
return new LaunchResult(true, "Starting",
@@ -200,12 +200,12 @@ public sealed class SimpleLiteLauncher : IDisposable
DisplayMode: displayMode,
Warning: ready
? null
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
: $"Simple3 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
}
/// <summary>
/// 手动关闭 SimpleLite 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
/// 手动关闭 Simple3 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— Simple3 是独立程序,MiGu.Server 关闭不带走它。
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
/// </summary>
public void Dispose()
@@ -227,7 +227,7 @@ public sealed class SimpleLiteLauncher : IDisposable
// 独立模式:不杀子进程,仅释放 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);
_log.LogInformation("[Simple3] standalone mode: MiGu.Server stopping — Simple3 pid={Pid} keeps running (FollowParent=false)", proc.Id);
}
try { proc?.Dispose(); } catch { /* ignore */ }
return;
@@ -236,7 +236,7 @@ public sealed class SimpleLiteLauncher : IDisposable
if (proc is { HasExited: false })
{
try { proc.Kill(entireProcessTree: true); }
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
catch (Exception ex) { _log.LogWarning("[Simple3] kill failed: {Msg}", ex.Message); }
try { proc.WaitForExit(2000); } catch { /* ignore */ }
}
@@ -248,21 +248,21 @@ public sealed class SimpleLiteLauncher : IDisposable
private static string NormalizeDisplayMode(string launchMode)
{
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 SimpleLite「内部语言」(web / web+local)。
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 Simple3「内部语言」(web / web+local)。
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
return launchMode?.Trim().ToLowerInvariant() switch
{
"webonly" or "web" or "web-only" => "web",
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
_ => "web+local",
"desktopandweb" or "web+local" or "weblocal" or "local" => "web+local",
// Simple3 无本地桌面端:默认 / 未知一律 web
_ => "web",
};
}
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 关闭。
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 Simple3 WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:Simple3:EmbeddedCanvas=false 关闭。
if (_opts.EmbeddedCanvas) args += " --migu";
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
var sceneArg = ReadActiveScenesArg();
@@ -271,7 +271,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return args;
}
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>
/// <summary>解析 Simple3 工作目录(与拉起时一致):优先显式 <see cref="Simple3Options.WorkingDirectory"/>
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
public string? ResolveWorkingDirectory()
{
@@ -281,7 +281,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
}
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
/// <summary>Simple3 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
public string? ResolvePluginsDir()
{
var wd = ResolveWorkingDirectory();
@@ -289,16 +289,16 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite
/// 把「配置向导选定的导航场景」写入 Simple3 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 Simple3 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 Simple3
/// <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 目录");
return new ActiveScenesWriteResult(false, null, "未找到 Simple3 工作目录/可执行文件,无法定位 plugins 目录");
try
{
Directory.CreateDirectory(pluginsDir);
@@ -313,13 +313,13 @@ public sealed class SimpleLiteLauncher : IDisposable
updatedAt = DateTime.UtcNow
};
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
_log.LogInformation("[Simple3] 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 失败");
_log.LogWarning(ex, "[Simple3] 写 active-scenes.json 失败");
return new ActiveScenesWriteResult(false, null, ex.Message);
}
}
@@ -349,11 +349,12 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
/// <summary>终止本机全部 Simple3(及旧名 SimpleLite进程并清理 MiGu.Server 侧托管引用。</summary>
public int StopAll()
{
var killed = 0;
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
foreach (var name in new[] { "Simple3", "SimpleLite" })
foreach (var proc in Process.GetProcessesByName(name))
{
try
{
@@ -361,12 +362,12 @@ public sealed class SimpleLiteLauncher : IDisposable
{
proc.Kill(entireProcessTree: true);
killed++;
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
_log.LogInformation("[Simple3] stop: killed {Name} pid={Pid}", name, proc.Id);
}
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
_log.LogWarning("[Simple3] stop: kill {Name} pid={Pid} failed: {Msg}", name, proc.Id, ex.Message);
}
finally
{
@@ -385,7 +386,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return killed;
}
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
/// <summary>关闭全部 Simple3 后按 launchMode 重新拉起(不同步 DLL)。</summary>
public LaunchResult Restart(string launchMode = "webonly")
{
StopAll();
@@ -393,19 +394,19 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// 关闭所有 Simple3 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
public LaunchResult RestartForUpdate(string launchMode = "webonly")
{
StopAll();
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var synced = Simple3BuildSync.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。"
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 Simple3 仍占用文件)。请先运行 scripts/redeploy-simple3.ps1。"
};
}
@@ -416,12 +417,12 @@ public sealed class SimpleLiteLauncher : IDisposable
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
public SimpleLiteDiagnostics GetDiagnostics()
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simple3 与启动日志)。</summary>
public Simple3Diagnostics GetDiagnostics()
{
var resolved = ResolveExecutable(_opts.ExecutablePath);
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 Simple3 发探测请求。
bool? gotoSite = null;
if (projectionUp)
{
@@ -431,7 +432,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
else
{
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
gotoSite = Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
if (gotoSite is not null)
{
_gotoSiteProbeCache = gotoSite;
@@ -440,9 +441,9 @@ public sealed class SimpleLiteLauncher : IDisposable
}
}
var deployHint = gotoSite == false
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
? "关闭 Simple3 后:运行 Migu2.0/scripts/redeploy-simple3.ps1,或 POST /api/health/simple3/restart-for-update"
: null;
return new SimpleLiteDiagnostics(
return new Simple3Diagnostics(
Enabled: _opts.Enabled,
FollowParent: _opts.FollowParent,
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
@@ -456,26 +457,26 @@ public sealed class SimpleLiteLauncher : IDisposable
ProjectionPortReachable: projectionUp,
GotoSiteApiAvailable: gotoSite,
DeployHint: deployHint,
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 Simple3 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 Simple3。");
}
/// <summary>
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite
/// 拉起 Simple3。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 Simple3
/// </summary>
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
private Process? StartSimple3Process(string resolved, string arguments, string workdir)
{
if (_opts.FollowParent)
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
if (OperatingSystem.IsWindows())
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3DetachedWindows(resolved, arguments, workdir)
?? StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
}
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
private static Process? StartSimple3Direct(string resolved, string arguments, string workdir, bool useShellExecute)
{
var psi = new ProcessStartInfo
{
@@ -493,14 +494,15 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
/// </summary>
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
private Process? StartSimple3DetachedWindows(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}";
// cmd start 的第一个引号参数是「窗口标题」,不是 exe;写死 Simple3 会让 Simple3 控制台标题错名。
var cmdArgs = $"/c start \"{exeName}\" /D \"{workdir}\" \"{resolved}\" {arguments}";
var shimPsi = new ProcessStartInfo
{
FileName = "cmd.exe",
@@ -521,7 +523,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (p.HasExited) continue;
if (beforeIds.Contains(p.Id)) continue;
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
_log.LogInformation("[Simple3] detached start via cmd.exe; new pid={Pid}", p.Id);
return AttachToExistingProcess(p.Id);
}
catch { /* ignore */ }
@@ -529,7 +531,7 @@ public sealed class SimpleLiteLauncher : IDisposable
Thread.Sleep(200);
}
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
_log.LogWarning("[Simple3] detached start: no new {Name} process observed after cmd.exe start", exeName);
return null;
}
@@ -543,7 +545,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
_log.LogWarning("[Simple3] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
return null;
}
}
@@ -554,7 +556,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
int exit;
try { exit = proc.ExitCode; } catch { exit = -1; }
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
_log.LogInformation("[Simple3] process exited code={Code}; next login will relaunch if needed", exit);
lock (_sync)
{
if (ReferenceEquals(_proc, proc))
@@ -577,16 +579,26 @@ public sealed class SimpleLiteLauncher : IDisposable
var cwd = _env.ContentRootPath;
var staticCandidates = new[]
{
Path.Combine(cwd, "Simple3.exe"),
Path.Combine(cwd, "SimpleLite.exe"),
Path.Combine(cwd, "Simple3", "Simple3.exe"),
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple3.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
// 发布布局(本机常见):D:\MDCS\Release\Simple\Simple3
@"D:\MDCS\Release\Simple\Simple3\Simple3.exe",
// 源码并列:Migu2.0/MiGu.Server → ../../Simple/Simple3OutputPath 常直接写到 Release
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Debug", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Release", "Simple3.exe"),
Path.Combine(cwd, "..", "Simple", "Simple3", "Simple3.exe"),
// 兼容旧 SimpleLite 目录/产物名
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"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
};
foreach (var c in staticCandidates)
{
@@ -599,6 +611,13 @@ public sealed class SimpleLiteLauncher : IDisposable
{
foreach (var sub in new[]
{
"Simple3/Simple3.exe",
"Simple3/bin/Debug/Simple3.exe",
"Simple3/bin/Release/Simple3.exe",
"Simple/Simple3/Simple3.exe",
"Simple/Simple3/bin/Debug/Simple3.exe",
"Simple/Simple3/bin/Release/Simple3.exe",
"SimpleLite/SimpleLite.exe",
"SimpleLite/bin/Debug/SimpleLite.exe",
"SimpleLite/bin/Release/SimpleLite.exe",
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
@@ -668,14 +687,14 @@ public sealed class SimpleLiteLauncher : IDisposable
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
{
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
_log.LogInformation("[Simple3] projection :{Port} ready", _opts.ProjectionPort);
return true;
}
Thread.Sleep(interval);
}
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
_log.LogWarning("[Simple3] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
return false;
}
@@ -694,10 +713,10 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
/// 在 TCP 通的基础上读取 Simple3 Projection 的强类型 JSON 端点,判别「真的是 Simple3」。
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
/// </summary>
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
private static bool ProbeSimple3Http(string host, int port, TimeSpan timeout)
{
try
{
@@ -718,20 +737,20 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 拉起 SimpleLite 的结果摘要。
/// 拉起 Simple3 的结果摘要。
/// <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>
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 Simple3,对应模式未知。</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(
public sealed record Simple3Diagnostics(
bool Enabled,
bool FollowParent,
string ConfiguredExecutablePath,
@@ -807,7 +826,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (!OperatingSystem.IsWindows())
{
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
_log.LogInformation("[Simple3] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
return;
}
@@ -818,7 +837,7 @@ public sealed class SimpleLiteLauncher : IDisposable
_job = CreateJobObject(IntPtr.Zero, null);
if (_job == IntPtr.Zero)
{
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] CreateJobObject failed; child may outlive MiGu.Server");
return;
}
@@ -831,17 +850,17 @@ public sealed class SimpleLiteLauncher : IDisposable
{
Marshal.StructureToPtr(info, ptr, false);
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] 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");
_log.LogWarning("[Simple3] AssignProcessToJobObject failed; child may outlive MiGu.Server");
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
_log.LogWarning("[Simple3] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
}
}
}
@@ -1,31 +1,30 @@
namespace MiGu.Server.Launcher;
/// <summary>
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 Simple3 子进程配置。
///
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
/// 会话 N+1(启动反转):原架构是 Simple3 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 Simple3.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 Simple3 的 Configuration。
///
/// 绑定 <c>appsettings.json:SimpleLite</c>。
/// 绑定 <c>appsettings.json:Simple3</c>。
/// </summary>
public sealed class SimpleLiteOptions
public sealed class Simple3Options
{
/// <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>
/// Simple3 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
/// 1) <c>./Simple3.exe</c>(合并发布布局;仍兼容探测旧名 <c>./SimpleLite.exe</c>
/// 2) <c>./Simple3/Simple3.exe</c>
/// 3) 开发态:兄弟仓库 <c>../Simple/Simple3/</c> 产出目录或 <c>bin/Debug|Release</c>
/// 4) 发布目录常见布局:<c>D:\MDCS\Release\Simple\Simple3\Simple3.exe</c>(仅当该绝对路径存在
/// 5) 沿父目录上行寻找 Simple3 产出
/// </summary>
public string ExecutablePath { get; set; } = "";
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
public string WorkingDirectory { get; set; } = "";
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
@@ -33,7 +32,7 @@ public sealed class SimpleLiteOptions
/// <summary>
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
/// 0 = 不等待(登录立即返回,前端可能还连不上 Simple3 WebApi);
/// 负值 = 无限等待(直到子进程退出或就绪)。
/// </summary>
public int ReadinessTimeoutMs { get; set; } = 8000;
@@ -41,17 +40,17 @@ public sealed class SimpleLiteOptions
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
public int ReadinessPollIntervalMs { get; set; } = 250;
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
/// <summary>Simple3 Projection WebApi 监听端口。默认与 Simple3 Configuration 的 `port` 一致。用于就绪检测。</summary>
public int ProjectionPort { get; set; } = 8222;
/// <summary>
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
/// 是否把 Simple3 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
///
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false
/// 设计原则:Simple3 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 Simple3
/// MiGu.Server 关闭不应该带走 Simple3,反之亦然。所以 FollowParent 默认 false
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
/// - Simple3 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
///
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
@@ -59,11 +58,11 @@ public sealed class SimpleLiteOptions
public bool FollowParent { get; set; } = false;
/// <summary>
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
/// 平台拉起 Simple3 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
///
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// true:平台 iframe 嵌入场景,Simple3 WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
/// false:平台拉起的 Simple3 web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
///
/// 与 LaunchModeweb / web+local,是否保留本地调试窗口)正交,可任意组合。
/// </summary>
+270
View File
@@ -0,0 +1,270 @@
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Logs;
/// <summary>
/// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。
/// </summary>
public sealed class LogCleanupService : IHostedService, IDisposable
{
private readonly ConfigStore _store;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogCleanupService> _log;
private readonly object _cleanLock = new();
private readonly object _scheduleLock = new();
private Timer? _cleanTimer;
private Timer? _diskTimer;
private bool _diskBelow;
public LogCleanupService(
ConfigStore store,
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogCleanupService> log)
{
_store = store;
_launcher = launcher;
_config = config;
_log = log;
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
var opt = _store.GetLogCleanup();
if (opt.Enabled && opt.RunOnStartup)
RunCleanSafely(opt, "启动清理");
ApplySchedule();
CheckDisk(opt);
}
catch (Exception ex)
{
_log.LogWarning(ex, "LogCleanupService 启动失败");
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
_diskTimer?.Dispose();
_diskTimer = null;
}
return Task.CompletedTask;
}
public void Dispose()
{
_cleanTimer?.Dispose();
_diskTimer?.Dispose();
}
public LogCleanupOptions GetOptions() => _store.GetLogCleanup();
public LogCleanupOptions SaveOptions(LogCleanupOptions options)
{
var saved = _store.PutLogCleanup(options);
_ = saved;
var opt = _store.GetLogCleanup();
ApplySchedule();
CheckDisk(opt, forceReeval: true);
return opt;
}
public void ApplySchedule()
{
var opt = _store.GetLogCleanup();
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
if (opt.Enabled)
{
var hours = opt.CheckIntervalHours <= 0 ? 1 : opt.CheckIntervalHours;
var period = TimeSpan.FromHours(hours);
_cleanTimer = new Timer(_ => RunCleanSafely(_store.GetLogCleanup(), "定时清理"),
null, period, period);
}
_diskTimer?.Dispose();
var mins = opt.DiskCheckIntervalMinutes <= 0 ? 30 : opt.DiskCheckIntervalMinutes;
var diskPeriod = TimeSpan.FromMinutes(mins);
_diskTimer = new Timer(_ => CheckDisk(_store.GetLogCleanup()),
null, diskPeriod, diskPeriod);
}
}
public CleanResult CleanNow()
{
var opt = _store.GetLogCleanup();
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("立即清理失败:{Error}", r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("立即清理删除 {Count} 个日志文件,释放 {Mb:F1} MB", r.DeletedFiles, r.FreedMB);
else
_log.LogInformation("立即清理完成:没有过期日志");
return r;
}
public DiskStatus GetDiskStatus()
{
var opt = _store.GetLogCleanup();
var known = TryGetFreeGB(out var freeGB, out var drive);
var below = known && opt.DiskAlertEnabled && freeGB < opt.DiskFreeAlertGB;
return new DiskStatus(known, drive, freeGB, opt.DiskAlertEnabled, opt.DiskFreeAlertGB, below);
}
private void RunCleanSafely(LogCleanupOptions opt, string reason)
{
if (!opt.Enabled && reason != "立即清理") return;
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("[{Reason}] 失败:{Error}", reason, r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("[{Reason}] 删除 {Count} 个日志文件,释放 {Mb:F1} MB", reason, r.DeletedFiles, r.FreedMB);
}
private CleanResult CleanOnce(LogCleanupOptions opt)
{
opt.Clamp();
lock (_cleanLock)
{
try
{
var (root, _, error) = ResolveLogRoot();
if (root == null)
return new CleanResult(0, 0, error ?? "无法定位日志目录");
if (!Directory.Exists(root))
return new CleanResult(0, 0, null);
var cutoff = DateTime.Now.AddDays(-opt.RetentionDays);
var deleted = 0;
long freed = 0;
foreach (var f in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories))
{
try
{
var fi = new FileInfo(f);
if (fi.LastWriteTime >= cutoff) continue;
var len = fi.Length;
fi.Delete();
deleted++;
freed += len;
}
catch
{
/* 占用/权限:跳过 */
}
}
TryRemoveEmptyDirs(root);
return new CleanResult(deleted, freed, null);
}
catch (Exception ex)
{
return new CleanResult(0, 0, ex.Message);
}
}
}
private static void TryRemoveEmptyDirs(string root)
{
try
{
foreach (var dir in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
try
{
if (!Directory.EnumerateFileSystemEntries(dir).Any())
Directory.Delete(dir, false);
}
catch { /* 忽略单目录失败 */ }
}
}
catch { /* 忽略枚举失败 */ }
}
private void CheckDisk(LogCleanupOptions opt, bool forceReeval = false)
{
if (forceReeval) _diskBelow = false;
if (!opt.DiskAlertEnabled)
{
_diskBelow = false;
return;
}
if (!TryGetFreeGB(out var freeGB, out var drive))
return;
if (freeGB < opt.DiskFreeAlertGB)
{
if (_diskBelow) return;
_diskBelow = true;
_log.LogWarning(
"磁盘剩余空间不足:{Drive} 仅剩 {Free:F1} GB(低于阈值 {Threshold:F0} GB)。请及时清理磁盘,或在系统配置中调低日志保留天数 / 立即清理。",
drive, freeGB, opt.DiskFreeAlertGB);
}
else
{
_diskBelow = false;
}
}
private bool TryGetFreeGB(out double freeGB, out string driveName)
{
freeGB = 0;
driveName = "";
var (root, _, _) = ResolveLogRoot();
if (root == null) return false;
try
{
var driveRoot = Path.GetPathRoot(root);
if (string.IsNullOrEmpty(driveRoot)) return false;
var di = new DriveInfo(driveRoot);
driveName = di.Name;
freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0;
return true;
}
catch
{
return false;
}
}
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, "未能定位 Simple3 工作目录");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
}
public readonly record struct CleanResult(int DeletedFiles, long FreedBytes, string? Error)
{
public double FreedMB => FreedBytes / 1024.0 / 1024.0;
}
public readonly record struct DiskStatus(
bool Known,
string Drive,
double FreeGB,
bool AlertEnabled,
double AlertGB,
bool BelowThreshold);
}
+2 -2
View File
@@ -28,8 +28,8 @@
<ItemGroup>
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
<Content Include="OpenApi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simplelite-projection.json')" />
<Content Include="..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json')" />
<Content Include="OpenApi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simple3-projection.json')" />
<Content Include="..\..\Simple\Simple3\Docs\openapi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\Simple3\Docs\openapi\simple3-projection.json')" />
</ItemGroup>
</Project>
@@ -5,10 +5,10 @@ using Swashbuckle.AspNetCore.SwaggerGen;
namespace MiGu.Server.OpenApi;
/// <summary>
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
/// 将 Simple3 EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/Simple3/Docs/openapi/simple3-projection.json
/// </summary>
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
public sealed class Simple3OpenApiDocumentFilter : IDocumentFilter
{
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
{
@@ -175,9 +175,12 @@ public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
foreach (var root in roots)
{
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
// 兼容旧文件名 / 旧目录
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simplelite-projection.json"));
}
}
}
+29
View File
@@ -16,6 +16,35 @@ public sealed class OtaJobRunner
_wd = wd;
_vehicles = vehicles;
_log = log;
RecoverInterruptedJobs();
}
/// <summary>进程重启后把落盘中非终态 job 标为 failed,避免 UI 永久显示 running。</summary>
private void RecoverInterruptedJobs()
{
try
{
foreach (var job in _store.ListJobs(500))
{
if (job.Status is not ("running" or "pending")) continue;
job.Status = "failed";
job.Message = string.IsNullOrWhiteSpace(job.Message)
? "进程重启,任务中断"
: job.Message;
job.FinishedAt = DateTimeOffset.UtcNow;
foreach (var step in job.Steps.Where(s => s.Status is "pending" or "running"))
{
step.Status = "failed";
step.Error ??= "进程重启,任务中断";
}
_store.SaveJob(job);
_log.LogWarning("OTA job {Id} marked failed after process restart", job.Id);
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA interrupted job recovery failed");
}
}
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
+3 -1
View File
@@ -6,9 +6,11 @@ public sealed class OtaSettings
public int MaxCar { get; set; } = 2;
public bool LatencyEnabled { get; set; }
public int RttThresholdMs { get; set; } = 200;
/// <summary>skip | confirm</summary>
/// <summary>skip | allow(历史值 confirm 视为 allow</summary>
public string OverThreshold { get; set; } = "skip";
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public int BackupPeriodMinutes { get; set; } = 60;
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public bool BackupExe { get; set; }
public string? NewVersionName { get; set; }
}
+24 -7
View File
@@ -24,6 +24,8 @@ public sealed class OtaStore
private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
@@ -144,29 +146,44 @@ public sealed class OtaStore
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
{
id = byIp;
return true;
}
if (ip == null && _lastPullId != null)
{
id = _lastPullId;
return true;
}
id = null;
return false;
}
}
public void NotePullReceive(string? sourceIp)
{
var ip = NormalizeIp(sourceIp);
if (ip == null) return;
_pullLastReceiveUtc[ip] = DateTime.UtcNow;
}
public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc)
{
var ip = NormalizeIp(sourceIp);
if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc))
return true;
utc = default;
return false;
}
public void ClearActivePull(string? sourceIp = null)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp.TryRemove(ip, out _);
if (ip != null)
{
_pullByIp.TryRemove(ip, out _);
_pullLastReceiveUtc.TryRemove(ip, out _);
}
if (_pullByIp.IsEmpty) _lastPullId = null;
}
}
+2 -2
View File
@@ -7,13 +7,13 @@ namespace MiGu.Server.Ota;
public sealed class OtaVehicleSource
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStoreAccessor _token;
private readonly ILogger<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
@@ -21,8 +21,4 @@ public static class PlatformPersistence
services.AddScoped<Dashboard.DashboardShortcutService>();
return services;
}
/// <summary>启动建库/迁移入口(内部即 MigrateMiGuDbAsyncMigrate + 基线 + DataMigrator)。</summary>
public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
=> services.MigrateMiGuDbAsync();
}
+33 -23
View File
@@ -8,6 +8,8 @@ using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.OpenApi;
using MiGu.Server.Ota;
using MiGu.Server.Signal;
using MiGu.DB.Kernel.Hosting;
using MiGu.Server.Persistence;
using Yarp.ReverseProxy.Transforms;
@@ -122,11 +124,11 @@ builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "MiGu.Server + SimpleLite",
Title = "MiGu.Server + Simple3",
Version = "v1",
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
Description = "咪咕平台后端 API,以及经 YARP 反代的 Simple3 数据 WebApi(标签 Simple3/*)。详见 Simple/Simple3/Docs/MIGU-API.md。"
});
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
c.DocumentFilter<Simple3OpenApiDocumentFilter>();
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
@@ -160,7 +162,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// - InternalTokenStore 管理 Simple3 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// 共享密钥(YARP transform 自动追加)。
builder.Services.AddSingleton<RbacStore>();
builder.Services.AddSingleton<JwtIssuer>(sp =>
@@ -215,7 +217,7 @@ builder.Services.AddAuthorization(opts =>
}));
});
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222AR-1/AR-2 配套)。
// YARP + transform:把 Platform 内部 token 透传给 Simple3 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
@@ -253,16 +255,19 @@ builder.Services.AddReverseProxy()
// 单例配置仓库(内存 + JSON 文件持久化占位)
builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
builder.Services.AddSingleton<Simple3Launcher>();
builder.Services.AddSingleton<SignalDataStore>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
@@ -279,42 +284,47 @@ builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
builder.Services.AddHttpClient(nameof(WatchDogClient));
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
await app.Services.MigrateMiGuDbAsync();
// 启动期主动构造 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>();
// 主动实例化 Simple3LauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
{
var sl = simpleLiteLauncher.GetDiagnostics();
var sl = simple3Launcher.GetDiagnostics();
app.Logger.LogInformation(
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
"[MiGu.Server] Simple3: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → Simple3",
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))
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
{
app.Lifetime.ApplicationStopping.Register(() =>
{
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
catch { /* shutdown best-effort */ }
});
}
else
{
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
app.Logger.LogInformation("[MiGu.Server] Simple3: FollowParent=false — MiGu.Server 退出不会结束 Simple3");
}
if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
app.Use(async (ctx, next) =>
{
app.UseSwagger();
app.UseSwaggerUI();
}
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
ctx.Connection.RemoteIpAddress?.ToString();
await next();
});
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
+36 -36
View File
@@ -5,8 +5,8 @@
>
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web)
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
> 后端 `AuthController.Login` 调用 `Simple3Launcher.MaybeStart(launchMode)` 按所选模式拉起 `Simple3.exe`
> 通过 `--display-mode=web|web+local` 透传给 Simple3 的 `Configuration.displayMode`。
>
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
@@ -19,83 +19,83 @@
cd MiGu.Server
dotnet build MiGu.Server.csproj -c Debug
# 2) 启动(登录后按所选模式拉起 SimpleLite
# 2) 启动(登录后按所选模式拉起 Simple3
dotnet run
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
```
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`
## 配置 SimpleLite 路径(必读)
## 配置 Simple3 路径(必读)
> **配置文件位置(无 Web 界面):**
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
> **`MiGu.Server/appsettings.json`** → 搜索 **`"Simple3"`** 节点。
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `Simple3.exe`。
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simple3`** 查看路径是否解析成功。
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
登录成功后,MiGu.Server 会按所选启动模式拉起 **Simple3.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
### 本机开发(与 Simple 仓库并列)
仓库默认已在 `appsettings.Development.json` 中写好:
```json
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug",
"ProjectionPort": 8222,
"ReadinessTimeoutMs": 8000,
"FollowParent": false
}
```
请先编译 SimpleLite(在 Simple 仓库):
请先编译 Simple3(在 Simple 仓库):
```pwsh
cd ..\Simple
dotnet build SimpleLite\SimpleLite.csproj -c Debug
dotnet build Simple3\Simple3.csproj -c Debug
```
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
```json
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug"
```
### 配置项说明
| 键 | 含义 |
|----|------|
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI |
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs` |
| `Enabled` | `false` 时永不拉起 Simple3(只调试平台 UI |
| `ExecutablePath` | `Simple3.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/Simple3Options.cs` |
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini` |
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
| `ReadinessTimeoutMs` | 登录后等待 Simple3 WebAPI 就绪的最长时间(毫秒) |
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 Simple3;默认 `false`(两进程独立) |
### 环境变量覆盖
```pwsh
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
$env:SimpleLite__WorkingDirectory = "D:\apps"
$env:SimpleLite__Enabled = "true"
$env:Simple3__ExecutablePath = "D:\apps\Simple3.exe"
$env:Simple3__WorkingDirectory = "D:\apps"
$env:Simple3__Enabled = "true"
```
### 生产 / 合并发布
`SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
`Simple3.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./Simple3.exe`),或在 `appsettings.Production.json` 写死部署路径。
浏览器访问 `http://localhost:8080/login`
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
2. 选 scope(管理员 / 运营);
3.**SimpleLite 启动模式**
3.**Simple3 启动模式**
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
4. 点登录。`AuthController.Login` 调用 `Simple3Launcher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
> 如果只想跑 MiGu.Server 单进程调试(不拉 Simple3),把 `appsettings.json:Simple3.Enabled` 改为 `false` 即可。
## 数据库启动流程
@@ -110,8 +110,8 @@ AddPlatformPersistence(configuration) # 注册 AddMiGuDb + Wms/SimpleFields
builder.Build()
EnsurePlatformDatabaseAsync() # → MiGu.DB.MigrateMiGuDbAsync()
# Schema 初始化 + 可选 IDataMigrator
MigrateMiGuDbAsync() # Schema 初始化 + 可选 IDataMigrator
… 其余中间件 …
UseMiddleware<HttpActorContextMiddleware> # 请求级写入 IActorContext(审计戳)
@@ -198,7 +198,7 @@ SchemaMode = Migrate
>
> 详见代码审核 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。
> **Simple3 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。
## 能力清单(本轮)
@@ -208,11 +208,11 @@ SchemaMode = Migrate
- 兜底/部署模式:找不到 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);
- `/api/sl/{**catch-all}``http://127.0.0.1:8222/`Simple3 WebAPI;当前 Simple3 未启 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`
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `Simple3Launcher` 据此拉起子进程;
- Simple3 子进程编排:`Launcher/Simple3Launcher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:Simple3`
- 运维白名单网关占位:`/api/sl/ops/execute``/api/sl/ops/audits`
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`
- 健康检查:`/api/health`
@@ -255,7 +255,7 @@ MiGu.Server/
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../distassets/ 与 index.html 已 ignore
```
## 独立启动(仅当你不通过 SimpleLite 自启时)
## 独立启动(仅当你不通过 Simple3 自启时)
```pwsh
# 1) 还原 + 编译 + 运行
@@ -278,7 +278,7 @@ dotnet run
> 启动时控制台会打印 `[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
> Simple3 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
## 与前端联调
@@ -292,7 +292,7 @@ dotnet run
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`Program.cs 会优先挂 dist
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
## YARP 与 SimpleLite
## YARP 与 Simple3
YARP 路由配置见 `appsettings.json`
@@ -312,7 +312,7 @@ YARP 路由配置见 `appsettings.json`
```
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`SimpleLite 未启时会得到 502/连接被拒。
- 其他 `/api/sl/*` 由 YARP 透传到 Simple3 `:8222`Simple3 未启时会得到 502/连接被拒。
## iframe 嵌入 webVRender 的两种方式
+139
View File
@@ -0,0 +1,139 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using MiGu.Server.Launcher;
namespace MiGu.Server.Signal;
public sealed record SignalColumn(string Key, string Label, string Type, IReadOnlyList<string>? Options = null, string? Group = null);
public sealed record SignalTableDef(
string Id,
string Title,
string Category,
string FileName,
IReadOnlyList<SignalColumn> Columns);
/// <summary>
/// 读写 Simple3 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
/// 表结构只从 Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射;无插件时才读 signal-tables.json。
/// </summary>
public sealed class SignalDataStore
{
private static readonly JsonSerializerOptions FileJson = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly object FileLock = new();
private readonly Simple3Launcher _launcher;
private IReadOnlyList<SignalTableDef>? _cachedTables;
private long _cachedSignature;
public SignalDataStore(Simple3Launcher launcher) => _launcher = launcher;
public IReadOnlyList<SignalTableDef> GetTables()
{
var sig = SignalTableManifestLoader.ComputeManifestSignature(_launcher);
if (_cachedTables == null || sig != _cachedSignature)
{
_cachedTables = SignalTableManifestLoader.Load(_launcher);
_cachedSignature = sig;
}
return _cachedTables;
}
public SignalTableDef? Find(string id) =>
GetTables().FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
public string? ResolveWorkingDirectory() => _launcher.ResolveWorkingDirectory();
public (string? Path, string? Error) ResolveFile(SignalTableDef table, bool createDir)
{
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, "未找到 Simple3 工作目录,无法定位 Config/Signal");
if (!TryResolveUnderSignalDir(wd, table.FileName, out var dest, out var pathError))
return (null, pathError);
if (File.Exists(dest))
return (dest, null);
if (createDir)
{
try
{
var dir = Path.GetDirectoryName(dest);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
catch (Exception ex)
{
return (null, ex.Message);
}
}
return (dest, null);
}
public JsonArray LoadRows(SignalTableDef table)
{
var (path, _) = ResolveFile(table, createDir: false);
if (path == null || !File.Exists(path))
return new JsonArray();
lock (FileLock)
{
var json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
return new JsonArray();
var node = JsonNode.Parse(json);
if (node is JsonArray arr)
return arr;
return new JsonArray();
}
}
public void SaveRows(SignalTableDef table, JsonArray rows)
{
var (path, error) = ResolveFile(table, createDir: true);
if (path == null)
throw new InvalidOperationException(error ?? "无法解析信号配置文件路径");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
lock (FileLock)
File.WriteAllText(path, rows.ToJsonString(FileJson));
}
private static bool TryResolveUnderSignalDir(string workingDirectory, string fileName, out string dest, out string error)
{
dest = "";
error = "";
if (string.IsNullOrWhiteSpace(fileName) ||
Path.IsPathRooted(fileName) ||
fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
error = "信号表文件名非法";
return false;
}
var root = Path.GetFullPath(Path.Combine(workingDirectory, "Config", "Signal"));
dest = Path.GetFullPath(Path.Combine(root, fileName));
var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!dest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
error = "信号表路径超出 Config/Signal";
dest = "";
return false;
}
return true;
}
}
@@ -0,0 +1,289 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json.Serialization;
namespace MiGu.Server.Signal;
/// <summary>
/// 从 Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射数据中心表和列。
/// 平台不编译引用该插件工程,避免仓外路径和 Windows TFM 耦合。
/// </summary>
public static class SignalModelSchemaResolver
{
private const string ModelNamespace = "StandardScene.Signal.Model";
private const string AssemblyFileName = "StandardScene.Signal.dll";
public static string? FindAssemblyPath(string? pluginsDir)
{
if (string.IsNullOrWhiteSpace(pluginsDir))
return null;
var path = Path.Combine(pluginsDir, AssemblyFileName);
return File.Exists(path) ? path : null;
}
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
{
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
if (assembly == null)
return Array.Empty<SignalTableDef>();
try
{
return BuildTablesFromAssembly(assembly);
}
catch
{
return Array.Empty<SignalTableDef>();
}
});
}
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
{
if (string.IsNullOrWhiteSpace(modelName))
return Array.Empty<SignalColumn>();
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
if (assembly == null)
return Array.Empty<SignalColumn>();
try
{
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
if (type == null)
return Array.Empty<SignalColumn>();
return DiscoverColumns(type);
}
catch
{
return Array.Empty<SignalColumn>();
}
});
}
private static T WithSignalAssembly<T>(string? pluginsDir, string? workingDirectory, Func<Assembly?, T> use)
{
var assemblyPath = FindAssemblyPath(pluginsDir);
if (assemblyPath == null)
return use(null);
var expected = Path.GetFullPath(assemblyPath);
var cached = FindLoadedFrom(expected);
if (cached != null)
return use(cached);
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
ResolveEventHandler handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
AppDomain.CurrentDomain.AssemblyResolve += handler;
try
{
var loaded = Assembly.LoadFrom(expected);
return use(HasModelTypes(loaded) ? loaded : null);
}
catch
{
return use(null);
}
finally
{
AppDomain.CurrentDomain.AssemblyResolve -= handler;
}
}
private static Assembly? FindLoadedFrom(string expectedFullPath)
{
try
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!string.Equals(assembly.GetName().Name, "StandardScene.Signal", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(assembly.Location))
continue;
if (!string.Equals(Path.GetFullPath(assembly.Location), expectedFullPath, StringComparison.OrdinalIgnoreCase))
continue;
return HasModelTypes(assembly) ? assembly : null;
}
}
catch
{
/* 继续 LoadFrom */
}
return null;
}
private static bool HasModelTypes(Assembly assembly)
{
return SafeGetTypes(assembly).Any(t =>
t != null && string.Equals(t.Namespace, ModelNamespace, StringComparison.Ordinal));
}
private static IReadOnlyList<string> BuildProbeDirs(string? pluginsDir, string? workingDirectory)
{
var dirs = new List<string>();
void Add(string? dir)
{
if (string.IsNullOrWhiteSpace(dir)) return;
var full = Path.GetFullPath(dir);
if (Directory.Exists(full) && !dirs.Contains(full, StringComparer.OrdinalIgnoreCase))
dirs.Add(full);
}
Add(pluginsDir);
Add(workingDirectory);
if (!string.IsNullOrWhiteSpace(pluginsDir))
Add(Path.GetDirectoryName(pluginsDir));
return dirs;
}
private static Assembly? ResolveAssembly(string? assemblyName, IReadOnlyList<string> probeDirs)
{
if (string.IsNullOrWhiteSpace(assemblyName))
return null;
string simpleName;
try
{
simpleName = new AssemblyName(assemblyName).Name ?? assemblyName;
}
catch
{
simpleName = assemblyName.Split(',')[0];
}
foreach (var dir in probeDirs)
{
var path = Path.Combine(dir, simpleName + ".dll");
if (!File.Exists(path))
continue;
try
{
return Assembly.LoadFrom(path);
}
catch
{
/* try next dir */
}
}
return null;
}
private static IReadOnlyList<SignalTableDef> BuildTablesFromAssembly(Assembly assembly)
{
var found = new List<(int Order, SignalTableDef Table)>();
foreach (var type in SafeGetTypes(assembly))
{
if (type == null || !string.Equals(type.Namespace, ModelNamespace, StringComparison.Ordinal))
continue;
var attr = type.GetCustomAttributes(inherit: false)
.FirstOrDefault(a => a.GetType().Name == "SignalTableAttribute");
if (attr == null)
continue;
var attrType = attr.GetType();
var id = (attrType.GetProperty("Id")?.GetValue(attr) as string ?? "").Trim();
var fileName = (attrType.GetProperty("FileName")?.GetValue(attr) as string ?? "").Trim();
var title = (attrType.GetProperty("Title")?.GetValue(attr) as string ?? "").Trim();
var order = attrType.GetProperty("Order")?.GetValue(attr) as int? ?? 0;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fileName))
continue;
var category = type.GetCustomAttribute<CategoryAttribute>()?.Category ?? "";
if (string.IsNullOrWhiteSpace(title))
title = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? id;
found.Add((order, new SignalTableDef(id, title, category, fileName, DiscoverColumns(type))));
}
return found.OrderBy(x => x.Order).ThenBy(x => x.Table.Id).Select(x => x.Table).ToList();
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t != null)!;
}
}
private static IReadOnlyList<SignalColumn> DiscoverColumns(Type type)
{
var list = new List<SignalColumn>();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Where(p => !IsJsonIgnored(p))
.OrderBy(p => p.MetadataToken))
{
var label = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
if (string.IsNullOrWhiteSpace(label))
label = prop.Name;
var columnType = MapType(prop.PropertyType);
string[]? options = null;
var group = prop.GetCustomAttribute<CategoryAttribute>(inherit: false)?.Category;
if (string.IsNullOrWhiteSpace(group))
group = null;
var select = ReadSelectOptions(prop);
if (select is { Length: > 0 })
{
columnType = "enum";
options = select;
}
else if (columnType == "enum" && (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType).IsEnum)
{
options = Enum.GetNames(Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType);
}
list.Add(new SignalColumn(prop.Name, label, columnType, options, group));
}
return list;
}
private static bool IsJsonIgnored(PropertyInfo prop)
{
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() != null)
return true;
foreach (var attr in prop.GetCustomAttributes(inherit: true))
{
if (attr.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute")
return true;
}
return false;
}
private static string[]? ReadSelectOptions(PropertyInfo prop)
{
var attr = prop.GetCustomAttributes(inherit: false)
.FirstOrDefault(a => a.GetType().Name == "SignalSelectAttribute");
if (attr == null)
return null;
var options = attr.GetType().GetProperty("Options")?.GetValue(attr) as string[];
return options is { Length: > 0 } ? options : null;
}
private static string MapType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
if (underlying == typeof(bool))
return "bool";
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) ||
underlying == typeof(byte) || underlying == typeof(uint) || underlying == typeof(ulong))
return "int";
if (underlying.IsEnum)
return "enum";
return "string";
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace MiGu.Server.Signal;
using System.Text.Json.Serialization;
public sealed class SignalTableManifest
{
public int Version { get; set; } = 1;
public List<SignalTableManifestEntry> Tables { get; set; } = new();
}
public sealed class SignalTableManifestEntry
{
public string Id { get; set; } = "";
public string Title { get; set; } = "";
public string Category { get; set; } = "";
public string FileName { get; set; } = "";
public string Model { get; set; } = "";
public List<SignalTableColumnEntry>? Columns { get; set; }
}
public sealed class SignalTableColumnEntry
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public string Type { get; set; } = "string";
[JsonPropertyName("options")]
public string[]? Options { get; set; }
[JsonPropertyName("group")]
public string? Group { get; set; }
}
@@ -0,0 +1,174 @@
using System.Text.Json;
using MiGu.Server.Launcher;
namespace MiGu.Server.Signal;
/// <summary>
/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。
/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。
/// </summary>
public static class SignalTableManifestLoader
{
private const string ManifestFileName = "signal-tables.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public static IReadOnlyList<SignalTableDef> Load(Simple3Launcher launcher)
{
var workingDirectory = launcher.ResolveWorkingDirectory();
var pluginsDir = launcher.ResolvePluginsDir();
var fromPlugin = SignalModelSchemaResolver.ResolveTables(pluginsDir, workingDirectory);
if (fromPlugin.Count > 0)
return fromPlugin;
var manifest = TryLoadManifest(launcher);
return manifest.Tables
.Where(t => !string.IsNullOrWhiteSpace(t.Id) && !string.IsNullOrWhiteSpace(t.FileName))
.Select(e => ToDef(e, pluginsDir, workingDirectory))
.ToList();
}
public static long ComputeManifestSignature(Simple3Launcher launcher)
{
long sig = 0;
foreach (var path in ResolveManifestPaths(launcher))
{
if (!File.Exists(path)) continue;
try
{
var info = new FileInfo(path);
sig ^= info.LastWriteTimeUtc.Ticks;
sig ^= info.Length;
}
catch { /* ignore */ }
}
var pluginsDir = launcher.ResolvePluginsDir();
var dll = pluginsDir == null ? null : Path.Combine(pluginsDir, "StandardScene.Signal.dll");
if (dll != null && File.Exists(dll))
{
try
{
var info = new FileInfo(dll);
sig ^= info.LastWriteTimeUtc.Ticks;
}
catch { /* ignore */ }
}
return sig;
}
private static SignalTableManifest TryLoadManifest(Simple3Launcher launcher)
{
foreach (var path in ResolveManifestPaths(launcher))
{
if (!File.Exists(path)) continue;
try
{
var json = File.ReadAllText(path);
var manifest = JsonSerializer.Deserialize<SignalTableManifest>(json, JsonOptions);
if (manifest?.Tables is { Count: > 0 })
return manifest;
}
catch { /* try next */ }
}
return JsonSerializer.Deserialize<SignalTableManifest>(EmbeddedFallbackJson, JsonOptions)
?? new SignalTableManifest();
}
private static IEnumerable<string> ResolveManifestPaths(Simple3Launcher launcher)
{
var wd = launcher.ResolveWorkingDirectory();
if (!string.IsNullOrWhiteSpace(wd))
yield return Path.Combine(wd, "Config", "Signal", ManifestFileName);
var plugins = launcher.ResolvePluginsDir();
if (string.IsNullOrWhiteSpace(plugins)) yield break;
yield return Path.Combine(plugins, ManifestFileName);
yield return Path.Combine(plugins, "Config", "Signal", ManifestFileName);
}
private static SignalTableDef ToDef(SignalTableManifestEntry entry, string? pluginsDir, string? workingDirectory)
{
var columns = BuildColumns(entry, pluginsDir, workingDirectory);
return new SignalTableDef(
entry.Id.Trim(),
string.IsNullOrWhiteSpace(entry.Title) ? entry.Id.Trim() : entry.Title.Trim(),
entry.Category ?? "",
entry.FileName.Trim(),
columns);
}
private static IReadOnlyList<SignalColumn> BuildColumns(
SignalTableManifestEntry entry,
string? pluginsDir,
string? workingDirectory)
{
var reflected = SignalModelSchemaResolver.ResolveColumns(entry.Model, pluginsDir, workingDirectory);
if (reflected.Count > 0)
return reflected;
if (entry.Columns is { Count: > 0 })
{
return entry.Columns
.Where(c => !string.IsNullOrWhiteSpace(c.Key))
.Select(c => new SignalColumn(
c.Key.Trim(),
string.IsNullOrWhiteSpace(c.Label) ? c.Key.Trim() : c.Label.Trim(),
string.IsNullOrWhiteSpace(c.Type) ? "string" : c.Type.Trim(),
c.Options,
string.IsNullOrWhiteSpace(c.Group) ? null : c.Group.Trim()))
.ToList();
}
return Array.Empty<SignalColumn>();
}
private const string EmbeddedFallbackJson = """
{
"version": 1,
"tables": [
{
"id": "stations",
"title": "PLC机构",
"category": "PLC数据管理",
"fileName": "stations.json",
"model": "PlcStationModel"
},
{
"id": "docks",
"title": "机构工位",
"category": "PLC数据管理",
"fileName": "station-docks.json",
"model": "PlcStationDockModel"
},
{
"id": "handshake",
"title": "握手点",
"category": "握手点数据管理",
"fileName": "handshake-points.json",
"model": "HandshakePointModel"
},
{
"id": "release",
"title": "放行点",
"category": "放行点数据管理",
"fileName": "release-points.json",
"model": "ReleasePointModel"
},
{
"id": "mag-control",
"title": "磁条管控区",
"category": "磁条交管",
"fileName": "mag-control-areas.json",
"model": "MagControlAreaModel"
}
]
}
""";
}
@@ -6,9 +6,9 @@ namespace MiGu.Server.SimpleFields;
public sealed class SimpleFieldService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public SimpleFieldService(PlatformDbContext db) => _db = db;
public SimpleFieldService(MiGuDbContext db) => _db = db;
public async Task<List<SimpleField>> ListAsync(string? fieldType = null, string? carType = null, string? q = null)
{
+1 -6
View File
@@ -20,7 +20,7 @@ public sealed record ContainerMaterialSnapshot(
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
public sealed record MasterDataRequest(
Guid? Id, long? Version, string Code, string Name, string Type, string Status, bool Enabled, int SortOrder,
Guid? Id, long? Version, string Code, string Name, string Type, bool Enabled, int SortOrder,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record AreaRequest(
@@ -57,8 +57,3 @@ public sealed record ContainerLocationRequest(
public sealed record BindMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo,
string Status, DateTimeOffset? LoadedAt, DateTimeOffset? UnloadedAt, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
+2 -2
View File
@@ -5,9 +5,9 @@ namespace MiGu.Server.Wms;
public sealed class WmsReferenceValidator
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public WmsReferenceValidator(PlatformDbContext db)
public WmsReferenceValidator(MiGuDbContext db)
{
_db = db;
}
+24 -87
View File
@@ -10,14 +10,14 @@ namespace MiGu.Server.Wms;
public sealed class WmsService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IServiceProvider _services;
private readonly WmsReferenceValidator _refs;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsService(
PlatformDbContext db,
MiGuDbContext db,
IUnitOfWork uow,
IServiceProvider services,
WmsReferenceValidator refs)
@@ -158,7 +158,7 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.Warehouses.Add(entity);
}
await EnsureUnique(_db.Warehouses, x => x.Code == req.Code && x.Id != entity.Id, "仓库编码已存在");
await Repo<Warehouse>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "仓库编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Default");
@@ -185,7 +185,7 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.WarehouseAreas.Add(entity);
}
await EnsureUnique(_db.WarehouseAreas, x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在");
await Repo<WarehouseArea>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在");
entity.WarehouseId = warehouseId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
@@ -214,7 +214,7 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.Storages.Add(entity);
}
await EnsureUnique(_db.Storages, x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在");
await Repo<Storage>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在");
entity.AreaId = req.AreaId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
@@ -227,7 +227,7 @@ public sealed class WmsService
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
entity.Barcode = req.Barcode.TrimOr("");
entity.Capacity = 1;
var status = StorageStatuses.Normalize(req.Status);
var status = StorageStatuses.ParseOr(req.Status);
if (status == StorageStatuses.Disabled || req.Enabled == false)
entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
@@ -326,7 +326,7 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.Containers.Add(entity);
}
await EnsureUnique(_db.Containers, x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在");
await Repo<Container>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在");
entity.AreaId = req.AreaId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
@@ -353,7 +353,7 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.MaterialTypes.Add(entity);
}
await EnsureUnique(_db.MaterialTypes, x => x.Code == req.Code && x.Id != entity.Id, "物料类型编码已存在");
await Repo<MaterialType>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "物料类型编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Spec = req.Spec.TrimOr("");
@@ -378,9 +378,9 @@ public sealed class WmsService
StampCreate(entity, actor);
_db.Materials.Add(entity);
}
await EnsureUnique(_db.Materials, x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在");
await Repo<Material>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在");
if (!string.IsNullOrWhiteSpace(req.Barcode))
await EnsureUnique(_db.Materials, x => x.Barcode == req.Barcode && x.Id != entity.Id, "物料条码已存在");
await Repo<Material>().EnsureUniqueAsync(x => x.Barcode == req.Barcode && x.Id != entity.Id, "物料条码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.TypeCode = req.TypeCode.TrimOr("");
@@ -425,9 +425,9 @@ public sealed class WmsService
await _uow.SaveChangesAsync();
}
public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase
public async Task DeleteEntity<T>(Guid id, long? version, string actor)
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable, IAuditable
{
var entity = await FindEditable<T>(id, version);
if (typeof(T) == typeof(WarehouseArea))
{
if (await _db.Storages.AnyAsync(x => x.AreaId == id))
@@ -444,10 +444,8 @@ public sealed class WmsService
if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
throw new InvalidOperationException("物料仍在绑定中,不能删除");
}
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await Repo<T>().SoftDeleteAsync(id, version);
await _uow.SaveChangesAsync();
}
@@ -497,8 +495,7 @@ public sealed class WmsService
}
else
{
EnsureVersion(current, req.Version);
EnsureUnlocked(current);
await Repo<ContainerLocation>().GetEditableAsync(current.Id, req.Version);
}
current.LocationType = locationType;
@@ -540,7 +537,7 @@ public sealed class WmsService
{
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId)
?? throw new InvalidOperationException("容器当前位置不存在");
EnsureUnlocked(current);
await Repo<ContainerLocation>().GetEditableAsync(current.Id, null);
Guid? fromStorageId = null;
if (current.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(current.LocationId, out var fs))
fromStorageId = fs;
@@ -614,15 +611,9 @@ public sealed class WmsService
return current;
}
/// <summary>兼容旧接口:忽略数量,按实体绑定。</summary>
public Task<ContainerMaterial> SaveContainerMaterial(ContainerMaterialRequest req, string actor) =>
BindMaterial(new BindMaterialRequest(req.Id, req.Version, req.ContainerId, req.MaterialId, req.Source, req.Reason, req.IsLock, req.Remark, req.Extend), actor);
public async Task UnbindMaterial(Guid bindingId, string actor, string reason = "", bool archive = false)
{
var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == bindingId)
?? throw new InvalidOperationException("绑定不存在");
EnsureUnlocked(current);
var current = await Repo<ContainerMaterial>().GetEditableAsync(bindingId, null);
var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == current.MaterialId);
var before = Snapshot(current);
var now = DateTimeOffset.UtcNow;
@@ -718,46 +709,6 @@ public sealed class WmsService
return wh;
}
public async Task MigrateLegacyAsync(string actor = "system")
{
var wh = await EnsureDefaultWarehouseAsync(actor);
var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync();
foreach (var a in areas)
a.WarehouseId = wh.Id;
var storages = await _db.Storages.ToListAsync();
foreach (var s in storages)
{
if (s.LevelNo <= 0) s.LevelNo = 1;
if (s.DepthNo <= 0) s.DepthNo = 1;
if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId;
if (!StorageStatuses.All.Contains(s.Status))
s.Status = StorageStatuses.Empty;
if (!s.Enabled) s.Status = StorageStatuses.Disabled;
}
var containers = await _db.Containers.ToListAsync();
foreach (var c in containers)
{
if (!ContainerStatuses.All.Contains(c.Status))
c.Status = ContainerStatuses.EmptyMaterial;
}
var binds = await _db.ContainerMaterials.ToListAsync();
foreach (var b in binds)
{
b.Quantity = 1;
if (b.BoundAt == default) b.BoundAt = b.LoadedAt == default ? DateTimeOffset.UtcNow : b.LoadedAt;
if (!ContainerMaterialStatuses.ActiveBind.Contains(b.Status))
b.Status = ContainerMaterialStatuses.Bound;
}
await _uow.SaveChangesAsync();
foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled))
await SyncOccupancyStatus(storageId: s.Id);
}
public async Task<List<ContainerLocationHistory>> LocationHistory(Guid? containerId = null)
{
var rows = await (containerId.HasValue
@@ -857,16 +808,13 @@ public sealed class WmsService
});
}
private IEditableRepository<T> Repo<T>()
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
=> _services.GetRequiredService<IEditableRepository<T>>();
private Task<T> FindEditable<T>(Guid id, long? version)
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
=> _services.GetRequiredService<IEditableRepository<T>>().GetEditableAsync(id, version);
private static void EnsureVersion(EntityBase entity, long? version)
{
if (version.HasValue && entity.Version != version.Value)
throw new MiGu.DB.Abstractions.Exceptions.ConcurrencyConflictException(
entity.GetType().Name, entity.Id, version);
}
=> Repo<T>().GetEditableAsync(id, version);
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
@@ -882,19 +830,13 @@ public sealed class WmsService
return false;
}
private static void EnsureUnlocked(EntityBase entity)
{
if (entity.IsLock)
throw new MiGu.DB.Abstractions.Exceptions.EntityLockedException(entity.GetType().Name, entity.Id);
}
private static void StampCreate(EntityBase entity, string actor)
private static void StampCreate(AggregateRoot entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
private void ApplyCommon(EntityBase entity, CommonRequest req, string actor)
private void ApplyCommon(AggregateRoot entity, CommonRequest req, string actor)
{
entity.IsLock = req.IsLock;
entity.Remark = req.Remark.TrimOr("");
@@ -902,11 +844,6 @@ public sealed class WmsService
entity.UpdatedBy = actor;
}
private static async Task EnsureUnique<T>(IQueryable<T> query, System.Linq.Expressions.Expression<Func<T, bool>> predicate, string message)
{
if (await query.AnyAsync(predicate)) throw new InvalidOperationException(message);
}
private string NormalizeExtend(string? extend)
{
if (string.IsNullOrWhiteSpace(extend)) return "{}";
-1
View File
@@ -16,4 +16,3 @@ global using WmsDefaults = MiGu.DB.Domains.Wms.WmsDefaults;
global using WmsTransportTaskStatuses = MiGu.DB.Domains.Transport.WmsTransportTaskStatuses;
global using WmsTransportTriggerTypes = MiGu.DB.Domains.Transport.WmsTransportTriggerTypes;
global using WmsReservationStatuses = MiGu.DB.Domains.Transport.WmsReservationStatuses;
global using WmsDispatchStatuses = MiGu.DB.Domains.Transport.WmsDispatchStatuses;
+2 -2
View File
@@ -6,10 +6,10 @@ namespace MiGu.Server.Wms;
public sealed class WmsTransportPlanner
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsTransportPlanner(PlatformDbContext db) => _db = db;
public WmsTransportPlanner(MiGuDbContext db) => _db = db;
public async Task<TransportCandidatePreview> PreviewAsync(WmsTransportRequest request)
{
+4 -4
View File
@@ -7,12 +7,12 @@ namespace MiGu.Server.Wms;
public sealed class WmsTransportRuleService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly IEditableRepository<WmsTransportRule> _rules;
private readonly IUnitOfWork _uow;
public WmsTransportRuleService(
PlatformDbContext db,
MiGuDbContext db,
IEditableRepository<WmsTransportRule> rules,
IUnitOfWork uow)
{
@@ -71,13 +71,13 @@ public sealed class WmsTransportRuleService
await _uow.SaveChangesAsync();
}
private static void StampCreate(EntityBase entity, string actor)
private static void StampCreate(AggregateRoot entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
private static void ApplyCommon(EntityBase entity, CommonRequest req, string actor)
private static void ApplyCommon(AggregateRoot entity, CommonRequest req, string actor)
{
entity.IsLock = req.IsLock;
entity.Remark = req.Remark.TrimOr("");
+3 -3
View File
@@ -6,14 +6,14 @@ namespace MiGu.Server.Wms;
public sealed class WmsTransportTaskService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly WmsTransportPlanner _planner;
private readonly WmsService _wms;
private readonly IWmsDispatchAdapter _dispatch;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsTransportTaskService(
PlatformDbContext db,
MiGuDbContext db,
WmsTransportPlanner planner,
WmsService wms,
IWmsDispatchAdapter dispatch)
@@ -331,7 +331,7 @@ public sealed class WmsTransportTaskService
GeneratedAt = DateTimeOffset.UtcNow
};
private static void StampCreate(EntityBase entity, string actor)
private static void StampCreate(AggregateRoot entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
+3 -3
View File
@@ -9,10 +9,10 @@
"Database": {
"SchemaMode": "EnsureCreated"
},
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
"ProjectionPort": 8222,
"ReadinessTimeoutMs": 8000,
"FollowParent": false
+5 -5
View File
@@ -22,11 +22,11 @@
"Internal": {
"Token": "REPLACE_ME"
},
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
"SimpleLite": {
"_comment_Simple3": "登录后拉起调度内核 Simple3.exe;改路径请编辑下方 Simple3 节点。诊断: http://localhost:8080/api/health/simple3",
"Simple3": {
"Enabled": true,
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
"Arguments": "",
"ReadinessTimeoutMs": 15000,
"ReadinessPollIntervalMs": 250,
@@ -39,7 +39,7 @@
"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'",
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method'Method 必须是 Simple3 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.csOnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
"Ops": {
"Dispatch": {
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"section": "system",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
"version": 2,
"updatedAt": "2026-08-21T08:06:13.7109039+00:00",
"payload": {
"dispatchLoopHz": 50,
"log": {
@@ -11,7 +11,7 @@
},
"security": {
"jwtExpireMin": 1440,
"enableSwagger": false,
"enableSwagger": true,
"corsWhitelist": [
"http://localhost:5173"
]
+12 -6
View File
@@ -19,12 +19,18 @@
"id": "role-ops",
"name": "\u8FD0\u8425\u4EBA\u5458",
"description": "\u8FD0\u8425\u76D1\u63A7\u7AEF\u9ED8\u8BA4\u89D2\u8272\uFF1A\u53EF\u6267\u884C\u8FD0\u7EF4\u64CD\u4F5C\u3001\u67E5\u770B\u76D1\u63A7",
"scope": "RCSMonitor",
"scope": "*",
"pages": [
"monitor-dashboard",
"monitor-map",
"monitor-notes",
"admin-dashboard",
"admin-map-monitor",
"admin-tasks",
"admin-alarms",
"admin-config-warehouse",
"monitor-ops",
"monitor-notes"
"admin-config-ops-center",
"admin-logs",
"admin-playback"
],
"ops": [
"ops.car.pause",
@@ -87,8 +93,8 @@
"roleIds": [
"role-ops"
],
"salt": "G6c5\u002BnLEjf3fEor1LgNjyg==",
"passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg="
"salt": "F6wxGwnf/nvdbbAKp6hYoQ==",
"passwordHash": "\u002BFvJCZgUYReNSr4T6xIpZU7FoajVHe067JVaHf/Evb8="
}
]
}
+26 -26
View File
@@ -1,43 +1,43 @@
# 平台 v3 重构 — 阶段 0~6 实际改动
> 范围:把 SimpleLite WebTerminal 由"全功能浮动 Panel"模式收敛到"纯 3D Canvas",所有 UI 改由 Vue 平台前端 (`frontends/apps/simple-platform-vue`) 接管;同时补齐反射 / 编辑 / 监控 / 编排 4 大业务面板与撤销栈、AI 生图、报警 SSE 等关键能力。
> 范围:把 Simple3 WebTerminal 由"全功能浮动 Panel"模式收敛到"纯 3D Canvas",所有 UI 改由 Vue 平台前端 (`frontends/apps/simple-platform-vue`) 接管;同时补齐反射 / 编辑 / 监控 / 编排 4 大业务面板与撤销栈、AI 生图、报警 SSE 等关键能力。
>
> 基线:上一份方案见 `ARCHITECTURE.md` v2/v3 章节;后续以本文件为准。
## 编译验证
- `dotnet build SimpleLite/SimpleLite.csproj` —— 0 警告 0 错误
- `dotnet build Simple3/Simple3.csproj` —— 0 警告 0 错误
- `dotnet build MiGu.Server/MiGu.Server.csproj` —— 0 警告 0 错误
- `build-platform-frontend.bat` —— vite build 46.5s,无 vue-tsc / ts 错误,robocopy /MIR 已同步 MiGu.Server/wwwroot
- 旧 `SimpleComposer` (WinForms) 在 v3 之外,不在本次构建目标里
- 旧 `HttpClientHelper` 提升 LangVersion=latest 修通
## 阶段 0:后端 SimpleLite 平台化(已完成)
## 阶段 0:后端 Simple3 平台化(已完成)
### 新增文件
| 文件 | 作用 |
|------|------|
| `SimpleLite/Web/MapEditApiController.cs` | 写操作 8 路 APIcreate/delete/batch/copy-to/pick/dashboard/upload/ai-generate;阶段 6 又补:**project/save**、**project/load**、**project/current**、**sample-sites-along-track** |
| `SimpleLite/Web/ReflectionApiController.cs` | 只读 + execute APIlistKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
| `SimpleLite/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
| `SimpleLite/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
| `SimpleLite/Web/AiConfigController.cs` | AI 服务配置(endpoint/apiKey/model/system prompt/temperature/maxTokens/timeout)读写 |
| `SimpleLite/Web/AlarmStreamService.cs` | 1s 周期 diff `car.status.enums.AlarmInfo`(语义与 `E:\Work\FRLD\StandardScene\Forklift.cs``Commons.GetCarStatus(this, "AlarmInfo")` 等价),变化时通过 SSE `alarm` 事件推送 raise/update/clear |
| `SimpleLite/Web/ProjectionStreamModule.cs` | 平台 SSE 总线,对外广播 `object-created` / `object-deleted` / `object-patched` / `object-batch-changed` / `alarm` / `pick-result` / `project-saved` / `project-loaded` |
| `SimpleLite/Web/PluginAssemblyScan.cs` | 反射扫描 plugins/*.dll,把 `MethodMember` 标注的方法 + Car/Mission/CarProgram/Site/Track 子类型暴露给反射 API |
| `Simple3/Web/MapEditApiController.cs` | 写操作 8 路 APIcreate/delete/batch/copy-to/pick/dashboard/upload/ai-generate;阶段 6 又补:**project/save**、**project/load**、**project/current**、**sample-sites-along-track** |
| `Simple3/Web/ReflectionApiController.cs` | 只读 + execute APIlistKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
| `Simple3/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
| `Simple3/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
| `Simple3/Web/AiConfigController.cs` | AI 服务配置(endpoint/apiKey/model/system prompt/temperature/maxTokens/timeout)读写 |
| `Simple3/Web/AlarmStreamService.cs` | 1s 周期 diff `car.status.enums.AlarmInfo`(语义与 `E:\Work\FRLD\StandardScene\Forklift.cs``Commons.GetCarStatus(this, "AlarmInfo")` 等价),变化时通过 SSE `alarm` 事件推送 raise/update/clear |
| `Simple3/Web/ProjectionStreamModule.cs` | 平台 SSE 总线,对外广播 `object-created` / `object-deleted` / `object-patched` / `object-batch-changed` / `alarm` / `pick-result` / `project-saved` / `project-loaded` |
| `Simple3/Web/PluginAssemblyScan.cs` | 反射扫描 plugins/*.dll,把 `MethodMember` 标注的方法 + Car/Mission/CarProgram/Site/Track 子类型暴露给反射 API |
### 修改文件
| 文件 | 关键改动 |
|------|----------|
| `SimpleLite/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
| `SimpleLite/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
| `SimpleLite/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
| `SimpleLite/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
| `SimpleLite/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
| `SimpleLite/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
| `SimpleLite/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel |
| `Simple3/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
| `Simple3/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
| `Simple3/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
| `Simple3/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
| `Simple3/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
| `Simple3/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
| `Simple3/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel |
### 关键 API 路径
@@ -119,7 +119,7 @@ SSE /projection/stream # 所有 SSE 事件
- 报警来源走 `useMapEditStream.onAlarm`(订阅 SSE 的 `alarm` 事件,由 AlarmStreamService 1s diff 推送)
- KPI 3 卡:在线车辆 / 运行中任务 / 排队任务
- 实时列表:左侧 Workspace3D + 右侧 VehicleMonitorPanel + SelectionDetailPanel
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 SimpleLite 3D 高亮该车(视觉上等效 flyTo)
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 Simple3 3D 高亮该车(视觉上等效 flyTo)
## 阶段 5OrchestrationView 设计与编排(已完成)
@@ -159,9 +159,9 @@ Tab 状态缓存到 sessionStorage。
### 编译相关清理
- `SimpleLite/Recording/`(之前的录制/回放实验目录)误用 `SimpleLite.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
- `SimpleLite/Props/UISite.cs``SimpleLite.Props` namespace 下 `Workspace.AddProp` 被解析成 `SimpleLite.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
- `SimpleLite/Workspace/WorkspaceBottomBar.cs``using SimpleLite.Workspace;` → 补
- `Simple3/Recording/`(之前的录制/回放实验目录)误用 `Simple3.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
- `Simple3/Props/UISite.cs``Simple3.Props` namespace 下 `Workspace.AddProp` 被解析成 `Simple3.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
- `Simple3/Workspace/WorkspaceBottomBar.cs``using Simple3.Workspace;` → 补
- `HttpClientHelper.csproj` LangVersion 7.3 → latest
## 已知 TODO(不阻塞 v3 上线)
@@ -176,16 +176,16 @@ Tab 状态缓存到 sessionStorage。
## 启动方式
```
# 1. 后端 buildSimpleLite + MiGu.Server
dotnet build SimpleLite\SimpleLite.csproj
# 1. 后端 buildSimple3 + MiGu.Server
dotnet build Simple3\Simple3.csproj
dotnet build MiGu.Server\MiGu.Server.csproj
# 2. 前端打包并同步到 wwwroot
.\build-platform-frontend.bat
# 3. 启动平台 + SimpleLite
# 3. 启动平台 + Simple3
dotnet run --project MiGu.Server # 监听 0.0.0.0:8080
# SimpleLite 端口 8222/8223 由 SimpleLite 自己启动
# Simple3 端口 8222/8223 由 Simple3 自己启动
# 4. 访问
http://127.0.0.1:8080/admin/map-editor # 地图编辑(v3 四栏壳)
+18 -18
View File
@@ -1,14 +1,14 @@
# Migu2.0
迷毂智能调度平台主仓库(平台后端 + 平台前端)。
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `SimpleLite` 仍在相邻仓库 `../Simple` 中维护。
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `Simple3` 仍在相邻仓库 `../Simple` 中维护。
## 仓库定位
- **平台后端**`MiGu.Server`ASP.NET Core 8 + YARP,默认 `:8080`
- **平台前端**`frontends/apps/simple-platform-vue`Vue 3 + Vite
- **调度内核**`SimpleLite`(不在本仓库,位于 `../Simple/SimpleLite`
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `SimpleLite.exe`
- **调度内核**`Simple3`(不在本仓库,位于 `../Simple/Simple3`
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `Simple3.exe`
## 目录结构
@@ -16,11 +16,11 @@
Migu2.0/
├── MiGu.Server/ # 平台后端入口工程
│ ├── Controllers/ # Auth/Config/Ops/Health 等 API
│ ├── Launcher/ # SimpleLite 子进程拉起与健康探测
│ ├── Launcher/ # Simple3 子进程拉起与健康探测
│ ├── Auth/ # JWT / 用户鉴权 / 内部 token
│ ├── Configs/ # 平台配置中心模型与存取
│ ├── wwwroot/ # 已构建前端静态资源(部署用)
│ ├── appsettings.json # 启动、YARP、SimpleLite 路径配置
│ ├── appsettings.json # 启动、YARP、Simple3 路径配置
│ └── README.md # 后端详细说明
├── frontends/ # 平台前端源码 workspace
│ └── apps/simple-platform-vue/
@@ -31,35 +31,35 @@ Migu2.0/
└── Doc/
```
## 与 SimpleLite 的关系
## 与 Simple3 的关系
`Migu2.0``SimpleLite` 是分仓协作关系:
`Migu2.0``Simple3` 是分仓协作关系:
- `MiGu.Server` 提供登录、权限、配置中心、平台 UI 托管与网关能力
- `SimpleLite` 提供调度内核、投影接口、3D/仿真运行时能力
- `Simple3` 提供调度内核、投影接口、3D/仿真运行时能力
- 两者通过以下链路协作:
- **进程编排**`MiGu.Server` 登录后调用 `SimpleLiteLauncher` 拉起 `SimpleLite.exe`
- **进程编排**`MiGu.Server` 登录后调用 `Simple3Launcher` 拉起 `Simple3.exe`
- **接口代理**`/api/sl/*` 通过 YARP 转发到 `http://127.0.0.1:8222/`
- **3D 页面代理**`/vr/*` 转发到 `http://127.0.0.1:8223/`
默认开发路径(可改)在 `MiGu.Server/appsettings.json`
```json
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug",
"ProjectionPort": 8222
}
```
## 快速启动(开发机)
### 1) 先编译 SimpleLite
### 1) 先编译 Simple3
```powershell
cd ..\Simple
dotnet build SimpleLite\SimpleLite.csproj -c Debug
dotnet build Simple3\Simple3.csproj -c Debug
```
### 2) 启动 MiGu.Server
@@ -74,7 +74,7 @@ dotnet run
- `admin / admin`
- `ops / ops`
> 仅调试平台页面、不拉起内核:将 `MiGu.Server/appsettings.json``SimpleLite.Enabled` 改为 `false`
> 仅调试平台页面、不拉起内核:将 `MiGu.Server/appsettings.json``Simple3.Enabled` 改为 `false`
## 前端构建与发布
@@ -106,7 +106,7 @@ cd E:\Work\Core\Simple-FR\Migu2.0
- `Jwt.Secret`
- `Internal.Token`
- `Auth.Users.*.Password`
- `SimpleLite.ExecutablePath` / `WorkingDirectory`(按部署实际路径)
- `Simple3.ExecutablePath` / `WorkingDirectory`(按部署实际路径)
推荐通过环境变量覆盖(`__` 表示层级):
@@ -119,8 +119,8 @@ $env:Auth__Users__admin__Password = "<strong-password>"
## 常见检查点
- `http://localhost:8080/api/health`:后端健康
- `http://localhost:8080/api/health/simplelite`SimpleLite 路径与端口诊断
- 访问平台时若出现 `502`:通常表示 `SimpleLite``8222` 未就绪或未启动
- `http://localhost:8080/api/health/simple3`Simple3 路径与端口诊断
- 访问平台时若出现 `502`:通常表示 `Simple3``8222` 未就绪或未启动
## 相关文档
+6 -6
View File
@@ -5,7 +5,7 @@
> 通过路由前缀 `/admin/*``/monitor/*` 切换,后续可按 ARCHITECTURE.md §13 拆分为独立工程。
>
> 落地范围与依据:本目录是 [ARCHITECTURE.md](../ARCHITECTURE.md) v1.7 §6/§9/§13/§17/§18 中前端形态的初版骨架;
> 真实业务接入由 `Platform.Server`(同仓库新增的 .NET 8 工程)反代 SimpleLite 完成。
> 真实业务接入由 `Platform.Server`(同仓库新增的 .NET 8 工程)反代 Simple3 完成。
>
> 品牌主色:默认 **工业紫** `#6a1b9a`(6 套主题可切换),详见 ARCHITECTURE.md §17.7。
> 工程包名 `simple-platform-vue` / 仓库目录 `frontends/apps/simple-platform-vue` 保持不动,避免破坏 pnpm/CI 脚本,仅用户可见文案统一为「迷毂」。
@@ -82,7 +82,7 @@ pnpm dev
## 地图监控页与 webVRender
地图监控页(`/admin/map-monitor``/monitor/map`)通过 `<Workspace3D>` 组件以 iframe 嵌入
SimpleLite WebTerminal 提供的 webVRender,默认 URL
Simple3 WebTerminal 提供的 webVRender,默认 URL
```
http://localhost:8223/?scope=Platform&token=<jwt>&ro=0 # 管理员,可交互
@@ -93,10 +93,10 @@ http://localhost:8223/?scope=RCSMonitor&token=<jwt>&ro=1 # 运营,只读
### iframe 嵌入注意事项(CORS / X-Frame-Options
- 当前 SimpleLite WebTerminal 暂未声明 `X-Frame-Options`/`Content-Security-Policy: frame-ancestors`
- 当前 Simple3 WebTerminal 暂未声明 `X-Frame-Options`/`Content-Security-Policy: frame-ancestors`
浏览器多数情况下会按默认策略放行同源 / 局域网嵌入。
- 若浏览器控制台报「Refused to display 'http://localhost:8223/' in a frame」,请在 SimpleLite WebTerminal
侧加上 `X-Frame-Options: ALLOWALL``Content-Security-Policy: frame-ancestors *`(本轮代码未改动 SimpleLite)。
- 若浏览器控制台报「Refused to display 'http://localhost:8223/' in a frame」,请在 Simple3 WebTerminal
侧加上 `X-Frame-Options: ALLOWALL``Content-Security-Policy: frame-ancestors *`(本轮代码未改动 Simple3)。
- 若需通过 `Platform.Server` 走同源代理,可以让 iframe 指向 `http://localhost:8080/vr/`,由 YARP
转发到 `http://127.0.0.1:8223/`(见 `Platform.Server/appsettings.json``vrender-route`)。
@@ -107,7 +107,7 @@ http://localhost:8223/?scope=RCSMonitor&token=<jwt>&ro=1 # 运营,只读
- `/api/config/{section}``Platform.Server` `ConfigController`14 维度占位)
- `/api/health``Platform.Server` `HealthController`
- `/api/projection/*``Platform.Server` `ProjectionController`(占位)
- `/api/sl/*` → YARP 反代 SimpleLite WebAPI `http://127.0.0.1:8222/`
- `/api/sl/*` → YARP 反代 Simple3 WebAPI `http://127.0.0.1:8222/`
- `/api/sl/ops/execute``/api/sl/ops/audits``Platform.Server` `OpsController`(白名单网关占位)
### Mock 开关:`VITE_USE_MOCK`
-1
View File
@@ -80,7 +80,6 @@ declare module 'vue' {
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScopeSwitcher: typeof import('./src/components/ScopeSwitcher.vue')['default']
SelectionDetailPanel: typeof import('./src/components/workbench/SelectionDetailPanel.vue')['default']
SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default']
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
@@ -8,7 +8,8 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"wcs-proto:selfcheck": "npx --yes tsx src/wcs-proto/selfcheck.ts"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -3,7 +3,7 @@ import http from './http'
/**
* AI API/ axios fetch SSE
* EventSource GET Authorization headerfetch
* axios Cookie + Bearer + X-Scope SimpleLite `Web/Assistant/AssistantApi.cs`
* axios Cookie + Bearer + X-Scope Simple3 `Web/Assistant/AssistantApi.cs`
*/
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
@@ -120,7 +120,7 @@ export async function streamChat(
if (!resp.ok || !resp.body) {
let msg = `请求失败(HTTP ${resp.status}`
if (resp.status === 403) msg = '无权使用 AI 助手(需要 Platform 权限)。'
else if (resp.status === 502 || resp.status === 504) msg = 'SimpleLite 未连接:请先启动后端(端口 8222)。'
else if (resp.status === 502 || resp.status === 504) msg = 'Simple3 未连接:请先启动后端(端口 8222)。'
else {
try {
const t = await resp.text()
@@ -20,7 +20,7 @@ export async function listDeliveries(opts?: {
return Array.isArray(data) ? data : []
}
/** CDM 任务快照订阅结果:来自平台库 cdm_tasksSimpleLite 关闭时仍可读,含完整历史)。 */
/** CDM 任务快照订阅结果:来自平台库 cdm_tasksSimple3 关闭时仍可读,含完整历史)。 */
export interface CdmTaskFeed {
online: boolean
lastSyncAt: string | null
@@ -32,7 +32,7 @@ export async function fetchFleetHealth(): Promise<FleetHealthRow[]> {
await new Promise((r) => setTimeout(r, 120))
return mockFleetHealth()
}
// 平台侧聚合:SimpleLite 指标 + WatchDog(:9776) TCP RTT。
// 平台侧聚合:Simple3 指标 + WatchDog(:9776) TCP RTT。
// 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。
try {
const { data } = await http.get<FleetHealthRow[]>('/fleet/health')
@@ -7,7 +7,7 @@ export interface HealthInfo {
uptimeSec: number
}
export interface SimpleLiteDiagnostics {
export interface Simple3Diagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
@@ -18,7 +18,7 @@ export interface SimpleLiteDiagnostics {
deployHint?: string | null
}
export interface SimpleLiteLaunchResult {
export interface Simple3LaunchResult {
started: boolean
status: string
detail: string
@@ -30,29 +30,26 @@ export function getHealth() {
return http.get<HealthInfo>('/health')
}
export function getSimpleLiteDiagnostics() {
return http.get<SimpleLiteDiagnostics>('/health/simplelite')
export function getSimple3Diagnostics() {
return http.get<Simple3Diagnostics>('/health/simple3')
}
export function stopSimpleLite() {
return http.post<{ killed: number; diagnostics: SimpleLiteDiagnostics }>('/health/simplelite/stop')
export function stopSimple3() {
return http.post<{ killed: number; diagnostics: Simple3Diagnostics }>('/health/simple3/stop')
}
export function restartSimpleLite(launchMode: LaunchMode) {
return http.post<{ restart: SimpleLiteLaunchResult; diagnostics: SimpleLiteDiagnostics }>(
'/health/simplelite/restart',
export function restartSimple3(launchMode: LaunchMode) {
return http.post<{ restart: Simple3LaunchResult; diagnostics: Simple3Diagnostics }>(
'/health/simple3/restart',
null,
{ params: { launchMode }, timeout: 60_000 }
)
}
/** 从诊断/会话 runMode 推断重启时使用的 launchMode。 */
/** Simple3 仅 Web;重启一律 WebOnly(保留函数签名供旧调用方)。 */
export function resolveRestartLaunchMode(
lastLaunchMode: string | null | undefined,
runMode: RunMode | null | undefined
_lastLaunchMode?: string | null,
_runMode?: RunMode | null
): LaunchMode {
const mode = (lastLaunchMode ?? '').toLowerCase()
if (mode === 'web') return 'WebOnly'
if (mode === 'web+local') return 'DesktopAndWeb'
return runMode === 'WebOnly' ? 'WebOnly' : 'DesktopAndWeb'
return 'WebOnly'
}
@@ -13,6 +13,10 @@ const http: AxiosInstance = axios.create({
})
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
// FormData 必须由浏览器自动带 multipart boundary;清掉默认 application/json。
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers.delete('Content-Type')
}
// 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带;
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
const token = localStorage.getItem('simple.auth.token')
@@ -27,21 +31,21 @@ http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
})
/**
* axios SimpleLite
* axios Simple3
*
* /api/sl/projection/** Platform.Server YARP SimpleLite EmbedIO :8222
* /api/sl/projection/** Platform.Server YARP Simple3 EmbedIO :8222
*
* SimpleLite / YARP 502 504
* SimpleLite SimpleLite ( 8222)
* Simple3 / YARP 502 504
* Simple3 Simple3 ( 8222)
* `Request failed with status code 502` 怀 bug
*/
function describeError(err: AxiosError): string {
const status = err.response?.status
const url = err.config?.url ?? ''
const isSimpleLite = url.startsWith('/sl/') || url.startsWith('sl/')
const isSimple3 = url.startsWith('/sl/') || url.startsWith('sl/')
if (status === 502 || status === 504 || err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK') {
if (isSimpleLite) {
return 'SimpleLite 未连接:请先启动 SimpleLite 后端(监听端口 8222)后重试。'
if (isSimple3) {
return 'Simple3 未连接:请先启动 Simple3 后端(监听端口 8222)后重试。'
}
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
}
@@ -3,7 +3,7 @@ import http from './http'
/**
* MiGu.Server `LogsController``/api/logs/*`
*
* SimpleLite `Diagnosis.Post / Diagnosis.Log` `log/{日期}/xxx.log`
* Simple3 `Diagnosis.Post / Diagnosis.Log` `log/{日期}/xxx.log`
* DLog / / /
* / /
*
@@ -14,10 +14,10 @@ import http from './http'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
/** Simple3 projection 投影 API 统一信封(同 reflection.ts)。 */
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
/** 经 YARP 反代到 Simple3 EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
const SL_DIAG = '/sl/projection/diagnosis'
export interface LogEntry {
@@ -47,6 +47,31 @@ export interface LogDayStat {
bytes: number
}
export interface LogDiskStatus {
known: boolean
drive: string
freeGB: number
alertEnabled: boolean
alertGB: number
belowThreshold: boolean
}
export interface LogCleanupConfig {
enabled: boolean
retentionDays: number
checkIntervalHours: number
runOnStartup: boolean
diskAlertEnabled: boolean
diskFreeAlertGB: number
diskCheckIntervalMinutes: number
}
export interface LogCleanupResult {
deletedFiles: number
freedBytes: number
freedMB: number
}
export interface LogOverview {
exists: boolean
root: string | null
@@ -56,6 +81,7 @@ export interface LogOverview {
latestFileTime?: string | null
days?: LogDayStat[]
message?: string
disk?: LogDiskStatus
}
export interface LogFilesResult {
@@ -116,7 +142,7 @@ export interface DigestQuery {
maxPerTag?: number
}
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
/** 实时诊断单条:对应 Simple3 内核 Diagnosis 的内存态 Post/Toast。 */
export interface LiveDiagItem {
index: number
time: string
@@ -225,12 +251,25 @@ export interface AnalyzeQuery {
function mockOverview(): LogOverview {
return {
exists: true,
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
root: 'E:\\...\\Simple3\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\Simple3\\bin\\Debug',
totalFiles: 3,
totalBytes: 5_233_649,
latestFileTime: new Date().toISOString(),
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }],
disk: { known: true, drive: 'E:\\', freeGB: 118.7, alertEnabled: true, alertGB: 5, belowThreshold: false }
}
}
function mockCleanupConfig(): LogCleanupConfig {
return {
enabled: true,
retentionDays: 30,
checkIntervalHours: 24,
runOnStartup: true,
diskAlertEnabled: true,
diskFreeAlertGB: 5,
diskCheckIntervalMinutes: 30
}
}
@@ -285,7 +324,7 @@ function mockLiveDiagnosis(): LiveDiagnosis {
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[Simple3] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
]
const taggedCount = items.filter((i) => i.tagged).length
@@ -373,7 +412,7 @@ export const logsApi = {
? Promise.resolve(mockDigest())
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
/** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 502。 */
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
? Promise.resolve(mockLiveDiagnosis())
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
@@ -398,5 +437,17 @@ export const logsApi = {
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
downloadUrl: (file: string): string =>
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`,
cleanupConfig: (): Promise<LogCleanupConfig> => MOCK
? Promise.resolve(mockCleanupConfig())
: http.get<LogCleanupConfig>('/logs/cleanup-config').then((r) => r.data),
saveCleanupConfig: (body: LogCleanupConfig): Promise<LogCleanupConfig> => MOCK
? Promise.resolve({ ...body })
: http.put<LogCleanupConfig>('/logs/cleanup-config', body).then((r) => r.data),
cleanupNow: (): Promise<LogCleanupResult> => MOCK
? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 })
: http.post<LogCleanupResult>('/logs/cleanup-now').then((r) => r.data)
}
@@ -2,8 +2,8 @@ import type { AxiosError } from 'axios'
import http from './http'
/**
* SimpleLite `MapEditApiController`
* `/sl/projection/map-edit`YARP SimpleLite `/projection/map-edit`
* Simple3 `MapEditApiController`
* `/sl/projection/map-edit`YARP Simple3 `/projection/map-edit`
*
* AI `/sl/projection/ai-config`GET / POST AiConfigController
*
@@ -205,7 +205,7 @@ export const mapEditApi = {
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
/**
* CAD SimpleLite CadAlignService
* CAD Simple3 CadAlignService
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
*/
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
@@ -264,8 +264,15 @@ export const mapEditApi = {
),
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
pick: (opts?: { snap?: boolean }) =>
unwrap<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
pick: (opts?: { snap?: boolean; signal?: AbortSignal }) =>
unwrap<PickResult>(http.post(
`${BASE}/pick`,
{ snap: opts?.snap ?? true },
{ timeout: 0, signal: opts?.signal }
)),
cancelPick: () =>
unwrap<{ cancelled: boolean }>(http.post(`${BASE}/pick/cancel`)),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
@@ -300,9 +307,9 @@ export const mapEditApi = {
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
/**
* SimpleLite Windows
* Simple3 Windows
* JSON { path, cancelled }cancelled=true path null
* initialDir SimpleLite
* initialDir Simple3
*/
projectNativePickOpen: (initialDir?: string) =>
unwrap<{ path: string | null; cancelled: boolean }>(
@@ -328,7 +335,7 @@ export const mapEditApi = {
/**
* reactive
* SimpleLite
* Simple3
*/
getViewFilter: () => unwrap<ViewFilterSnapshot>(http.get(`${BASE}/view-filter`)),
@@ -348,7 +355,7 @@ export const aiConfigApi = {
// ──────────────────────────────────────────────────────────────────────────
// 地图管理(固定文件夹 + 按名称列表 / 保存 / 删除 / 使用 / 编辑打开)
// 对应 SimpleLite MapEditApiController 的 /map-edit/maps* 接口。
// 对应 Simple3 MapEditApiController 的 /map-edit/maps* 接口。
// ──────────────────────────────────────────────────────────────────────────
export interface MapListItem {
@@ -11,6 +11,9 @@ export interface OpsExecuteReq {
targetId: string
reason?: string
idempotencyKey?: string
method?: string
params?: Record<string, string>
siteId?: number
}
export interface OpsExecuteResp {
@@ -47,7 +47,6 @@ export async function uploadOtaPackage(file: File): Promise<OtaPackageInfo> {
const form = new FormData()
form.append('file', file)
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000
})
return data
@@ -116,7 +115,6 @@ export async function pushOtaCustomFile(opts: {
form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1]))
for (const f of opts.files) form.append('files', f)
const { data } = await http.post<OtaJob>('/ota/custom-file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000
})
return data
@@ -6,7 +6,7 @@ import type { Mission, MissionStatus } from '@/types/mission'
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
/** true 使 Mock YARP SimpleLite :8222
/** true 使 Mock YARP Simple3 :8222
* auth.ts / config.ts / ops.ts VITE_USE_MOCK VITE_PROJECTION_MOCK */
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
@@ -114,7 +114,7 @@ async function enrichCarsRuntime(cars: Car[]): Promise<Car[]> {
)
}
/** 优先走 SimpleLite /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
/** 优先走 Simple3 /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
export async function listCars(): Promise<Car[]> {
if (MOCK) return mockCars()
try {
@@ -19,10 +19,10 @@ import {
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/**
* SimpleLite `ReflectionApiController`
* `/sl/projection/reflection` YARP SimpleLite EmbedIO
* Simple3 `ReflectionApiController`
* `/sl/projection/reflection` YARP Simple3 EmbedIO
*
* SimpleLite plugins/*.dll Standard
* Simple3 plugins/*.dll Standard
* `MethodMember` Car/Mission/CarProgram/Site/Track
*
*/
@@ -39,7 +39,7 @@ export type ReflectionKind =
| 'map'
| 'special'
| 'script'
// 顶级合成 kind:等于 site + track + special 的并集,由 SimpleLite 后端 /objects/scene 直接返回,
// 顶级合成 kind:等于 site + track + special 的并集,由 Simple3 后端 /objects/scene 直接返回,
// 每行带 subKind 字段,前端 bundle / execute / setField 走对应底层 kind。
| 'scene'
@@ -167,7 +167,7 @@ export interface ScriptExceptionStatusPayload {
// 地图监控可见性配置(与后端 MonitorVisibilityConfig / MonitorVisibilityForKind 对应)
//
// `config` = 管理员勾选的白名单。空数组 = 显示全部;非空 = 仅显示列表中的 key。
// `available` = 当前 SimpleLite 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
// `available` = 当前 Simple3 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
// 运行时实例的 Prop.fields / status 反射键)。是 GET 返回的副产物,
// POST 写入时不需要、也不该回传。
// ──────────────────────────────────────────────────────────────────────────
@@ -253,17 +253,23 @@ export function formatReflectionExecuteMessage(
): string {
if (result.returnValue) return `已执行:${result.returnValue}`
if (result.accepted && result.completed === false) {
return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)`
return `已受理:${label}(后台继续执行,请稍后在 Simple3 查看结果)`
}
if (result.accepted) return `已执行 ${label}`
return `已执行 ${label}`
}
export interface ReflectionExecuteOptions {
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
/** 已在平台侧完成二次确认时带上;新内核仍需再带 428 下发的 confirmToken。 */
platformConfirmed?: boolean
}
interface PlatformConfirmChallenge {
message: string
token: string
header: string
}
async function get<T>(path: string): Promise<T> {
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
@@ -275,9 +281,69 @@ async function post<T>(
params?: Record<string, string | number | boolean>,
headers?: Record<string, string>
): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
try {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
} catch (e) {
if (e instanceof ReflectionApiError) throw e
throw wrapEnvelopeError(e, `reflection ${path} failed`)
}
}
function wrapEnvelopeError(e: unknown, fallback: string): unknown {
const resp = e && typeof e === 'object' && 'response' in e
? (e as { response?: { status?: number; data?: ReflectionEnvelope<unknown> } }).response
: undefined
const data = resp?.data
if (data && typeof data === 'object' && (data.success === false || typeof data.code === 'number')) {
return new ReflectionApiError(
data.message || fallback,
data.code ?? resp?.status ?? 500,
data.data
)
}
return e
}
const CONFIRM_TOKEN_HEADER = 'X-Platform-Confirm-Token'
function parseConfirmChallenge(e: unknown): PlatformConfirmChallenge | null {
const payload = unwrapConfirmPayload(e)
const token = payload?.confirmToken?.trim()
if (!token) return null
const rawHeader = payload?.confirmTokenHeader?.trim()
return {
message: payload?.confirmMessage?.trim() || '此操作需要确认',
token,
header: rawHeader && rawHeader.toLowerCase() === CONFIRM_TOKEN_HEADER.toLowerCase()
? rawHeader
: CONFIRM_TOKEN_HEADER
}
}
function unwrapConfirmPayload(e: unknown): {
confirmMessage?: string | null
confirmToken?: string | null
confirmTokenHeader?: string | null
} | null {
if (e instanceof ReflectionApiError) {
if (e.code !== 428) return null
return (e.data as {
confirmMessage?: string | null
confirmToken?: string | null
confirmTokenHeader?: string | null
} | null) ?? null
}
const resp = e && typeof e === 'object' && 'response' in e
? (e as { response?: { status?: number; data?: ReflectionEnvelope<Record<string, string>> } }).response
: undefined
const data = resp?.data
const code = typeof data?.code === 'number' ? data.code : resp?.status
if (code !== 428) return null
const inner = data?.data
if (inner && typeof inner === 'object') return inner
return null
}
async function del<T>(path: string): Promise<T> {
@@ -297,22 +363,24 @@ async function executeWithPlatformConfirm<T>(
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
): Promise<T> {
const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined
const headers: Record<string, string> = {}
if (opts?.platformConfirmed) headers['X-Platform-Confirmed'] = '1'
try {
return await post<T>(path, params, headers)
return await post<T>(path, params, Object.keys(headers).length ? headers : undefined)
} catch (e) {
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
const confirmMessage =
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|| '此操作需要确认'
await ElMessageBox.confirm(confirmMessage, '确认', {
const challenge = parseConfirmChallenge(e)
if (!challenge) throw e
if (!opts?.platformConfirmed) {
await ElMessageBox.confirm(challenge.message, '确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
return await post<T>(path, params, { 'X-Platform-Confirmed': '1' })
}
throw e
return await post<T>(path, params, {
'X-Platform-Confirmed': '1',
[challenge.header]: challenge.token
})
}
}
@@ -367,7 +435,7 @@ export const reflectionApi = {
: del<{ kind: string; id: number; deleted: boolean }>(`/objects/${kind}/${id}`),
/**
* SimpleLite ./plugins dll UiDiscoveryCache
* Simple3 ./plugins dll UiDiscoveryCache
* dll
* dll assembly mission/car
*/
@@ -515,7 +583,7 @@ export const reflectionApi = {
return data.data as MonitorConfigSaveBody
},
// 选中同步:让 SimpleLite 3D 场景同步高亮被点击对象
// 选中同步:让 Simple3 3D 场景同步高亮被点击对象
getSelection: () => MOCK
? Promise.resolve<ReflectionSelection>({ kind: null, id: 0, name: '' })
: get<ReflectionSelection>('/selection'),
@@ -685,7 +753,7 @@ export const reflectionApi = {
: post<{ path: string }>('/car-style/save')
}
/** 与 SimpleLite 视口样式对话框 / Configuration.conf.viewport 同步。 */
/** 与 Simple3 视口样式对话框 / Configuration.conf.viewport 同步。 */
export interface ViewportStyleSite {
drawScale: number
dotRadiusNormal: number
@@ -0,0 +1,98 @@
import { listCars, listSites, listTracks } from '@/api/projection'
import { reflectionApi } from '@/api/reflection'
import { getWizardProfile } from '@/api/wizard'
import type { SetupCarParamRow, SetupStatus } from '@/types/setup'
const IP_KEYS = ['address', 'ip']
const PORT_KEYS = ['port', 'magport']
function pick(rows: Array<{ key: string; value: string }>, keys: string[]): string {
const set = new Set(keys)
const hit = rows.find((r) => set.has(r.key.toLowerCase()))
return (hit?.value ?? '').trim()
}
function ipOk(v: string): boolean {
return v.length > 0 && v !== '0.0.0.0'
}
function portOk(v: string): boolean {
const n = Number(v)
return Number.isFinite(n) && n > 0 && n <= 65535
}
async function inspectCarParams(rawId: number, name: string): Promise<SetupCarParamRow> {
try {
const fields = await reflectionApi.getFields('car', rawId)
const address = pick(fields, IP_KEYS)
const port = pick(fields, PORT_KEYS)
return { id: rawId, name, address, port, paramsReady: ipOk(address) && portOk(port) }
} catch {
return { id: rawId, name, address: '', port: '', paramsReady: false }
}
}
export async function loadSetupStatus(): Promise<SetupStatus> {
const empty: SetupStatus = {
carCount: 0,
carsWithParams: 0,
siteCount: 0,
trackCount: 0,
carsReady: false,
mapsReady: false,
incomplete: true,
cars: [],
navigationKinds: [],
scenarios: [],
modules: []
}
try {
const [cars, sites, tracks, profile] = await Promise.all([
listCars().catch(() => []),
listSites().catch(() => []),
listTracks().catch(() => []),
getWizardProfile().catch(() => null)
])
const inspected = await Promise.all(
cars.slice(0, 40).map((c) => {
const id = c.rawId ?? Number(String(c.id).replace(/^C/i, ''))
if (!Number.isFinite(id) || id <= 0) {
return Promise.resolve({
id: 0,
name: c.name,
address: c.address ?? c.ip ?? '',
port: '',
paramsReady: ipOk(c.address ?? c.ip ?? '')
} satisfies SetupCarParamRow)
}
return inspectCarParams(id, c.name)
})
)
const carsWithParams = inspected.filter((c) => c.paramsReady).length
const couldReadParams = inspected.some((c) => c.address || c.port || c.paramsReady)
const carsReady = cars.length >= 1 && (!couldReadParams || carsWithParams >= 1)
const mapsReady = sites.length >= 1 && tracks.length >= 1
return {
carCount: cars.length,
carsWithParams,
siteCount: sites.length,
trackCount: tracks.length,
carsReady,
mapsReady,
incomplete: !(carsReady && mapsReady),
cars: inspected,
navigationKinds: profile?.navigationKinds ?? [],
scenarios: profile?.scenarios ?? [],
modules: profile?.modules ?? []
}
} catch (e) {
return {
...empty,
error: e instanceof Error ? e.message : String(e)
}
}
}
@@ -0,0 +1,69 @@
import http from './http'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
export interface SignalColumn {
key: string
label: string
type: 'string' | 'int' | 'bool' | 'enum' | string
options?: string[] | null
group?: string | null
}
export interface SignalTableSummary {
id: string
title: string
category: string
fileName: string
}
export interface SignalTableDto extends SignalTableSummary {
exists: boolean
error?: string | null
columns: SignalColumn[]
rows: Record<string, unknown>[]
}
export interface SignalDataListDto {
signalEnabled: boolean
workingDirectory?: string | null
tables: SignalTableSummary[]
}
export async function listSignalTables(): Promise<SignalDataListDto> {
if (MOCK) {
return {
signalEnabled: true,
workingDirectory: 'D:\\工作\\stand\\Simple3',
tables: [
{ id: 'stations', title: 'PLC机构', category: 'PLC数据管理', fileName: 'stations.json' },
{ id: 'docks', title: '机构工位', category: 'PLC数据管理', fileName: 'station-docks.json' },
{ id: 'handshake', title: '握手点', category: '握手点数据管理', fileName: 'handshake-points.json' },
{ id: 'release', title: '放行点', category: '放行点数据管理', fileName: 'release-points.json' },
{ id: 'mag-control', title: '磁条管控区', category: '磁条交管', fileName: 'mag-control-areas.json' }
]
}
}
const { data } = await http.get<SignalDataListDto>('/signal-data', { params: { summary: true } })
return data
}
export async function getSignalTable(id: string): Promise<SignalTableDto> {
if (MOCK) {
const list = await listSignalTables()
const meta = list.tables.find((t) => t.id === id)
if (!meta) throw new Error(`未知数据表:${id}`)
return { ...meta, exists: true, columns: [], rows: [] }
}
const { data } = await http.get<SignalTableDto>(`/signal-data/${id}`)
return data
}
export async function saveSignalTable(id: string, rows: Record<string, unknown>[]): Promise<SignalTableDto> {
if (MOCK) {
const t = await getSignalTable(id)
return { ...t, rows: JSON.parse(JSON.stringify(rows)) }
}
const { data } = await http.put<SignalTableDto>(`/signal-data/${id}`, { rows })
return data
}
@@ -1,7 +1,16 @@
import { reflectionApi } from './reflection'
import { executeOp } from './ops'
import { ReflectionApiError, reflectionApi } from './reflection'
export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
/** 地图监控车辆列表快捷动作 */
export type VehicleQuickAction =
| 'online'
| 'offline'
| 'endTask'
| 'disable'
| 'enable'
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
@@ -9,6 +18,29 @@ const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
blown: 'Blown'
}
/** 结束任务:按车型依次尝试(Dummy=ForceStopClumsy=ForceStopUI,通用=UIIntercept */
const END_TASK_METHODS = ['ForceStopUI', 'ForceStop', 'UIIntercept'] as const
const QUICK_METHOD_MAP: Record<Exclude<VehicleQuickAction, 'endTask'>, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
disable: 'DisableCar',
enable: 'EnableCar'
}
export const VEHICLE_QUICK_ACTIONS: {
value: VehicleQuickAction
label: string
danger?: boolean
confirm: string
}[] = [
{ value: 'online', label: '上线', confirm: '确认将车辆上线?' },
{ value: 'offline', label: '下线', confirm: '确认将车辆下线?下线后不再被任务选中。' },
{ value: 'endTask', label: '结束任务', danger: true, confirm: '确认结束该车当前任务?' },
{ value: 'disable', label: '禁用车辆', danger: true, confirm: '确认禁用该车?禁用后需先启用才能重新上线。' },
{ value: 'enable', label: '启用车辆', confirm: '确认启用该车?启用后仍需上线才能参与调度。' }
]
export async function setVehicleMaintenance(
carId: number,
mode: VehicleMaintenanceMode
@@ -23,6 +55,53 @@ export async function setVehicleMaintenance(
}
}
function isUnsupportedMethodError(err: unknown): boolean {
if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true
const msg = err instanceof Error ? err.message : String(err)
return /method|not\s*found|unsupported|not\s*supported/i.test(msg)
}
async function executeCarMethodViaOps(carId: number, method: string): Promise<void> {
const resp = await executeOp({
opCode: 'ops.car.execute',
targetId: String(carId),
method
})
if (resp.ok) return
throw new Error(resp.message || `执行 ${method} 未成功`)
}
/** 执行车辆快捷动作;失败抛错,由调用方提示。经运维网关以便写入运维记录。 */
export async function executeVehicleQuickAction(
carId: number,
action: VehicleQuickAction
): Promise<void> {
if (!Number.isFinite(carId) || carId <= 0) {
throw new Error('无效车辆 ID')
}
if (action === 'endTask') {
let lastErr: unknown
for (const method of END_TASK_METHODS) {
try {
await executeCarMethodViaOps(carId, method)
return
} catch (err) {
lastErr = err
if (!isUnsupportedMethodError(err) && !isUnconfiguredMethodError(err)) throw err
}
}
if (lastErr) throw lastErr
throw new Error('当前车型不支持结束任务')
}
const method = QUICK_METHOD_MAP[action]
await executeCarMethodViaOps(carId, method)
}
function isUnconfiguredMethodError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err)
return /不在该车型已配置|未配置动作/.test(msg)
}
export function openOnboardWeb(url?: string | null, ip?: string | null): void {
const target = url ?? (ip ? `http://${ip}:8081` : null)
if (!target) return
@@ -29,6 +29,24 @@ const MOCK_OPTIONS: WizardOptions = {
}
}
const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.mag',
qrcode: 'scene.qrlidar',
laser: 'scene.qrlidar'
}
function toLauncherSceneIds(kinds: string[], scenarios: string[] = []): string[] {
const result: string[] = []
for (const k of kinds) {
const id = NAV_SCENE[k] ?? `scene.${k}`
if (!result.includes(id)) result.push(id)
}
const wantSignal = scenarios.some((s) => s === 'tpl-sps' || s === 'tpl-pack')
if (wantSignal && !result.includes('scene.signal')) result.push('scene.signal')
if (result.length > 0 && !result.includes('scene.device')) result.push('scene.device')
return result
}
let mockProfile: DeploymentProfileDto = {
configured: false,
platformType: 'standard',
@@ -61,7 +79,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
navigationKinds: req.navigationKinds ?? [],
scenarios: req.scenarios ?? [],
configured: true,
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [], req.scenarios ?? [])
}
return mockProfile
}
@@ -1,14 +1,14 @@
import http from './http'
/**
* API SimpleLite `WorkspaceToolbarApiController`
* API Simple3 `WorkspaceToolbarApiController`
*
* 33 SimpleLite iframe ImGui (`Panel_4` /
* 33 Simple3 iframe ImGui (`Panel_4` /
* `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`) 8 iframe
* Vue
* `WorkspaceCanvasToolbar.vue` HTTP API
*
* `/sl/projection/toolbar` (Platform.Server YARP SimpleLite EmbedIO :8222)
* `/sl/projection/toolbar` (Platform.Server YARP Simple3 EmbedIO :8222)
*/
const BASE = '/sl/projection/toolbar'
@@ -155,7 +155,7 @@ export const workspaceToolbarApi = {
/**
* + 2D
* SimpleLite `WorkspaceToolbarApiController.LocateCamera` = +
* Simple3 `WorkspaceToolbarApiController.LocateCamera` = +
*/
locateCamera: (carId: number) =>
unwrap<ToolbarState>(
@@ -1,6 +1,18 @@
<template>
<PermissionGuard widget-id="ConfigCenter">
<el-card shadow="never">
<slot
v-if="$slots.chrome"
name="chrome"
:payload="payload"
:update="update"
:save="save"
:reload="reload"
:saving="saving"
:loading="loading"
:envelope="envelope"
:relative-time="relativeTime"
/>
<el-card v-else shadow="never">
<template #header>
<div class="cpb-header">
<span class="cpb-title">{{ title }}</span>
@@ -9,7 +21,7 @@
<el-tag size="small" type="success" effect="plain" v-if="envelope?.updatedAt">{{ relativeTime }}</el-tag>
<div class="spacer" />
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save">保存</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save()">保存</el-button>
</div>
</template>
@@ -50,11 +62,11 @@ const props = defineProps<{
defaults: T
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
normalizePayload?: (payload: T) => T
/** 加载完成后二次合并(如从 SimpleLite 拉 monitor */
/** 加载完成后二次合并(如从 Simple3 拉 monitor */
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
/** 写入 Platform 之前先执行(如先落盘 SimpleLite monitor */
/** 写入 Platform 之前先执行(如先落盘 Simple3 monitor */
beforeSave?: (payload: T) => void | Promise<void>
/** 保存成功后副作用(如再次同步 SimpleLite */
/** 保存成功后副作用(如再次同步 Simple3 */
afterSave?: (payload: T) => void | Promise<void>
}>()
@@ -98,7 +110,8 @@ async function reload(force = false) {
await reloadFromServer(force, force)
}
async function save() {
async function save(opts?: { toast?: boolean }): Promise<boolean> {
const showToast = typeof opts === 'object' && opts && 'toast' in opts ? opts.toast !== false : true
saving.value = true
let body = payload.value
if (props.normalizePayload) body = props.normalizePayload(body)
@@ -109,7 +122,7 @@ async function save() {
} catch (e) {
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
saving.value = false
return
return false
}
}
@@ -122,14 +135,16 @@ async function save() {
try {
await props.afterSave(body)
} catch (e) {
monitorSyncWarn = `(平台配置已保存,但 SimpleLite 同步失败:${e instanceof Error ? e.message : String(e)}`
monitorSyncWarn = `(平台配置已保存,但 Simple3 同步失败:${e instanceof Error ? e.message : String(e)}`
}
}
await reloadFromServer(true, false)
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 SimpleLite' : ''
ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 Simple3' : ''
if (showToast) ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
return true
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
return false
} finally {
saving.value = false
}

Some files were not shown because too many files have changed in this diff Show More