Compare commits
18
Commits
bc3e9c5172
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0788fad74a | ||
|
|
3968f5dc1f | ||
|
|
c9e835aaf7 | ||
|
|
c5d5f7a726 | ||
|
|
59ccd7934a | ||
|
|
79c9e36d89 | ||
|
|
4180140ae4 | ||
|
|
3686abdc78 | ||
|
|
912c44c9bb | ||
|
|
4962d768d4 | ||
|
|
95205cbcde | ||
|
|
4ee6379c26 | ||
|
|
c5c1578696 | ||
|
|
134c7480b3 | ||
|
|
5d277cf17d | ||
|
|
125372b157 | ||
|
|
1f72488a8d | ||
|
|
7af29fe07f |
+4
-2
@@ -13,7 +13,7 @@ MiGu.Server/data/*.db
|
|||||||
MiGu.Server/data/*.db-shm
|
MiGu.Server/data/*.db-shm
|
||||||
MiGu.Server/data/*.db-wal
|
MiGu.Server/data/*.db-wal
|
||||||
MiGu.Server/data/.internal-token
|
MiGu.Server/data/.internal-token
|
||||||
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录,*.json 上面那行不递归)
|
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
|
||||||
MiGu.Server/data/ota/
|
MiGu.Server/data/ota/
|
||||||
|
|
||||||
# 前端构建产物
|
# 前端构建产物
|
||||||
@@ -34,10 +34,12 @@ _build_out.txt
|
|||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
/.cursor/rules
|
/.cursor/
|
||||||
/MiGu.Server/wwwroot
|
/MiGu.Server/wwwroot
|
||||||
/MiGu.Server/data/platform.db
|
/MiGu.Server/data/platform.db
|
||||||
|
|
||||||
# 临时脚本 / 工具产物(不应入库)
|
# 临时脚本 / 工具产物(不应入库)
|
||||||
.codex-temp/
|
.codex-temp/
|
||||||
frontends/apps/simple-platform-vue/imgui.ini
|
frontends/apps/simple-platform-vue/imgui.ini
|
||||||
|
/.cursor/plans
|
||||||
|
/tmp-*.json
|
||||||
|
|||||||
+116
-116
@@ -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
|
> 文档版本:v1.7.0
|
||||||
> 更新日期:2026-05-20
|
> 更新日期: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 变更(多主题色板 · 工业紫默认):
|
> v1.7.0 变更(多主题色板 · 工业紫默认):
|
||||||
> - **6 套可切换主题**:`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
|
> - **6 套可切换主题**:`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
|
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
|
||||||
|
|
||||||
> v1.4 变更(保留):
|
> 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 独立部署的服务。
|
> - **整体定位章节重写**:明确"两类后端可执行文件 + 一个进程编排者"的关系,避免读者误以为 Platform 是用 Windows Service / systemd 独立部署的服务。
|
||||||
|
|
||||||
> v1.3 变更(保留):
|
> v1.3 变更(保留):
|
||||||
> 1. **取消 RCSMonitor.Server**:`rcsmonitor-vue` 不再有独立后端进程,直接连 MiGu.Server;由 JWT 中的 `scope=RCSMonitor` + 角色权限码 + `WidgetGrant` 三层限制可见控件与可调用 API。
|
> 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)。
|
> 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/*` → SimpleLite WebAPI 的反代;带鉴权与审计中间件。
|
> 3. **反向代理选定 YARP**:MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → Simple3 WebAPI 的反代;带鉴权与审计中间件。
|
||||||
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
|
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
|
||||||
> 5. **Web-Enabled 下严禁同时本地操作**:CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
|
> 5. **Web-Enabled 下严禁同时本地操作**:CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
|
||||||
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
|
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
|
||||||
@@ -79,43 +79,43 @@
|
|||||||
|
|
||||||
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
|
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
|
||||||
|
|
||||||
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 SimpleLite 统一编排:**
|
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 Simple3 统一编排:**
|
||||||
|
|
||||||
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|
||||||
|----|----------|----------------|------------|------|--------|
|
|----|----------|----------------|------------|------|--------|
|
||||||
| **SimpleLite 桌面端** | CycleGUI 客户端 | `SimpleLite.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission)** | **全功能** |
|
| **Simple3 桌面端** | CycleGUI 客户端 | `Simple3.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission)** | **全功能** |
|
||||||
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起**(Web-Enabled 时自动 spawn;崩溃由 SimpleLite Watchdog 重启) | **SimpleLite 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 SimpleLite) |
|
| **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 一起被 SimpleLite 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
|
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 Simple3 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
|
||||||
|
|
||||||
**关键澄清(v1.4 重点):**
|
**关键澄清(v1.4 重点):**
|
||||||
|
|
||||||
- **后端可执行文件只有 2 个**:`SimpleLite.exe`、`MiGu.Server.exe`。
|
- **后端可执行文件只有 2 个**:`Simple3.exe`、`MiGu.Server.exe`。
|
||||||
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `SimpleLite.exe`,MiGu.Server 由 SimpleLite 自己拉起,**不需要也不允许独立安装为 Windows Service**。
|
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `Simple3.exe`,MiGu.Server 由 Simple3 自己拉起,**不需要也不允许独立安装为 Windows Service**。
|
||||||
- **进程编排归属 SimpleLite**:`SystemMission` 是 SimpleLite 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
|
- **进程编排归属 Simple3**:`SystemMission` 是 Simple3 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
|
||||||
- **Vue 两份产物,同一进程托管**:MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`。
|
- **Vue 两份产物,同一进程托管**:MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`。
|
||||||
- **鉴权与限权完全在 MiGu.Server 完成**:RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
|
- **鉴权与限权完全在 MiGu.Server 完成**:RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
|
||||||
- **Desktop-Only 模式下,SystemMission 不触发**:MiGu.Server 根本不会启动,Vue 端无法访问。
|
- **Desktop-Only 模式下,SystemMission 不触发**:MiGu.Server 根本不会启动,Vue 端无法访问。
|
||||||
|
|
||||||
> 一句话:**用户点 SimpleLite.exe,由 SimpleLite 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
|
> 一句话:**用户点 Simple3.exe,由 Simple3 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
|
||||||
|
|
||||||
### 1.2 核心拆分原则
|
### 1.2 核心拆分原则
|
||||||
|
|
||||||
1. **Crash-Isolation(搞不崩主服务)**
|
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. **共享数据模型,独立运行时**
|
2. **共享数据模型,独立运行时**
|
||||||
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 SimpleLite 里跑,Platform 只持有"投影"。
|
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 Simple3 里跑,Platform 只持有"投影"。
|
||||||
|
|
||||||
3. **库分离 + Provider 抽象**(详见 §7)
|
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)。
|
- 持久层基于 **EF Core**,**通过 Provider 切换底层数据库**:默认 **SQLite**(边缘单机,配 ROSE HA);可选 **MySQL / PostgreSQL / SQL Server**,匹配现场已有 IT 基础设施与企业级 HA 能力(详见 §7.4–§7.7)。
|
||||||
- 高可用方案随 Provider 不同:SQLite 用 ROSE HA;MySQL 用 InnoDB Cluster;PostgreSQL 用 Patroni;SQL Server 用 Always On AG。
|
- 高可用方案随 Provider 不同:SQLite 用 ROSE HA;MySQL 用 InnoDB Cluster;PostgreSQL 用 Patroni;SQL Server 用 Always On AG。
|
||||||
|
|
||||||
4. **统一协议层**
|
4. **统一协议层**
|
||||||
所有进程间调用统一 **WebAPI(REST/JSON)+ WebSocket 事件流**;Vue 端直接对接 Platform 后端的 REST + WS;Platform 后端用 **YARP** 反代 SimpleLite 的设计/调度 API。
|
所有进程间调用统一 **WebAPI(REST/JSON)+ WebSocket 事件流**;Vue 端直接对接 Platform 后端的 REST + WS;Platform 后端用 **YARP** 反代 Simple3 的设计/调度 API。
|
||||||
|
|
||||||
5. **统一身份**
|
5. **统一身份**
|
||||||
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
|
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),Simple3 在 Desktop-Only 下用本地用户表降级。
|
||||||
|
|
||||||
6. **运行模式互斥(强约束)**
|
6. **运行模式互斥(强约束)**
|
||||||
- **Desktop-Only**:CycleGUI 客户端独占;WebAPI / Platform **不启动**。
|
- **Desktop-Only**:CycleGUI 客户端独占;WebAPI / Platform **不启动**。
|
||||||
@@ -139,7 +139,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph Host["主控宿主机 / 边缘服务器"]
|
subgraph Host["主控宿主机 / 边缘服务器"]
|
||||||
subgraph SL["SimpleLite 主进程 (CycleGUI + ASP.NET Core 8)"]
|
subgraph SL["Simple3 主进程 (CycleGUI + ASP.NET Core 8)"]
|
||||||
direction TB
|
direction TB
|
||||||
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
|
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
|
||||||
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
|
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
|
||||||
@@ -155,7 +155,7 @@ flowchart TB
|
|||||||
direction TB
|
direction TB
|
||||||
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
|
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
|
||||||
PL_API["Platform WebAPI<br/>(配置中心 / 外部对接 / 库位 / 账号)"]
|
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_Auth["SimpleAuth<br/>(JWT 签发 + Scope 校验)"]
|
||||||
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
|
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
|
||||||
PL_DB[("SQLite: platform.db<br/>WAL")]
|
PL_DB[("SQLite: platform.db<br/>WAL")]
|
||||||
@@ -203,11 +203,11 @@ flowchart TB
|
|||||||
|
|
||||||
**要点:**
|
**要点:**
|
||||||
|
|
||||||
- **只有两个后端可执行文件**:`SimpleLite.exe` 与 `MiGu.Server.exe`;RCSMonitor 不再有独立后端。
|
- **只有两个后端可执行文件**:`Simple3.exe` 与 `MiGu.Server.exe`;RCSMonitor 不再有独立后端。
|
||||||
- **`MiGu.Server.exe` 由 SimpleLite 的 SystemMission 拉起**:用户只启动 SimpleLite;Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
|
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3;Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
|
||||||
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
|
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
|
||||||
- **`PL_YARP` 把 `/api/sl/*` 反代到 SimpleLite**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
|
- **`PL_YARP` 把 `/api/sl/*` 反代到 Simple3**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
|
||||||
- **3D 由 SimpleLite 的 webVRender 提供**:Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
|
- **3D 由 Simple3 的 webVRender 提供**:Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
|
||||||
- **Desktop-Only 模式**:`SL_API`、`PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
|
- **Desktop-Only 模式**:`SL_API`、`PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
|
||||||
- **Web-Enabled 模式**:CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
|
- **Web-Enabled 模式**:CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
|
||||||
|
|
||||||
@@ -219,8 +219,8 @@ flowchart TB
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
Start([SimpleLite.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
|
Start([Simple3.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
|
||||||
CheckPrev -- 否 --> Login[弹出登录窗<br/>SimpleLite Boot Login]
|
CheckPrev -- 否 --> Login[弹出登录窗<br/>Simple3 Boot Login]
|
||||||
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
|
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
|
||||||
Login --> Form
|
Login --> Form
|
||||||
AutoLogin --> ModeSwitch
|
AutoLogin --> ModeSwitch
|
||||||
@@ -269,7 +269,7 @@ flowchart TB
|
|||||||
|
|
||||||
| 元素 | 控件 | 说明 |
|
| 元素 | 控件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 标题 | Label | "SimpleLite · 启动登录" |
|
| 标题 | Label | "Simple3 · 启动登录" |
|
||||||
| 用户名 | TextInput | 默认上次登录用户 |
|
| 用户名 | TextInput | 默认上次登录用户 |
|
||||||
| 密码 | TextInput (password) | — |
|
| 密码 | TextInput (password) | — |
|
||||||
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
|
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
|
||||||
@@ -281,7 +281,7 @@ flowchart TB
|
|||||||
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
|
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
|
||||||
| 退出按钮 | 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 增量字段
|
#### simple.json 增量字段
|
||||||
|
|
||||||
@@ -310,18 +310,18 @@ flowchart TB
|
|||||||
| 场景 | 行为 |
|
| 场景 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
|
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
|
||||||
| Desktop-Only → Web-Enabled | **必须退出 SimpleLite 重新登录**;不支持热切(避免半启动状态) |
|
| Desktop-Only → Web-Enabled | **必须退出 Simple3 重新登录**;不支持热切(避免半启动状态) |
|
||||||
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
|
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
|
||||||
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
|
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
|
||||||
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
|
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
|
||||||
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `SimpleLite.exe --re-login` 强制弹出登录窗 |
|
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `Simple3.exe --re-login` 强制弹出登录窗 |
|
||||||
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset` 把 `rememberRunMode` 置为 `false`,**仅在下次重启时生效** |
|
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset` 把 `rememberRunMode` 置为 `false`,**仅在下次重启时生效** |
|
||||||
|
|
||||||
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
|
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌──────────────────────────────────────────┐
|
┌──────────────────────────────────────────┐
|
||||||
│ SimpleLite Service Status │
|
│ Simple3 Service Status │
|
||||||
├──────────────────────────────────────────┤
|
├──────────────────────────────────────────┤
|
||||||
│ 模式: Web-Enabled │
|
│ 模式: Web-Enabled │
|
||||||
│ 启动时间: 2026-05-18 09:00:00 │
|
│ 启动时间: 2026-05-18 09:00:00 │
|
||||||
@@ -348,7 +348,7 @@ flowchart TB
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A["SimpleLite.exe<br/>启动 + 登录"] --> Mode{运行模式}
|
A["Simple3.exe<br/>启动 + 登录"] --> Mode{运行模式}
|
||||||
|
|
||||||
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
|
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
|
||||||
D --> SchedD[调度引擎]
|
D --> SchedD[调度引擎]
|
||||||
@@ -360,7 +360,7 @@ flowchart LR
|
|||||||
WHost --> WV[WebTerminal :8223<br/>webVRender]
|
WHost --> WV[WebTerminal :8223<br/>webVRender]
|
||||||
WHost --> StatusPanel[CycleGUI 状态窗]
|
WHost --> StatusPanel[CycleGUI 状态窗]
|
||||||
|
|
||||||
subgraph SysMis["SimpleLite SystemMission 体系"]
|
subgraph SysMis["Simple3 SystemMission 体系"]
|
||||||
E["StartPlatformMission<br/>(SystemMission 子类)"]
|
E["StartPlatformMission<br/>(SystemMission 子类)"]
|
||||||
SM_Reg["SystemMissionRegistry"]
|
SM_Reg["SystemMissionRegistry"]
|
||||||
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
|
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
|
||||||
@@ -380,24 +380,24 @@ flowchart LR
|
|||||||
|
|
||||||
### 4.2 SystemMission 体系(v1.4 新增章节)
|
### 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()` 等抽象方法 |
|
| `SystemMission`(抽象) | `Simple3.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
|
||||||
| `StartPlatformMission` | `SimpleLite.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
|
| `StartPlatformMission` | `Simple3.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
|
||||||
| `SystemMissionRegistry` | `SimpleLite.Bootstrap` | 单例,注册所有 SystemMission;Web-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
|
| `SystemMissionRegistry` | `Simple3.Bootstrap` | 单例,注册所有 SystemMission;Web-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
|
||||||
| `ProcessHost` | `SimpleLite.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
|
| `ProcessHost` | `Simple3.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
|
||||||
| `HeartbeatChannel` | `SimpleLite.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`;3 个周期未达 → 标记不健康 |
|
| `HeartbeatChannel` | `Simple3.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`;3 个周期未达 → 标记不健康 |
|
||||||
| `ProcessWatchdog` | `SimpleLite.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
|
| `ProcessWatchdog` | `Simple3.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
|
||||||
| `RunMode`(枚举) | `SimpleLite.Bootstrap` | `DesktopOnly` / `WebEnabled` |
|
| `RunMode`(枚举) | `Simple3.Bootstrap` | `DesktopOnly` / `WebEnabled` |
|
||||||
|
|
||||||
**生命周期:**
|
**生命周期:**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SimpleLite 启动 (Web-Enabled)
|
Simple3 启动 (Web-Enabled)
|
||||||
├─ Kestrel Up :7001/7002
|
├─ Kestrel Up :7001/7002
|
||||||
├─ WebTerminal Up :8223
|
├─ WebTerminal Up :8223
|
||||||
├─ SchedulerRuntime.Start()
|
├─ SchedulerRuntime.Start()
|
||||||
@@ -414,7 +414,7 @@ SimpleLite 启动 (Web-Enabled)
|
|||||||
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
|
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
|
||||||
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
|
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
|
||||||
|
|
||||||
SimpleLite 优雅停机:
|
Simple3 优雅停机:
|
||||||
SystemMissionRegistry.StopAll()
|
SystemMissionRegistry.StopAll()
|
||||||
└─ StartPlatformMission.Stop()
|
└─ StartPlatformMission.Stop()
|
||||||
├─ 发送 SIGTERM 等价(命名管道 "shutdown" 消息)
|
├─ 发送 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`)。
|
- **不写入主库 Missions 表**:SystemMission 是进程级配置,不参与项目序列化;存储在 `simple_main.db.SystemMissionConfig`(或直接读 `simple.json`)。
|
||||||
- **崩溃隔离**:Platform 异常不影响 SimpleLite 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
|
- **崩溃隔离**:Platform 异常不影响 Simple3 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
|
||||||
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 SimpleLite 唯一;MiGu.Server 启动时校验"父进程是否为 SimpleLite",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
|
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 Simple3 唯一;MiGu.Server 启动时校验"父进程是否为 Simple3",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
|
||||||
- **静态资源**:MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`。
|
- **静态资源**:MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 三端职责矩阵
|
## 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) | 基础动作:暂停/取消/重派/优先级 |
|
| 任务编排(Mission/Recipe) | **读写** | **全功能读写**(含 Demand / Recipe) | 基础动作:暂停/取消/重派/优先级 |
|
||||||
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
|
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
|
||||||
| CAD 工具 | **可用** | **可用**(Vue 调用 `/api/sl/cad/*`) | 不可用 |
|
| CAD 工具 | **可用** | **可用**(Vue 调用 `/api/sl/cad/*`) | 不可用 |
|
||||||
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
|
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
|
||||||
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
|
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
|
||||||
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
|
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
|
||||||
| 3D 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
|
| 3D 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 Simple3 webVRender**(全功能交互) | **嵌入 Simple3 webVRender**(只读 + 圈选) |
|
||||||
| 系统级配置 | 局部(Desktop-Only) | **全局编辑 + 下发** | 不可编辑 |
|
| 系统级配置 | 局部(Desktop-Only) | **全局编辑 + 下发** | 不可编辑 |
|
||||||
| 外部对接(MES/WMS/RCS) | 不涉及 | **唯一入口** | 不涉及 |
|
| 外部对接(MES/WMS/RCS) | 不涉及 | **唯一入口** | 不涉及 |
|
||||||
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
|
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
|
||||||
@@ -549,7 +549,7 @@ Platform 将典型业务场景抽象为标准模板,支持开箱即用和后
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph 顶层["顶层(按端隔离)"]
|
subgraph 顶层["顶层(按端隔离)"]
|
||||||
L1A["SimpleLite (CycleGUI + WebAPI Host)"]
|
L1A["Simple3 (CycleGUI + WebAPI Host)"]
|
||||||
L1B["MiGu.Server (ASP.NET Core + YARP)"]
|
L1B["MiGu.Server (ASP.NET Core + YARP)"]
|
||||||
L1B2["platform-vue (SPA, 管理员)"]
|
L1B2["platform-vue (SPA, 管理员)"]
|
||||||
L1B3["rcsmonitor-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.Persistence` | net8.0 | EF Core 8 多 Provider 抽象(**SQLite / MySQL / PostgreSQL / SQL Server**)+ 迁移 + `DbProviderOptions` + 连接串构建器 + `WalSqliteOptions`(仅 SQLite) |
|
||||||
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
|
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
|
||||||
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
|
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
|
||||||
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 SimpleLite** + 静态托管两份 Vue dist;**启动时校验父进程为 SimpleLite,否则拒绝运行**(防误用) |
|
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 Simple3** + 静态托管两份 Vue dist;**启动时校验父进程为 Simple3,否则拒绝运行**(防误用) |
|
||||||
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 SimpleLite 所有控件 |
|
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 Simple3 所有控件 |
|
||||||
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
|
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
|
||||||
| `packages/sl-controls`(pnpm workspace) | Vue 3 组件库 | Workspace3D(嵌 webVRender)/ 地图列表 / 任务表 / CAD 工具栏等共享组件 |
|
| `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 仅显示运营菜单 |
|
| 主菜单栏 / 工具栏 | `<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>` | 表格 + 拓扑视图 |
|
| 地图面板(Sites / Tracks) | `<MapEditor>` + `<TrackTable>` | 表格 + 拓扑视图 |
|
||||||
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
|
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
|
||||||
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
|
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
|
||||||
@@ -674,7 +674,7 @@ function onReady() {
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
`SimpleLite/Rendering/SimpleSceneRenderer.cs` 端按 `scope` 和 `ro` 控制:
|
`Simple3/Rendering/SimpleSceneRenderer.cs` 端按 `scope` 和 `ro` 控制:
|
||||||
- `scope=RCSMonitor` 或 `ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
|
- `scope=RCSMonitor` 或 `ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
|
||||||
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
|
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
|
||||||
|
|
||||||
@@ -690,7 +690,7 @@ builder.Services.AddReverseProxy()
|
|||||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||||
.AddTransforms(ctx =>
|
.AddTransforms(ctx =>
|
||||||
{
|
{
|
||||||
// 透传当前 JWT 的 userId / scope 给 SimpleLite
|
// 透传当前 JWT 的 userId / scope 给 Simple3
|
||||||
ctx.AddRequestTransform(async tc =>
|
ctx.AddRequestTransform(async tc =>
|
||||||
{
|
{
|
||||||
var ident = tc.HttpContext.User;
|
var ident = tc.HttpContext.User;
|
||||||
@@ -767,7 +767,7 @@ app.Run();
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph App["应用代码 (SimpleLite / MiGu.Server)"]
|
subgraph App["应用代码 (Simple3 / MiGu.Server)"]
|
||||||
DOM["领域服务 / Repository"]
|
DOM["领域服务 / Repository"]
|
||||||
EFC["EF Core 8 DbContext"]
|
EFC["EF Core 8 DbContext"]
|
||||||
OPT["DbProviderOptions<br/>{ provider, conn, ha }"]
|
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 |
|
| **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'` 字段区分。
|
> 原 v1.2 中的 `rcsmonitor_local` 取消;其表(OpsLogs / Annotations / Snapshots / WidgetLayoutCache)合并到 `platform`,加 `source='RCSMonitor'` 字段区分。
|
||||||
@@ -824,12 +824,12 @@ flowchart TB
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph A["主节点 NODE-A (Active)"]
|
subgraph A["主节点 NODE-A (Active)"]
|
||||||
SL_A["SimpleLite.exe"]
|
SL_A["Simple3.exe"]
|
||||||
PL_A["MiGu.Server.exe"]
|
PL_A["MiGu.Server.exe"]
|
||||||
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
|
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
|
||||||
end
|
end
|
||||||
subgraph B["备节点 NODE-B (Standby)"]
|
subgraph B["备节点 NODE-B (Standby)"]
|
||||||
SL_B["SimpleLite.exe (suspended)"]
|
SL_B["Simple3.exe (suspended)"]
|
||||||
PL_B["MiGu.Server.exe (suspended)"]
|
PL_B["MiGu.Server.exe (suspended)"]
|
||||||
D_B[("ROSE 镜像盘 R:\")]
|
D_B[("ROSE 镜像盘 R:\")]
|
||||||
end
|
end
|
||||||
@@ -860,7 +860,7 @@ flowchart TB
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `RES_DISK_R` | Mirror Disk | — |
|
| `RES_DISK_R` | Mirror Disk | — |
|
||||||
| `RES_VIP_LAN` | Virtual IP | RES_DISK_R |
|
| `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_SVC_PL` | Service: MiGu.Server | RES_DISK_R, RES_VIP_LAN, RES_SVC_SL |
|
||||||
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
|
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
|
||||||
|
|
||||||
@@ -871,7 +871,7 @@ flowchart TB
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph App["应用层 (VIP + 重试)"]
|
subgraph App["应用层 (VIP + 重试)"]
|
||||||
SL["SimpleLite.exe"]
|
SL["Simple3.exe"]
|
||||||
PL["MiGu.Server.exe"]
|
PL["MiGu.Server.exe"]
|
||||||
end
|
end
|
||||||
subgraph Router["MySQL Router (或 ProxySQL)"]
|
subgraph Router["MySQL Router (或 ProxySQL)"]
|
||||||
@@ -911,7 +911,7 @@ flowchart TB
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph App["应用层"]
|
subgraph App["应用层"]
|
||||||
SL["SimpleLite.exe"]
|
SL["Simple3.exe"]
|
||||||
PL["MiGu.Server.exe"]
|
PL["MiGu.Server.exe"]
|
||||||
end
|
end
|
||||||
subgraph LB["HAProxy / PgBouncer"]
|
subgraph LB["HAProxy / PgBouncer"]
|
||||||
@@ -953,7 +953,7 @@ flowchart TB
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph App["应用层"]
|
subgraph App["应用层"]
|
||||||
SL["SimpleLite.exe"]
|
SL["Simple3.exe"]
|
||||||
PL["MiGu.Server.exe"]
|
PL["MiGu.Server.exe"]
|
||||||
end
|
end
|
||||||
subgraph Listener["AG Listener (DNS + VIP)"]
|
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 副本基本可用性组。
|
- **EF Core Provider**:`Microsoft.EntityFrameworkCore.SqlServer`;支持 SQL Server 2019+(建议 Enterprise 版以启用同步提交副本数 ≥3 + Read-Only Routing)。Standard 版可降级为 2 副本基本可用性组。
|
||||||
- **HA 形态**:**WSFC(Windows Server Failover Cluster)+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
|
- **HA 形态**:**WSFC(Windows Server Failover Cluster)+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
|
||||||
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
|
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
|
||||||
- **认证**:建议混合身份认证;MiGu.Server 用 SQL Login,SimpleLite 桌面端可走 Windows 集成认证。
|
- **认证**:建议混合身份认证;MiGu.Server 用 SQL Login,Simple3 桌面端可走 Windows 集成认证。
|
||||||
- **TempDB**:每节点分摊;MultipleActiveResultSets(MARS)建议关闭,EF Core 已能避免。
|
- **TempDB**:每节点分摊;MultipleActiveResultSets(MARS)建议关闭,EF Core 已能避免。
|
||||||
- **备份**:`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
|
- **备份**:`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
|
||||||
- **RTO / RPO**:RTO ≈ 10–30s(AG 自动 failover);RPO ≈ 0(同步提交副本)。
|
- **RTO / RPO**:RTO ≈ 10–30s(AG 自动 failover);RPO ≈ 0(同步提交副本)。
|
||||||
@@ -1091,7 +1091,7 @@ flowchart TB
|
|||||||
"database": "simple_main",
|
"database": "simple_main",
|
||||||
"user": "simple_app",
|
"user": "simple_app",
|
||||||
"password": "${SECRET_MSSQL_PWD}",
|
"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": {
|
"platform": {
|
||||||
"server": "tcp:rcs-ag.lan,1433",
|
"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>`。
|
2. **授权**:所有 WebAPI + WS 调用走 `IPermissionEvaluator`;权限码 `<area>.<action>`。
|
||||||
3. **范围(Scope)**:`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope`(`/admin/*` → `Platform`,`/monitor/*` → `RCSMonitor`)。
|
3. **范围(Scope)**:`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope`(`/admin/*` → `Platform`,`/monitor/*` → `RCSMonitor`)。
|
||||||
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
|
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
|
||||||
@@ -1173,7 +1173,7 @@ sequenceDiagram
|
|||||||
participant U as 运营用户
|
participant U as 运营用户
|
||||||
participant RV as rcsmonitor-vue
|
participant RV as rcsmonitor-vue
|
||||||
participant P as MiGu.Server
|
participant P as MiGu.Server
|
||||||
participant L as SimpleLite (YARP 后侧)
|
participant L as Simple3 (YARP 后侧)
|
||||||
participant DBp as platform.db
|
participant DBp as platform.db
|
||||||
|
|
||||||
U->>RV: 访问 https://host:8080/monitor
|
U->>RV: 访问 https://host:8080/monitor
|
||||||
@@ -1255,7 +1255,7 @@ flowchart LR
|
|||||||
UI11 --> Sch
|
UI11 --> Sch
|
||||||
Sch --> Val --> Ver --> Pub
|
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" --> RMVue["rcsmonitor-vue"]
|
||||||
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
|
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
|
||||||
```
|
```
|
||||||
@@ -1316,7 +1316,7 @@ public record EffectivePermissions(
|
|||||||
List<WidgetGrantDto> VisibleWidgets);
|
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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as 用户
|
participant U as 用户
|
||||||
participant L as SimpleLite (CycleGUI)
|
participant L as Simple3 (CycleGUI)
|
||||||
participant BL as BootLoginPanel
|
participant BL as BootLoginPanel
|
||||||
participant K as Kestrel (WebAPI)
|
participant K as Kestrel (WebAPI)
|
||||||
participant P as MiGu.Server (YARP)
|
participant P as MiGu.Server (YARP)
|
||||||
participant V as platform-vue
|
participant V as platform-vue
|
||||||
participant RV as rcsmonitor-vue
|
participant RV as rcsmonitor-vue
|
||||||
|
|
||||||
U->>L: 启动 SimpleLite.exe
|
U->>L: 启动 Simple3.exe
|
||||||
L->>L: 读取 simple.json
|
L->>L: 读取 simple.json
|
||||||
alt rememberRunMode = true
|
alt rememberRunMode = true
|
||||||
L->>L: 直接采用 runMode (仍鉴权)
|
L->>L: 直接采用 runMode (仍鉴权)
|
||||||
@@ -1373,10 +1373,10 @@ sequenceDiagram
|
|||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as 用户
|
participant U as 用户
|
||||||
participant L as SimpleLite
|
participant L as Simple3
|
||||||
participant BL as BootLoginPanel
|
participant BL as BootLoginPanel
|
||||||
|
|
||||||
U->>L: 启动 SimpleLite.exe
|
U->>L: 启动 Simple3.exe
|
||||||
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
|
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
|
||||||
L->>BL: 必要时弹出登录窗
|
L->>BL: 必要时弹出登录窗
|
||||||
U->>BL: 输入凭据 + 选 Desktop-Only
|
U->>BL: 输入凭据 + 选 Desktop-Only
|
||||||
@@ -1387,13 +1387,13 @@ sequenceDiagram
|
|||||||
L-->>U: 进入设计端主界面
|
L-->>U: 进入设计端主界面
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.3 platform-vue 编辑地图站点 → SimpleLite 落库
|
### 10.3 platform-vue 编辑地图站点 → Simple3 落库
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant V as platform-vue
|
participant V as platform-vue
|
||||||
participant P as MiGu.Server (YARP)
|
participant P as MiGu.Server (YARP)
|
||||||
participant L as SimpleLite (WebAPI)
|
participant L as Simple3 (WebAPI)
|
||||||
participant DBl as simple_main.db
|
participant DBl as simple_main.db
|
||||||
participant RV as rcsmonitor-vue (订阅)
|
participant RV as rcsmonitor-vue (订阅)
|
||||||
|
|
||||||
@@ -1406,7 +1406,7 @@ sequenceDiagram
|
|||||||
P-->>V: 201 + siteId
|
P-->>V: 201 + siteId
|
||||||
L->>L: WS broadcast { type: "map.site.created", payload }
|
L->>L: WS broadcast { type: "map.site.created", payload }
|
||||||
L-->>P: WS event
|
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 接收并按权限展示)
|
P-->>RV: WS event (RCSMonitor 接收并按权限展示)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1416,7 +1416,7 @@ sequenceDiagram
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant V as platform-vue
|
participant V as platform-vue
|
||||||
participant P as Platform
|
participant P as Platform
|
||||||
participant L as SimpleLite
|
participant L as Simple3
|
||||||
participant E as DispatchLoop
|
participant E as DispatchLoop
|
||||||
participant RV as rcsmonitor-vue
|
participant RV as rcsmonitor-vue
|
||||||
|
|
||||||
@@ -1440,7 +1440,7 @@ sequenceDiagram
|
|||||||
participant U as 运营人员
|
participant U as 运营人员
|
||||||
participant RV as rcsmonitor-vue
|
participant RV as rcsmonitor-vue
|
||||||
participant P as MiGu.Server (YARP)
|
participant P as MiGu.Server (YARP)
|
||||||
participant L as SimpleLite (Ops 网关)
|
participant L as Simple3 (Ops 网关)
|
||||||
participant E as DispatchLoop
|
participant E as DispatchLoop
|
||||||
participant DBp as platform.db
|
participant DBp as platform.db
|
||||||
participant DBl as simple_main.db
|
participant DBl as simple_main.db
|
||||||
@@ -1476,10 +1476,10 @@ sequenceDiagram
|
|||||||
VIP->>NA: 路由到 Active
|
VIP->>NA: 路由到 Active
|
||||||
Note over NA: 节点故障
|
Note over NA: 节点故障
|
||||||
ROSE->>NA: 心跳超时 → 触发切换
|
ROSE->>NA: 心跳超时 → 触发切换
|
||||||
ROSE->>NA: 强制停止 SimpleLite / Platform
|
ROSE->>NA: 强制停止 Simple3 / Platform
|
||||||
ROSE->>NA: 卸载 R: 卷
|
ROSE->>NA: 卸载 R: 卷
|
||||||
ROSE->>NB: 挂载 R: 卷
|
ROSE->>NB: 挂载 R: 卷
|
||||||
ROSE->>NB: 启动 SimpleLite (WAL 重放 + 主库自检)
|
ROSE->>NB: 启动 Simple3 (WAL 重放 + 主库自检)
|
||||||
ROSE->>NB: 启动 MiGu.Server
|
ROSE->>NB: 启动 MiGu.Server
|
||||||
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
|
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
|
||||||
CLI--xVIP: 短暂中断
|
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 操作、事件推送 |
|
| platform-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/admin`) | UI 操作、事件推送 |
|
||||||
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor`,`scope=RCSMonitor`) | UI 操作、事件推送(受限) |
|
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor`,`scope=RCSMonitor`) | UI 操作、事件推送(受限) |
|
||||||
| Vue Workspace3D ↔ SimpleLite WebTerminal | WSS(webVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe) |
|
| Vue Workspace3D ↔ Simple3 WebTerminal | WSS(webVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe) |
|
||||||
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
|
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
|
||||||
| Vehicle ↔ SimpleLite | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
|
| Vehicle ↔ Simple3 | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
|
||||||
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
|
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
|
||||||
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
|
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
|
||||||
|
|
||||||
@@ -1510,7 +1510,7 @@ sequenceDiagram
|
|||||||
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
|
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
|
||||||
- 幂等:写操作支持 `Idempotency-Key` 请求头
|
- 幂等:写操作支持 `Idempotency-Key` 请求头
|
||||||
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
|
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
|
||||||
- Swagger:Platform / SimpleLite 都暴露 `/swagger`(生产可关)
|
- Swagger:Platform / Simple3 都暴露 `/swagger`(生产可关)
|
||||||
- **YARP Scope 白名单**:`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*` 与 `/api/sl/projection/*` 命中;其他路径直接 403
|
- **YARP Scope 白名单**:`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*` 与 `/api/sl/projection/*` 命中;其他路径直接 403
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1521,12 +1521,12 @@ sequenceDiagram
|
|||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
|
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
|
||||||
subgraph NA["NODE-A (Active)"]
|
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)"]
|
PL_A["MiGu.Server.exe<br/>:8080/8081<br/>(YARP + 双 Vue dist)"]
|
||||||
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
|
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
|
||||||
end
|
end
|
||||||
subgraph NB["NODE-B (Standby)"]
|
subgraph NB["NODE-B (Standby)"]
|
||||||
SL_B["SimpleLite.exe (suspended)"]
|
SL_B["Simple3.exe (suspended)"]
|
||||||
PL_B["MiGu.Server.exe (suspended)"]
|
PL_B["MiGu.Server.exe (suspended)"]
|
||||||
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
|
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
|
||||||
end
|
end
|
||||||
@@ -1556,12 +1556,12 @@ flowchart TB
|
|||||||
|
|
||||||
| 端口 | 服务 | 启用条件 |
|
| 端口 | 服务 | 启用条件 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| 7001 | SimpleLite WebAPI | Web-Enabled |
|
| 7001 | Simple3 WebAPI | Web-Enabled |
|
||||||
| 7002 | SimpleLite WebSocket | Web-Enabled |
|
| 7002 | Simple3 WebSocket | Web-Enabled |
|
||||||
| 8080 | Platform WebAPI + platform-vue 静态(/admin)+ rcsmonitor-vue 静态(/monitor) | Web-Enabled |
|
| 8080 | Platform WebAPI + platform-vue 静态(/admin)+ rcsmonitor-vue 静态(/monitor) | Web-Enabled |
|
||||||
| 8081 | Platform WebSocket | Web-Enabled |
|
| 8081 | Platform WebSocket | Web-Enabled |
|
||||||
| 8222 | SimpleLite 车端 / API(现有) | 所有模式 |
|
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
|
||||||
| 8223 | SimpleLite WebTerminal(webVRender,Vue 嵌入用) | Web-Enabled |
|
| 8223 | Simple3 WebTerminal(webVRender,Vue 嵌入用) | Web-Enabled |
|
||||||
|
|
||||||
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
|
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
|
||||||
|
|
||||||
@@ -1588,7 +1588,7 @@ flowchart LR
|
|||||||
SHH["SimpleShared.WebApiHost"]
|
SHH["SimpleShared.WebApiHost"]
|
||||||
end
|
end
|
||||||
subgraph apps["两端后端"]
|
subgraph apps["两端后端"]
|
||||||
SL["SimpleLite (CycleGUI + WebAPI)"]
|
SL["Simple3 (CycleGUI + WebAPI)"]
|
||||||
PSrv["MiGu.Server (WebAPI + YARP)"]
|
PSrv["MiGu.Server (WebAPI + YARP)"]
|
||||||
end
|
end
|
||||||
subgraph frontends["前端 (pnpm workspace)"]
|
subgraph frontends["前端 (pnpm workspace)"]
|
||||||
@@ -1709,7 +1709,7 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
|||||||
|--------|----------|----------|
|
|--------|----------|----------|
|
||||||
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProvider;EF Core + SQLite + 迁移 |
|
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProvider;EF Core + SQLite + 迁移 |
|
||||||
| **M1 - 启动登录 + 运行模式** | 1 周 | `BootLoginPanel`;`RunMode` 枚举;CycleGUI 状态窗;`simple.json.runMode` 持久化;Web-Enabled 业务面板硬隐藏 |
|
| **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 Hub;JWT |
|
| **M2 - WebAPI 协议层** | 1.5 周 | Simple3 集成 Kestrel;定义 `/api/projection/*`、`/api/ops/*`、`/api/config/*`、`/api/map/*` 等;WS Hub;JWT |
|
||||||
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`:Workspace3D(嵌入 webVRender)+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
|
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`:Workspace3D(嵌入 webVRender)+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
|
||||||
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.Server(WebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist)+ 配置中心 11 个 schema + 用户/角色/控件权限 |
|
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.Server(WebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist)+ 配置中心 11 个 schema + 用户/角色/控件权限 |
|
||||||
| **M5 - platform-vue (管理员)** | 2.5 周 | 全功能套壳:登录、地图设计、车辆/任务、CAD、字段/图层、配置中心、库位、自定义控件、回放、设备管理、车队生命周期、场景模板 |
|
| **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%) |
|
| 1 | 运行模式持久化 | **写入 `simple.json.runMode` / `rememberRunMode`**(不使用 %APPDATA%) |
|
||||||
| 2 | Web-Enabled 下是否允许同时本地操作 | **不允许**;CycleGUI 仅状态窗;不提供"临时启用本地 UI"开关 |
|
| 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/*`) |
|
| 4 | 反向代理 | **YARP**(MiGu.Server 用 YARP 反代 `/api/sl/*`) |
|
||||||
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
|
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
|
||||||
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
|
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
|
||||||
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
|
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
|
||||||
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
|
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
|
||||||
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
|
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
|
||||||
| 10 | **Platform / RCSMonitor 启动归属(v1.4)** | **由 SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows Service;MiGu.Server 启动时校验父进程必须是 SimpleLite |
|
| 10 | **Platform / RCSMonitor 启动归属(v1.4)** | **由 Simple3 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows Service;MiGu.Server 启动时校验父进程必须是 Simple3 |
|
||||||
| 11 | **持久层多 Provider(v1.5)** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF Migrations,CI 全矩阵跑通;运行期由 `simple.json.database.provider` 决定 |
|
| 11 | **持久层多 Provider(v1.5)** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF Migrations,CI 全矩阵跑通;运行期由 `simple.json.database.provider` 决定 |
|
||||||
| 12 | **跨 Provider 切换策略** | 不支持热切;切换走停服窗口 + `tools/db/migrate.ps1`;旧文件作为回退包保留 |
|
| 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` 决定后续路径 |
|
| `Simple3/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
|
||||||
| `SimpleLite/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode`、`rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
|
| `Simple3/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode`、`rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
|
||||||
| `SimpleLite/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUI;Web-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
|
| `Simple3/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUI;Web-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
|
||||||
| `SimpleLite/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
|
| `Simple3/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
|
||||||
| `SimpleLite/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
|
| `Simple3/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
|
||||||
| `SimpleLite/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
|
| `Simple3/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
|
||||||
| `SimpleLite/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry,**不进入 DispatchLoop**) |
|
| `Simple3/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry,**不进入 DispatchLoop**) |
|
||||||
| `SimpleLite/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
|
| `Simple3/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
|
||||||
| `SimpleLite/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
|
| `Simple3/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
|
||||||
| `SimpleLite/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
|
| `Simple3/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
|
||||||
| `SimpleLite/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
|
| `Simple3/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
|
||||||
| `SimpleLite/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
|
| `Simple3/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
|
||||||
| `SimpleLite/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
|
| `Simple3/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
|
||||||
| `SimpleLite/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
|
| `Simple3/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
|
||||||
| `SimpleScheduler/WebApi.cs` | 调用方从 Nancy 切到 ASP.NET Core Controller;保持 `SchedulerRuntime` 外观稳定 |
|
| `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` |
|
| `Plugins/Sample` | 示例插件读取 `IConfigSnapshotProvider`;不再读 `Configuration.conf` |
|
||||||
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
|
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
|
||||||
| 新增 `tools/db/*` | 跨 Provider 迁移脚本(`migrate.ps1` / `dump.ps1` / `restore.ps1`);按目标 DB 校验 schema 与索引 |
|
| 新增 `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` |
|
| B1 | Kestrel + Swagger + CORS | ✅ | `Program.cs` |
|
||||||
| B2 | Auth / Health / Config / Ops / Projection 控制器 | ✅ 骨架 | Mock/内存存储 |
|
| 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` |
|
| B4 | 静态托管 + SPA fallback | ✅ | 单一 `wwwroot/index.html` |
|
||||||
| B5 | JWT 真签发 + 库持久化 | ☐ | 仍为 Mock |
|
| 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 差距分析
|
- [SIMPLELITE_COMPOSER_PARITY_PLAN.md](./SIMPLELITE_COMPOSER_PARITY_PLAN.md) — 与 SimpleComposer 差距分析
|
||||||
|
|||||||
+19
-19
@@ -1,19 +1,19 @@
|
|||||||
# SimpleLite 面向咪咕平台数据 API 文档
|
# Simple3 面向咪咕平台数据 API 文档
|
||||||
|
|
||||||
> **版本**:与 SimpleLite 源码同步
|
> **版本**:与 Simple3 源码同步
|
||||||
> **更新日期**:2026-06-18
|
> **更新日期**:2026-06-18
|
||||||
> **服务栈**:EmbedIO WebApi(非 ASP.NET Core)
|
> **服务栈**: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. 架构与访问路径
|
## 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/...` | 平台前端/运维正式入口 |
|
| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 |
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23,7 +23,7 @@ SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1
|
|||||||
MiGu.Server :8080 (/api/sl/*)
|
MiGu.Server :8080 (/api/sl/*)
|
||||||
│ 追加 X-Platform-Internal-Token
|
│ 追加 X-Platform-Internal-Token
|
||||||
▼
|
▼
|
||||||
SimpleLite :8222 (/projection/*)
|
Simple3 :8222 (/projection/*)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.1 鉴权
|
### 1.1 鉴权
|
||||||
@@ -31,12 +31,12 @@ SimpleLite :8222 (/projection/*)
|
|||||||
| 场景 | 要求 |
|
| 场景 | 要求 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 经 MiGu.Server 访问 | 需登录 JWT(`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` |
|
| 经 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`
|
1. `simple.json` → `platform.internalToken`
|
||||||
2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN`
|
2. 环境变量 `SIMPLE3__PLATFORM__INTERNALTOKEN`
|
||||||
3. `Platform.Server/data/.internal-token` 或 `MiGu.Server/data/.internal-token`
|
3. `Platform.Server/data/.internal-token` 或 `MiGu.Server/data/.internal-token`
|
||||||
|
|
||||||
### 1.2 通用响应格式
|
### 1.2 通用响应格式
|
||||||
@@ -60,8 +60,8 @@ Token 解析优先级(SimpleLite 侧):
|
|||||||
|
|
||||||
开发环境下启动 MiGu.Server 后访问:
|
开发环境下启动 MiGu.Server 后访问:
|
||||||
|
|
||||||
- **统一文档**:`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi)
|
- **统一文档**:`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + Simple3 全部 WebApi)
|
||||||
- SimpleLite 相关接口标签前缀为 `SimpleLite/`
|
- Simple3 相关接口标签前缀为 `Simple3/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Token 解析优先级(SimpleLite 侧):
|
|||||||
{
|
{
|
||||||
"id": "C01",
|
"id": "C01",
|
||||||
"name": "AGV-1",
|
"name": "AGV-1",
|
||||||
"typeName": "SimpleLite.RCS.CarTypes.Car",
|
"typeName": "Simple3.RCS.CarTypes.Car",
|
||||||
"rawId": 1,
|
"rawId": 1,
|
||||||
"x": 1200.5,
|
"x": 1200.5,
|
||||||
"y": 800.0,
|
"y": 800.0,
|
||||||
@@ -285,7 +285,7 @@ Token 解析优先级(SimpleLite 侧):
|
|||||||
|
|
||||||
同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields` 的 `IOArea`、`Speed` 等基类字段)。
|
同一车型若多个 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/`:
|
咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`:
|
||||||
|
|
||||||
| 文件 | 对应 SimpleLite 前缀 |
|
| 文件 | 对应 Simple3 前缀 |
|
||||||
|------|----------------------|
|
|------|----------------------|
|
||||||
| `reflection.ts` | `/api/sl/projection/reflection` |
|
| `reflection.ts` | `/api/sl/projection/reflection` |
|
||||||
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
|
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
|
||||||
@@ -480,16 +480,16 @@ Token 解析优先级(SimpleLite 侧):
|
|||||||
|
|
||||||
| 服务 | 默认端口 | 路径 |
|
| 服务 | 默认端口 | 路径 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite |
|
| MiGu.Server | 8080 | `/api/sl/*` → Simple3 |
|
||||||
| SimpleLite 投影 API | 8222 | `/projection/*` |
|
| Simple3 投影 API | 8222 | `/projection/*` |
|
||||||
| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API) |
|
| Simple3 webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API) |
|
||||||
|
|
||||||
**健康检查**:`GET http://localhost:8080/api/health/simplelite`
|
**健康检查**:`GET http://localhost:8080/api/health/simple3`
|
||||||
|
|
||||||
**本地联调**:
|
**本地联调**:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起)
|
# 1. 启动 Simple3(或登录 MiGu.Server 后自动拉起)
|
||||||
# 2. 启动 MiGu.Server
|
# 2. 启动 MiGu.Server
|
||||||
# 3. 浏览器打开 http://localhost:8080/swagger
|
# 3. 浏览器打开 http://localhost:8080/swagger
|
||||||
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
|
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
|
`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/*`。
|
- 平台前端:`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. 技术栈总览
|
## 2. 技术栈总览
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
|
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
|
||||||
- 代理:YARP Reverse Proxy。
|
- 代理:YARP Reverse Proxy。
|
||||||
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
|
- 持久化:当前为 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 方式嵌入。
|
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
|
||||||
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
|
- Simple3 Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
|
||||||
|
|
||||||
## 3. 顶层目录职责
|
## 3. 顶层目录职责
|
||||||
|
|
||||||
@@ -64,33 +64,33 @@ flowchart LR
|
|||||||
Server --> Auth["Auth/RBAC/JWT"]
|
Server --> Auth["Auth/RBAC/JWT"]
|
||||||
Server --> Config["ConfigStore<br/>data/config-*.json"]
|
Server --> Config["ConfigStore<br/>data/config-*.json"]
|
||||||
Server --> Wizard["部署向导<br/>deployment profile"]
|
Server --> Wizard["部署向导<br/>deployment profile"]
|
||||||
Server --> Launcher["SimpleLiteLauncher"]
|
Server --> Launcher["Simple3Launcher"]
|
||||||
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
|
Launcher -->|登录后按 launchMode 拉起/复用| SL["Simple3.exe<br/>相邻仓库"]
|
||||||
Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"]
|
Server -->|YARP /api/sl/*| SLApi["Simple3 :8222"]
|
||||||
Server -->|YARP /vr/*| VR["webVRender :8223"]
|
Server -->|YARP /vr/*| VR["webVRender :8223"]
|
||||||
Vue -->|iframe 或 /vr 代理| VR
|
Vue -->|iframe 或 /vr 代理| VR
|
||||||
```
|
```
|
||||||
|
|
||||||
关键说明:
|
关键说明:
|
||||||
|
|
||||||
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite。
|
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 Simple3。
|
||||||
- 用户登录时可选择 `DesktopAndWeb` 或 `WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local` 或 `--display-mode=web`。
|
- 用户登录时可选择 `DesktopAndWeb` 或 `WebOnly`,后端转换为 Simple3 命令行 `--display-mode=web+local` 或 `--display-mode=web`。
|
||||||
- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite。
|
- `Simple3Launcher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 Simple3。
|
||||||
- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
|
- 如果 `8222` 已经有 Simple3 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
|
||||||
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
|
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
|
||||||
|
|
||||||
## 5. 文档蓝图与当前代码的差异
|
## 5. 文档蓝图与当前代码的差异
|
||||||
|
|
||||||
仓库中存在两类叙述:
|
仓库中存在两类叙述:
|
||||||
|
|
||||||
- `README.md` 和 `MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。
|
- `README.md` 和 `MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `Simple3`”这一当前实现。
|
||||||
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
|
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“Simple3 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
|
||||||
|
|
||||||
当前代码落地状态:
|
当前代码落地状态:
|
||||||
|
|
||||||
- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
|
- 已落地:`MiGu.Server` 主入口、登录拉起 Simple3、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
|
||||||
- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
|
- 部分落地:配置中心模型、运维白名单、Simple3 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
|
||||||
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
|
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 Simple3 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
|
||||||
|
|
||||||
## 6. 后端模块
|
## 6. 后端模块
|
||||||
|
|
||||||
@@ -101,14 +101,14 @@ flowchart LR
|
|||||||
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
|
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
|
||||||
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`。
|
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`。
|
||||||
- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`。
|
- 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。
|
- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。
|
||||||
- 托管静态资源和 SPA fallback。
|
- 托管静态资源和 SPA fallback。
|
||||||
|
|
||||||
核心关系:
|
核心关系:
|
||||||
|
|
||||||
- 调用 `JwtIssuer` 颁发/验签 JWT。
|
- 调用 `JwtIssuer` 颁发/验签 JWT。
|
||||||
- 调用 `InternalTokenStore` 管理转发给 SimpleLite 的 `X-Platform-Internal-Token`。
|
- 调用 `InternalTokenStore` 管理转发给 Simple3 的 `X-Platform-Internal-Token`。
|
||||||
- YARP `sl-route` 会自动注入内部 token。
|
- YARP `sl-route` 会自动注入内部 token。
|
||||||
- `app.MapControllers()` 处理平台自身 API,`app.MapReverseProxy()` 处理 `/api/sl/*` 与 `/vr/*`。
|
- `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。
|
- `POST /api/auth/logout`:清理 Cookie。
|
||||||
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
|
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
|
||||||
- `POST /api/auth/switch-scope`:同一用户在 `Platform` 与 `RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
|
- `POST /api/auth/switch-scope`:同一用户在 `Platform` 与 `RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
|
||||||
@@ -125,7 +125,7 @@ flowchart LR
|
|||||||
|
|
||||||
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
|
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
|
||||||
- `JwtIssuer`:签发 JWT。
|
- `JwtIssuer`:签发 JWT。
|
||||||
- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite。
|
- `Simple3Launcher`:登录后拉起或复用 Simple3。
|
||||||
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
|
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
|
||||||
|
|
||||||
### 6.3 RBAC 权限模块:`Auth/`
|
### 6.3 RBAC 权限模块:`Auth/`
|
||||||
@@ -136,7 +136,7 @@ flowchart LR
|
|||||||
- `RbacModels.cs`:用户、角色、DTO。
|
- `RbacModels.cs`:用户、角色、DTO。
|
||||||
- `PageCatalog.cs`:页面权限目录。
|
- `PageCatalog.cs`:页面权限目录。
|
||||||
- `JwtIssuer.cs`:JWT 颁发和验签参数。
|
- `JwtIssuer.cs`:JWT 颁发和验签参数。
|
||||||
- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。
|
- `InternalTokenStore.cs`:平台与 Simple3 内部通信 token。
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
@@ -182,32 +182,32 @@ flowchart LR
|
|||||||
|
|
||||||
- 前端 `useConfigStore` 统一读写这些 section。
|
- 前端 `useConfigStore` 统一读写这些 section。
|
||||||
- 许多配置页只是不同 section 的编辑视图。
|
- 许多配置页只是不同 section 的编辑视图。
|
||||||
- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。
|
- `deployment` section 还会参与菜单裁剪与 Simple3 插件选择。
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
|
|
||||||
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
|
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
|
||||||
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
|
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
|
||||||
|
|
||||||
### 6.5 SimpleLite 拉起与诊断:`Launcher/`
|
### 6.5 Simple3 拉起与诊断:`Launcher/`
|
||||||
|
|
||||||
主要文件:
|
主要文件:
|
||||||
|
|
||||||
- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。
|
- `Simple3Launcher.cs`:拉起、复用、重启、诊断、插件选择联动。
|
||||||
- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`。
|
- `Simple3Options.cs`:绑定 `appsettings.json:Simple3`。
|
||||||
- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。
|
- `Simple3BuildSync.cs`:辅助同步/探测 Simple3 构建与 API。
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
- 将业务启动模式翻译为 SimpleLite 内部 display mode:
|
- 将业务启动模式翻译为 Simple3 内部 display mode:
|
||||||
- `WebOnly` -> `--display-mode=web`
|
- `WebOnly` -> `--display-mode=web`
|
||||||
- `DesktopAndWeb` -> `--display-mode=web+local`
|
- `DesktopAndWeb` -> `--display-mode=web+local`
|
||||||
- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。
|
- 拉起前检查 `8222` 是否已有 Simple3,并用 `/projection/cars` 确认不是其他进程占用端口。
|
||||||
- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
|
- 查找 `Simple3.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
|
||||||
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite。
|
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 Simple3。
|
||||||
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
|
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
|
||||||
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite。
|
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 Simple3。
|
||||||
- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
|
- `GET /api/health/simple3` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
|
||||||
|
|
||||||
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
|
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ flowchart LR
|
|||||||
- 二次校验 JWT 的 `ops` claim。
|
- 二次校验 JWT 的 `ops` claim。
|
||||||
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
|
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
|
||||||
- `monitor.note.write` 仅写审计。
|
- `monitor.note.write` 仅写审计。
|
||||||
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。
|
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 Simple3 反射 API。
|
||||||
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
|
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
|
||||||
|
|
||||||
默认白名单:
|
默认白名单:
|
||||||
@@ -229,9 +229,9 @@ flowchart LR
|
|||||||
重要设计:
|
重要设计:
|
||||||
|
|
||||||
- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。
|
- 未配置映射时不会“假成功”,而是返回 `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`:平台后端健康检查,返回启动时间、运行时长、端口规划。
|
||||||
- `GET /api/health/simplelite`:SimpleLite 路径和端口诊断。
|
- `GET /api/health/simple3`:Simple3 路径和端口诊断。
|
||||||
- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。
|
- `POST /api/health/simple3/restart-for-update`:授权用户可触发关闭 Simple3、同步最新 DLL 并重新拉起。
|
||||||
|
|
||||||
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
|
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ flowchart LR
|
|||||||
|
|
||||||
- 首次部署时收集导航方式、功能模块、业务场景。
|
- 首次部署时收集导航方式、功能模块、业务场景。
|
||||||
- 保存后写入 `deployment` 配置 section。
|
- 保存后写入 `deployment` 配置 section。
|
||||||
- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`。
|
- 将导航方式映射为 Simple3 场景插件 ID,并调用 `Simple3Launcher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`。
|
||||||
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`。
|
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`。
|
||||||
|
|
||||||
对外 API:
|
对外 API:
|
||||||
@@ -273,7 +273,7 @@ flowchart LR
|
|||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
- 读取 SimpleLite 工作目录下的 `log/` 文件。
|
- 读取 Simple3 工作目录下的 `log/` 文件。
|
||||||
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
|
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
|
||||||
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
|
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
|
||||||
|
|
||||||
@@ -290,14 +290,14 @@ flowchart LR
|
|||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。
|
- 先调用 Simple3 `/projection/map-edit/maps` 获取地图目录。
|
||||||
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
|
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
|
||||||
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
|
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
|
||||||
|
|
||||||
关系:
|
关系:
|
||||||
|
|
||||||
- 依赖 SimpleLite 与 MiGu.Server 同机部署。
|
- 依赖 Simple3 与 MiGu.Server 同机部署。
|
||||||
- 依赖 `SimpleLiteOptions.ProjectionPort` 和 `InternalTokenStore`。
|
- 依赖 `Simple3Options.ProjectionPort` 和 `InternalTokenStore`。
|
||||||
|
|
||||||
## 7. 前端模块
|
## 7. 前端模块
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ flowchart LR
|
|||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。
|
- 登录页采集用户名、密码、scope 和 Simple3 启动模式。
|
||||||
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
|
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
|
||||||
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
|
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
|
||||||
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
|
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
|
||||||
@@ -346,7 +346,7 @@ flowchart LR
|
|||||||
- `api/config.ts`:配置中心读写。
|
- `api/config.ts`:配置中心读写。
|
||||||
- `api/projection.ts`:站点、路径、车辆、任务投影。
|
- `api/projection.ts`:站点、路径、车辆、任务投影。
|
||||||
- `api/ops.ts`:运维白名单执行和审计。
|
- `api/ops.ts`:运维白名单执行和审计。
|
||||||
- `api/reflection.ts`:SimpleLite 反射对象、字段、方法执行。
|
- `api/reflection.ts`:Simple3 反射对象、字段、方法执行。
|
||||||
- `api/mapEdit.ts`:地图编辑相关 API。
|
- `api/mapEdit.ts`:地图编辑相关 API。
|
||||||
- `api/wizard.ts`:部署向导。
|
- `api/wizard.ts`:部署向导。
|
||||||
- `api/logs.ts`:日志管理。
|
- `api/logs.ts`:日志管理。
|
||||||
@@ -354,7 +354,7 @@ flowchart LR
|
|||||||
关系:
|
关系:
|
||||||
|
|
||||||
- 所有平台 API 都以 `/api` 为 baseURL。
|
- 所有平台 API 都以 `/api` 为 baseURL。
|
||||||
- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite。
|
- `/api/sl/*` 由 MiGu.Server 反代给 Simple3。
|
||||||
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
|
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
|
||||||
|
|
||||||
### 7.4 Shell、主题和权限控制组件
|
### 7.4 Shell、主题和权限控制组件
|
||||||
@@ -418,7 +418,7 @@ flowchart LR
|
|||||||
- `useHistory`:撤销/重做命令栈。
|
- `useHistory`:撤销/重做命令栈。
|
||||||
- `useSelection`:对象选择状态。
|
- `useSelection`:对象选择状态。
|
||||||
- `useAlignment`、`useBatchGenerate`、`useClipboard`:编辑辅助能力。
|
- `useAlignment`、`useBatchGenerate`、`useClipboard`:编辑辅助能力。
|
||||||
- `mapEditApi`、`reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。
|
- `mapEditApi`、`reflectionApi`:与 Simple3 的地图编辑和反射 API 通信。
|
||||||
|
|
||||||
#### 地图监控
|
#### 地图监控
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ flowchart LR
|
|||||||
- `VehicleMonitorPanel`:车辆监控台。
|
- `VehicleMonitorPanel`:车辆监控台。
|
||||||
- `MissionListPanel`:任务列表。
|
- `MissionListPanel`:任务列表。
|
||||||
- `MonitorSelectionPanel`:选中对象详情和动作。
|
- `MonitorSelectionPanel`:选中对象详情和动作。
|
||||||
- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。
|
- `useProjectionStream`:订阅 Simple3/平台 SSE 事件。
|
||||||
|
|
||||||
### 7.7 运营端页面:`/monitor/*`
|
### 7.7 运营端页面:`/monitor/*`
|
||||||
|
|
||||||
@@ -469,13 +469,13 @@ flowchart LR
|
|||||||
- 选择导航方式:磁导航、二维码导航、激光导航。
|
- 选择导航方式:磁导航、二维码导航、激光导航。
|
||||||
- 选择功能模块:当前包含 WMS、PTL。
|
- 选择功能模块:当前包含 WMS、PTL。
|
||||||
- 选择业务场景模板。
|
- 选择业务场景模板。
|
||||||
- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic`、`scene.qrcode`、`scene.laser`。
|
- 预览将激活的 Simple3 场景插件,如 `scene.magnetic`、`scene.qrcode`、`scene.laser`。
|
||||||
|
|
||||||
保存后:
|
保存后:
|
||||||
|
|
||||||
- 调用 `PUT /api/wizard/profile`。
|
- 调用 `PUT /api/wizard/profile`。
|
||||||
- 后端写入 `deployment` section。
|
- 后端写入 `deployment` section。
|
||||||
- 后端写入 SimpleLite `plugins/active-scenes.json`。
|
- 后端写入 Simple3 `plugins/active-scenes.json`。
|
||||||
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
|
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
|
||||||
|
|
||||||
## 8. 数据与持久化
|
## 8. 数据与持久化
|
||||||
@@ -491,9 +491,9 @@ flowchart LR
|
|||||||
|
|
||||||
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
|
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
|
||||||
|
|
||||||
### SimpleLite 侧文件
|
### Simple3 侧文件
|
||||||
|
|
||||||
MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
|
MiGu.Server 会直接或间接使用 Simple3 工作目录:
|
||||||
|
|
||||||
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
|
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
|
||||||
- `log/**/*.log`:日志管理模块读取并分析。
|
- `log/**/*.log`:日志管理模块读取并分析。
|
||||||
@@ -511,7 +511,7 @@ MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
|
|||||||
- `/api/maps/{name}/content`:地图 JSON 预览。
|
- `/api/maps/{name}/content`:地图 JSON 预览。
|
||||||
- `/api/health/*`:健康检查。
|
- `/api/health/*`:健康检查。
|
||||||
|
|
||||||
### 平台后端到 SimpleLite
|
### 平台后端到 Simple3
|
||||||
|
|
||||||
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`。
|
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`。
|
||||||
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`。
|
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`。
|
||||||
@@ -533,7 +533,7 @@ flowchart TB
|
|||||||
RBAC["RbacStore / JwtIssuer"]
|
RBAC["RbacStore / JwtIssuer"]
|
||||||
Config["ConfigStore"]
|
Config["ConfigStore"]
|
||||||
Wizard["WizardController"]
|
Wizard["WizardController"]
|
||||||
Launcher["SimpleLiteLauncher"]
|
Launcher["Simple3Launcher"]
|
||||||
Ops["OpsController"]
|
Ops["OpsController"]
|
||||||
Logs["LogsController"]
|
Logs["LogsController"]
|
||||||
Maps["MapsContentController"]
|
Maps["MapsContentController"]
|
||||||
@@ -551,8 +551,8 @@ flowchart TB
|
|||||||
WizardVue["WizardView.vue"]
|
WizardVue["WizardView.vue"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph SimpleLite["SimpleLite 相邻仓库"]
|
subgraph Simple3["Simple3 相邻仓库"]
|
||||||
SLExe["SimpleLite.exe"]
|
SLExe["Simple3.exe"]
|
||||||
Projection["Projection API :8222"]
|
Projection["Projection API :8222"]
|
||||||
VRender["webVRender :8223"]
|
VRender["webVRender :8223"]
|
||||||
LogsFile["log/**/*.log"]
|
LogsFile["log/**/*.log"]
|
||||||
@@ -591,14 +591,14 @@ flowchart TB
|
|||||||
|
|
||||||
## 11. 典型业务链路
|
## 11. 典型业务链路
|
||||||
|
|
||||||
### 11.1 登录并启动 SimpleLite
|
### 11.1 登录并启动 Simple3
|
||||||
|
|
||||||
1. 用户打开 `/login`。
|
1. 用户打开 `/login`。
|
||||||
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`。
|
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`。
|
||||||
3. `AuthController` 调 `RbacStore.VerifyCredentials()` 校验密码。
|
3. `AuthController` 调 `RbacStore.VerifyCredentials()` 校验密码。
|
||||||
4. `AuthController` 检查用户是否可使用请求的 scope。
|
4. `AuthController` 检查用户是否可使用请求的 scope。
|
||||||
5. `AuthController` 调 `SimpleLiteLauncher.MaybeStart()`。
|
5. `AuthController` 调 `Simple3Launcher.MaybeStart()`。
|
||||||
6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`。
|
6. `Simple3Launcher` 复用或拉起 `Simple3.exe`。
|
||||||
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
|
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
|
||||||
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
|
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ flowchart TB
|
|||||||
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
|
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
|
||||||
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
|
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
|
||||||
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
|
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
|
||||||
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi` 或 `reflectionApi` 调用 SimpleLite。
|
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi` 或 `reflectionApi` 调用 Simple3。
|
||||||
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
|
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
|
||||||
|
|
||||||
### 11.3 运营端执行运维动作
|
### 11.3 运营端执行运维动作
|
||||||
@@ -618,7 +618,7 @@ flowchart TB
|
|||||||
3. 用户确认后调用 `/api/sl/ops/execute`。
|
3. 用户确认后调用 `/api/sl/ops/execute`。
|
||||||
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
|
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
|
||||||
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
|
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
|
||||||
6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。
|
6. 已配置映射时调用 Simple3 反射 execute;未配置映射时返回未下发。
|
||||||
7. 审计落盘,前端显示执行结果。
|
7. 审计落盘,前端显示执行结果。
|
||||||
|
|
||||||
### 11.4 首次部署配置向导
|
### 11.4 首次部署配置向导
|
||||||
@@ -628,7 +628,7 @@ flowchart TB
|
|||||||
3. 前端加载 `/api/wizard/options` 和 `/api/wizard/profile`。
|
3. 前端加载 `/api/wizard/options` 和 `/api/wizard/profile`。
|
||||||
4. 用户选择导航方式、模块、场景。
|
4. 用户选择导航方式、模块、场景。
|
||||||
5. 保存后端写 `deployment` section。
|
5. 保存后端写 `deployment` section。
|
||||||
6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`。
|
6. 后端将导航方式转换为场景插件 ID,并写 Simple3 `plugins/active-scenes.json`。
|
||||||
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
|
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
|
||||||
|
|
||||||
## 12. 构建与运行
|
## 12. 构建与运行
|
||||||
@@ -645,7 +645,7 @@ dotnet run
|
|||||||
|
|
||||||
- 平台:`http://localhost:8080/login`
|
- 平台:`http://localhost:8080/login`
|
||||||
- 健康检查:`http://localhost:8080/api/health`
|
- 健康检查:`http://localhost:8080/api/health`
|
||||||
- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite`
|
- Simple3 诊断:`http://localhost:8080/api/health/simple3`
|
||||||
- Swagger:开发环境 `/swagger`
|
- Swagger:开发环境 `/swagger`
|
||||||
|
|
||||||
### 前端开发
|
### 前端开发
|
||||||
@@ -679,7 +679,7 @@ pnpm dev
|
|||||||
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
|
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
|
||||||
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
|
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
|
||||||
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
|
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
|
||||||
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health` 与 `/api/health/simplelite` 的真实数据。
|
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health` 与 `/api/health/simple3` 的真实数据。
|
||||||
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`。
|
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`。
|
||||||
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
|
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
|
||||||
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
|
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
|
||||||
@@ -687,8 +687,8 @@ pnpm dev
|
|||||||
## 14. 快速索引
|
## 14. 快速索引
|
||||||
|
|
||||||
- 后端启动:`MiGu.Server/Program.cs`
|
- 后端启动:`MiGu.Server/Program.cs`
|
||||||
- 登录和启动 SimpleLite:`MiGu.Server/Controllers/AuthController.cs`
|
- 登录和启动 Simple3:`MiGu.Server/Controllers/AuthController.cs`
|
||||||
- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs`
|
- Simple3 拉起器:`MiGu.Server/Launcher/Simple3Launcher.cs`
|
||||||
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
|
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
|
||||||
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
|
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
|
||||||
- 权限 API:`MiGu.Server/Controllers/RbacController.cs`
|
- 权限 API:`MiGu.Server/Controllers/RbacController.cs`
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
namespace MiGu.Server.Fleet;
|
namespace MiGu.DB.Domains.Fleet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
|
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
|
||||||
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
|
/// 以任务 Id 为主键;Simple3/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史。
|
||||||
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CdmTaskRecord
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -1,10 +1,8 @@
|
|||||||
namespace MiGu.Server.Fleet;
|
namespace MiGu.DB.Domains.Fleet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
|
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
|
||||||
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
|
/// 出现报警→开一条 active 记录;文案/级别变化→先 clear 再建新 active;消失→cleared。
|
||||||
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
|
|
||||||
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VehicleAlarmRecord
|
public sealed class VehicleAlarmRecord
|
||||||
{
|
{
|
||||||
@@ -29,35 +29,16 @@ public sealed class SimpleFieldsCarTypeBackfillMigrator : IDataMigrator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>库区 LayoutMode 空值补 Flat(raw UPDATE,避开枚举 converter)。</summary>
|
||||||
/// 把库内旧状态字符串刷成规范枚举名(Available/Idle→Empty,Occupied→FullContainer)。
|
public sealed class AreaLayoutModeDefaultMigrator : IDataMigrator
|
||||||
/// 仅 Sqlite 表名/列名;绕过枚举 HasConversion(ExecuteUpdate + 字符串比较会 InvalidCast)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LegacyStatusNormalizationMigrator : IDataMigrator
|
|
||||||
{
|
{
|
||||||
public int Order => 15;
|
public int Order => 15;
|
||||||
public string Name => "Wms.LegacyStatusNormalization";
|
public string Name => "Wms.AreaLayoutModeDefault";
|
||||||
|
|
||||||
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
|
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (db is not MiGuDbContext) return;
|
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(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE wms_areas
|
UPDATE wms_areas
|
||||||
@@ -98,7 +79,7 @@ public static class DataMigratorRegistration
|
|||||||
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
|
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
|
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
|
||||||
services.AddSingleton<IDataMigrator, LegacyStatusNormalizationMigrator>();
|
services.AddSingleton<IDataMigrator, AreaLayoutModeDefaultMigrator>();
|
||||||
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
|
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,3 @@ public static class WmsReservationStatuses
|
|||||||
public const WmsReservationStatus Released = WmsReservationStatus.Released;
|
public const WmsReservationStatus Released = WmsReservationStatus.Released;
|
||||||
public const WmsReservationStatus Expired = WmsReservationStatus.Expired;
|
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,
|
Released,
|
||||||
Expired
|
Expired
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WmsDispatchStatus
|
|
||||||
{
|
|
||||||
Dispatched,
|
|
||||||
Failed
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ namespace MiGu.DB.Domains.Wms;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion<string>),成员名即库内合法值。
|
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion<string>),成员名即库内合法值。
|
||||||
/// 旧别名(Available/Idle/Occupied 等)不进入枚举,由 LegacyStatusNormalizationMigrator 刷库。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum AreaLayoutMode
|
public enum AreaLayoutMode
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,17 +33,8 @@ public static class StorageStatuses
|
|||||||
public static readonly HashSet<StorageStatus> All = new()
|
public static readonly HashSet<StorageStatus> All = new()
|
||||||
{ Empty, EmptyContainer, FullContainer, Disabled };
|
{ Empty, EmptyContainer, FullContainer, Disabled };
|
||||||
|
|
||||||
/// <summary>
|
public static StorageStatus ParseOr(string? value, StorageStatus fallback = StorageStatus.Empty) =>
|
||||||
/// 写路径归一:兼容历史字符串 Available/Idle/Occupied。
|
Enum.TryParse<StorageStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||||
/// 库内残留旧值须靠 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 class ContainerStatuses
|
public static class ContainerStatuses
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MiGu.DB.Domains.Dashboard;
|
using MiGu.DB.Domains.Dashboard;
|
||||||
|
using MiGu.DB.Domains.Fleet;
|
||||||
using MiGu.DB.Domains.SimpleFields;
|
using MiGu.DB.Domains.SimpleFields;
|
||||||
using MiGu.DB.Domains.Transport;
|
using MiGu.DB.Domains.Transport;
|
||||||
using MiGu.DB.Domains.Wms;
|
using MiGu.DB.Domains.Wms;
|
||||||
@@ -25,4 +26,6 @@ public partial class MiGuDbContext
|
|||||||
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
||||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||||
|
public DbSet<CdmTaskRecord> CdmTasks => Set<CdmTaskRecord>();
|
||||||
|
public DbSet<VehicleAlarmRecord> VehicleAlarms => Set<VehicleAlarmRecord>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ dotnet ef migrations add <迁移名称> `
|
|||||||
|
|
||||||
### 3.4 应用到本地库
|
### 3.4 应用到本地库
|
||||||
|
|
||||||
启动 `MiGu.Server` 即可(`EnsurePlatformDatabaseAsync` → `MigrateMiGuDbAsync`),或:
|
启动 `MiGu.Server` 即可(`Program.cs` 直接调用 `MigrateMiGuDbAsync`),或:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet ef database update `
|
dotnet ef database update `
|
||||||
@@ -151,7 +151,7 @@ dotnet ef database update <目标迁移名> `
|
|||||||
|
|
||||||
→ **[MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)**
|
→ **[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`。
|
**Migrate 基线:** 已有表、无 History 时,会把**全部** pending Migration 写入 `__EFMigrationsHistory`(假定 EnsureCreated 库已对齐模型 tip)。发版前若开发期改过模型,须先 `migrations add` 再切 `Migrate`。
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ dotnet ef database update <目标迁移名> `
|
|||||||
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
|
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
|
||||||
| 开发机整库清空重来 | 删 `data/platform.db*` 后启动(等同空库 Migrate);**不要**在生产用 EnsureDeleted |
|
| 开发机整库清空重来 | 删 `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);
|
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 =>
|
modelBuilder.Entity("MiGu.DB.Domains.SimpleFields.SimpleField", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
+1
-2
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
|
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
|
||||||
await app.Services.EnsurePlatformDatabaseAsync(); // 内部 MigrateMiGuDbAsync
|
await app.Services.MigrateMiGuDbAsync();
|
||||||
```
|
```
|
||||||
|
|
||||||
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
|
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
|
||||||
@@ -27,7 +27,6 @@ await app.Services.EnsurePlatformDatabaseAsync(); // 内部 Migrat
|
|||||||
## 实体与枚举
|
## 实体与枚举
|
||||||
|
|
||||||
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
|
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
|
||||||
- 旧库值(如 `Available`/`Idle`)由 `LegacyStatusNormalizationMigrator` 一次性刷成规范名。
|
|
||||||
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`。
|
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`。
|
||||||
- 复数辅助类(`StorageStatuses`、`LocationKinds`…)负责 API 字符串解析与集合校验。
|
- 复数辅助类(`StorageStatuses`、`LocationKinds`…)负责 API 字符串解析与集合校验。
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
namespace MiGu.Server.Auth;
|
namespace MiGu.Server.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
|
||||||
///
|
///
|
||||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
/// 设计动机:Simple3 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
|
||||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
/// (减少 Simple3 复杂度),采用一个轻量约定:
|
||||||
///
|
///
|
||||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
/// - <b>本机回环</b>(127.0.0.1 / ::1)Simple3 直接放行(开发机直连不受影响);
|
||||||
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header;
|
/// - <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;
|
/// 会自动追加该 header;
|
||||||
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
/// - Simple3 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||||
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
/// <c>SIMPLE3__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
||||||
///
|
///
|
||||||
/// Token 来源优先级:
|
/// Token 来源优先级:
|
||||||
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32)
|
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32)
|
||||||
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
|
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
|
||||||
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
|
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
|
||||||
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
|
/// 文件供本机 Simple3 读取(同机部署常见场景)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InternalTokenStore
|
public sealed class InternalTokenStore
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,7 @@ public sealed class InternalTokenStore
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
|
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 Simple3 读取
|
||||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||||
Directory.CreateDirectory(dataDir);
|
Directory.CreateDirectory(dataDir);
|
||||||
var file = Path.Combine(dataDir, ".internal-token");
|
var file = Path.Combine(dataDir, ".internal-token");
|
||||||
@@ -71,12 +71,12 @@ public sealed class InternalTokenStore
|
|||||||
PersistedFilePath = file;
|
PersistedFilePath = file;
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
||||||
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
|
"若 MiGu.Server 与 Simple3 不在同一台机器,请把同一个值写入 Simple3 端 simple.json:platform.internalToken。",
|
||||||
file);
|
file);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
|
logger.LogError(ex, "internal token 落盘失败 —— 远程 Simple3 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-18
@@ -29,9 +29,14 @@ public static class PageCatalog
|
|||||||
{
|
{
|
||||||
// ── 管理端 / Platform:概览 ──
|
// ── 管理端 / Platform:概览 ──
|
||||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||||
|
new("admin-setup", "初始配置", "概览", ScopePlatform),
|
||||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||||
|
new("admin-playback", "调度回放", "概览", ScopePlatform),
|
||||||
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||||
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||||
|
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
|
||||||
|
new("admin-notes", "运维备注", "概览", ScopePlatform),
|
||||||
|
new("admin-logs", "日志管理", "概览", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:设计与编排 ──
|
// ── 管理端 / Platform:设计与编排 ──
|
||||||
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
||||||
@@ -42,23 +47,25 @@ public static class PageCatalog
|
|||||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||||
|
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
|
||||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||||
|
|
||||||
|
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
|
||||||
|
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
|
||||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||||
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
|
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
|
||||||
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
|
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
|
||||||
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
|
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
|
||||||
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
|
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
|
||||||
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
|
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
|
||||||
|
|
||||||
// ── 运营端 / RCSMonitor ──
|
// ── 运营端 / RCSMonitor ──
|
||||||
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
|
|
||||||
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
|
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
|
||||||
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
|
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
|
||||||
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
|
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
|
||||||
new("monitor-notes", "运营备注", "运营监控", ScopeMonitor),
|
new("monitor-notes", "运维备注", "运营监控", ScopeMonitor),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> _keys =
|
private static readonly HashSet<string> _keys =
|
||||||
@@ -71,23 +78,79 @@ public static class PageCatalog
|
|||||||
["admin-config-vehicle"] = "admin-vehicle-hub",
|
["admin-config-vehicle"] = "admin-vehicle-hub",
|
||||||
["admin-config-fleet"] = "admin-vehicle-hub",
|
["admin-config-fleet"] = "admin-vehicle-hub",
|
||||||
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
|
// 会话 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-device"] = "admin-config-facility",
|
||||||
["admin-config-location"] = "admin-config-warehouse",
|
["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-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-map-monitor"] = "admin-config-ops-center",
|
||||||
["admin-config-system"] = "admin-config-system-center",
|
["admin-config-system-center"] = "admin-config-system",
|
||||||
["admin-config-auth"] = "admin-config-system-center",
|
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
|
||||||
|
["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>
|
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||||
public static bool IsValidKey(string key) => _keys.Contains(key);
|
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))
|
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
|
||||||
.Select(p => p.Key)
|
.Select(p => p.Key)
|
||||||
.ToList();
|
.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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
|
|||||||
///
|
///
|
||||||
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
|
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
|
||||||
/// admin(超级管理员,scope=*,全部页面 / 操作)
|
/// admin(超级管理员,scope=*,全部页面 / 操作)
|
||||||
/// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码)
|
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
|
||||||
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
|
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RbacStore
|
public sealed class RbacStore
|
||||||
@@ -58,9 +58,14 @@ public sealed class RbacStore
|
|||||||
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
|
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
|
||||||
if (snap is { Users.Count: > 0 })
|
if (snap is { Users.Count: > 0 })
|
||||||
{
|
{
|
||||||
_snapshot = Normalize(snap);
|
_snapshot = Normalize(snap, out var migrated);
|
||||||
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
|
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
|
||||||
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
|
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
|
||||||
|
if (migrated)
|
||||||
|
{
|
||||||
|
Persist();
|
||||||
|
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
|
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
|
||||||
@@ -82,21 +87,30 @@ public sealed class RbacStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
|
/// <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.Roles ??= new();
|
||||||
snap.Users ??= new();
|
snap.Users ??= new();
|
||||||
foreach (var r in snap.Roles)
|
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))
|
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
|
||||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
if (!SamePages(before, after)) migrated = true;
|
||||||
|
r.Pages = after;
|
||||||
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
||||||
r.WidgetGrants ??= new();
|
r.WidgetGrants ??= new();
|
||||||
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
|
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)
|
foreach (var u in snap.Users)
|
||||||
{
|
{
|
||||||
@@ -105,7 +119,12 @@ public sealed class RbacStore
|
|||||||
return snap;
|
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)
|
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
|
||||||
&& r.Scope != PageCatalog.Wildcard) return;
|
&& 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-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||||
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||||
r.Pages.Add("admin-simple-fields");
|
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)
|
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||||
@@ -153,7 +197,7 @@ public sealed class RbacStore
|
|||||||
{
|
{
|
||||||
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
|
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
|
||||||
Scope = PageCatalog.ScopeMonitor,
|
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 = new()
|
||||||
{
|
{
|
||||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
"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) =>
|
public bool CanUseScope(RbacUser user, string scope) =>
|
||||||
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
|
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>
|
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
|
||||||
public List<string> RoleNamesOf(RbacUser user)
|
public List<string> RoleNamesOf(RbacUser user)
|
||||||
{
|
{
|
||||||
@@ -270,7 +328,7 @@ public sealed class RbacStore
|
|||||||
foreach (var r in roles)
|
foreach (var r in roles)
|
||||||
{
|
{
|
||||||
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
|
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)
|
foreach (var o in r.Ops)
|
||||||
{
|
{
|
||||||
@@ -480,7 +538,11 @@ public sealed class RbacStore
|
|||||||
{
|
{
|
||||||
if (pages is null) return new();
|
if (pages is null) return new();
|
||||||
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
|
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
|
private static string NormalizeScope(string? scope) => scope switch
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||||
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
|
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
|
||||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ConfigStore
|
public sealed class ConfigStore
|
||||||
{
|
{
|
||||||
@@ -99,6 +99,58 @@ public sealed class ConfigStore
|
|||||||
return Put("deployment", el);
|
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)
|
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
|
||||||
{
|
{
|
||||||
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
|
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
|
||||||
|
|||||||
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
|
|||||||
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
["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()
|
public static IReadOnlyCollection<string> TailorablePages()
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
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 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;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
|
||||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
if (dp == null) return set;
|
if (dp == null) return set;
|
||||||
foreach (var m in dp.Modules ?? new List<string>())
|
foreach (var m in dp.Modules ?? new List<string>())
|
||||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
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;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
|
|||||||
/// <para>它是「按选型裁剪」的单一事实来源:</para>
|
/// <para>它是「按选型裁剪」的单一事实来源:</para>
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
|
/// <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>
|
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
|
||||||
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
|
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
@@ -35,7 +35,7 @@ public record DeploymentProfile(
|
|||||||
UpdatedBy: "");
|
UpdatedBy: "");
|
||||||
|
|
||||||
/// <summary>
|
/// <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> 一致;这是平台与内核之间「导航选型」的契约约定。
|
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||||
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>;
|
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>;
|
||||||
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
|
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
|
||||||
@@ -49,7 +49,7 @@ public record DeploymentProfile(
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
/// 把已选 <see cref="NavigationKinds"/> 映射为 Simple3 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
||||||
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
|
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<string> ToActiveSceneIds()
|
public IReadOnlyList<string> ToActiveSceneIds()
|
||||||
@@ -64,4 +64,36 @@ public record DeploymentProfile(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>信号交互场景 id(PLC 握手)。仅 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;<= 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;<= 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,12 +103,15 @@ public sealed class OpsAuditStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>最近的审计(倒序,最新在前)。</summary>
|
/// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
|
||||||
public IReadOnlyList<AuditEntry> Recent()
|
public IReadOnlyList<AuditEntry> Recent(string? user = null)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
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();
|
copy.Reverse();
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
namespace MiGu.Server.Configs;
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
public record SystemConfig
|
||||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
|
||||||
|
|
||||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
|
||||||
{
|
{
|
||||||
public static SystemConfig Default() => new(
|
public LogCleanupOptions LogCleanup { get; init; } = new();
|
||||||
DispatchLoopHz: 50,
|
|
||||||
Log: new LogPolicy("info", 7, 256),
|
public static SystemConfig Default() => new();
|
||||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,17 @@ namespace MiGu.Server.Controllers;
|
|||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 登录请求体。
|
/// 登录请求体。可选 <see cref="LaunchMode"/>;Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
|
||||||
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb";
|
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
|
||||||
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
|
|
||||||
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
|
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// 登录响应。
|
/// 登录响应。
|
||||||
/// 会话 N+1 增量字段:
|
/// 会话 N+1 增量字段:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
|
/// <item><c>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
|
||||||
/// <item><c>LaunchStatus</c>:<see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
/// <item><c>LaunchStatus</c>:<see cref="Simple3Launcher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
||||||
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -51,17 +49,18 @@ public class AuthController : ControllerBase
|
|||||||
string Scope,
|
string Scope,
|
||||||
string RunMode,
|
string RunMode,
|
||||||
EffectivePermissions EffectivePermissions,
|
EffectivePermissions EffectivePermissions,
|
||||||
bool NeedsWizard = false);
|
bool NeedsWizard = false,
|
||||||
|
string? Token = null);
|
||||||
|
|
||||||
private const string CookieName = "simple.auth.token";
|
private const string CookieName = "simple.auth.token";
|
||||||
|
|
||||||
private readonly RbacStore _rbac;
|
private readonly RbacStore _rbac;
|
||||||
private readonly JwtIssuer _jwt;
|
private readonly JwtIssuer _jwt;
|
||||||
private readonly SimpleLiteLauncher _launcher;
|
private readonly Simple3Launcher _launcher;
|
||||||
private readonly ConfigStore _config;
|
private readonly ConfigStore _config;
|
||||||
private readonly ILogger<AuthController> _log;
|
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;
|
_rbac = rbac;
|
||||||
_jwt = jwt;
|
_jwt = jwt;
|
||||||
@@ -79,41 +78,40 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.Username))
|
if (string.IsNullOrWhiteSpace(req.Username))
|
||||||
return BadRequest(new { message = "用户名不能为空" });
|
return BadRequest(new { message = "用户名不能为空" });
|
||||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
|
||||||
return BadRequest(new { message = "无效 scope" });
|
|
||||||
|
|
||||||
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
|
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
|
||||||
var user = _rbac.VerifyCredentials(req.Username, req.Password);
|
var user = _rbac.VerifyCredentials(req.Username, req.Password);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
|
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
|
||||||
|
|
||||||
// scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。
|
// 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」。
|
||||||
if (!_rbac.CanUseScope(user, req.Scope))
|
var scope = _rbac.ResolveLoginScope(user);
|
||||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
|
if (scope == null)
|
||||||
|
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
|
||||||
|
|
||||||
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
|
// 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。
|
||||||
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
||||||
SimpleLiteLauncher.LaunchResult? launchResult = null;
|
Simple3Launcher.LaunchResult? launchResult = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// M1:waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
|
// M1:waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口
|
||||||
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
|
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
|
||||||
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
|
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);
|
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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 runMode = ResolveRunMode(launchResult, launchMode);
|
||||||
|
|
||||||
var (perm, roleNames, token) = BuildSession(user, req.Scope);
|
var (perm, roleNames, token) = BuildSession(user, scope);
|
||||||
SetAuthCookie(token);
|
SetAuthCookie(token);
|
||||||
|
|
||||||
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
|
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,
|
LaunchStatus: launchResult?.Status,
|
||||||
LaunchWarning: launchResult?.Warning,
|
LaunchWarning: launchResult?.Warning,
|
||||||
NeedsWizard: NeedsWizard()));
|
NeedsWizard: NeedsWizard()));
|
||||||
@@ -144,13 +142,20 @@ public class AuthController : ControllerBase
|
|||||||
if (user == null || !user.Enabled)
|
if (user == null || !user.Enabled)
|
||||||
return Unauthorized(new { message = "账号已失效或被停用" });
|
return Unauthorized(new { message = "账号已失效或被停用" });
|
||||||
|
|
||||||
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
|
var preferred = _rbac.ResolveLoginScope(user);
|
||||||
if (!_rbac.CanUseScope(user, scope))
|
if (preferred == null)
|
||||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
|
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);
|
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>
|
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
|
||||||
@@ -172,6 +177,11 @@ public class AuthController : ControllerBase
|
|||||||
if (!_rbac.CanUseScope(user, req.Scope))
|
if (!_rbac.CanUseScope(user, req.Scope))
|
||||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {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);
|
var (perm, roleNames, token) = BuildSession(user, req.Scope);
|
||||||
SetAuthCookie(token);
|
SetAuthCookie(token);
|
||||||
|
|
||||||
@@ -200,22 +210,23 @@ public class AuthController : ControllerBase
|
|||||||
return (perm, roleNames, token);
|
return (perm, roleNames, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
|
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
|
||||||
private string InferRunMode()
|
private string InferRunMode()
|
||||||
{
|
{
|
||||||
var last = _launcher.LastLaunchMode;
|
var last = _launcher.LastLaunchMode;
|
||||||
if (string.IsNullOrEmpty(last)) return "Detached";
|
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";
|
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
|
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
|
||||||
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
|
private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode)
|
||||||
{
|
{
|
||||||
if (result is not { Started: true })
|
if (result is not { Started: true })
|
||||||
return "Detached";
|
return "Detached";
|
||||||
|
// Simple3 无本地端:复用既有实例也按 WebOnly 展示。
|
||||||
if (result.Value.Status == "ReusingExisting")
|
if (result.Value.Status == "ReusingExisting")
|
||||||
return "WebEnabled";
|
return "WebOnly";
|
||||||
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
||||||
{
|
{
|
||||||
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -230,8 +241,9 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
return raw?.Trim().ToLowerInvariant() switch
|
return raw?.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"webonly" or "web-only" or "web" => "WebOnly",
|
"desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb",
|
||||||
_ => "DesktopAndWeb",
|
// Simple3 默认仅 Web;未传 / 未知也走 WebOnly
|
||||||
|
_ => "WebOnly",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using MiGu.Server.Configs;
|
using MiGu.Server.Configs;
|
||||||
|
using MiGu.Server.Logs;
|
||||||
|
|
||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
|
|||||||
public class ConfigController : ControllerBase
|
public class ConfigController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ConfigStore _store;
|
private readonly ConfigStore _store;
|
||||||
|
private readonly LogCleanupService _cleanup;
|
||||||
|
|
||||||
public ConfigController(ConfigStore store)
|
public ConfigController(ConfigStore store, LogCleanupService cleanup)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
|
_cleanup = cleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var env = _store.Put(section, payload);
|
var env = _store.Put(section, payload);
|
||||||
|
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
|
||||||
|
_cleanup.ApplySchedule();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
section = env.Section,
|
section = env.Section,
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ public sealed class FleetController : ControllerBase
|
|||||||
private readonly FleetHealthService _health;
|
private readonly FleetHealthService _health;
|
||||||
private readonly CdmTaskSyncer _cdmSyncer;
|
private readonly CdmTaskSyncer _cdmSyncer;
|
||||||
private readonly AlarmCollector _alarmCollector;
|
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;
|
_health = health;
|
||||||
_cdmSyncer = cdmSyncer;
|
_cdmSyncer = cdmSyncer;
|
||||||
@@ -31,7 +31,7 @@ public sealed class FleetController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
|
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
|
||||||
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
|
/// Simple3 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("tasks")]
|
[HttpGet("tasks")]
|
||||||
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
|
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
|
||||||
@@ -76,7 +76,7 @@ public sealed class FleetController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
|
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;Simple3 离线时回退最近记录,
|
||||||
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
|
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("alarms")]
|
[HttpGet("alarms")]
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ namespace MiGu.Server.Controllers;
|
|||||||
public class HealthController : ControllerBase
|
public class HealthController : ControllerBase
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
|
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>
|
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -26,28 +26,28 @@ public class HealthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
/// Simple3 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>Simple3</c> 节点。
|
||||||
/// 含服务器本地路径等敏感信息,要求登录。
|
/// 含服务器本地路径等敏感信息,要求登录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("simplelite")]
|
[HttpGet("simple3")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
public IActionResult GetSimple3Diagnostics() => Ok(_launcher.GetDiagnostics());
|
||||||
|
|
||||||
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
|
/// <summary>关闭本机全部 Simple3 进程。仅 Platform 管理端可调。</summary>
|
||||||
[HttpPost("simplelite/stop")]
|
[HttpPost("simple3/stop")]
|
||||||
[Authorize(Policy = "PlatformScope")]
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult StopSimpleLite()
|
public IActionResult StopSimple3()
|
||||||
{
|
{
|
||||||
var killed = _launcher.StopAll();
|
var killed = _launcher.StopAll();
|
||||||
var diag = _launcher.GetDiagnostics();
|
var diag = _launcher.GetDiagnostics();
|
||||||
return Ok(new { killed, diagnostics = diag });
|
return Ok(new { killed, diagnostics = diag });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
|
/// <summary>关闭并重新拉起 Simple3(不同步 DLL)。仅 Platform 管理端可调。</summary>
|
||||||
[HttpPost("simplelite/restart")]
|
[HttpPost("simple3/restart")]
|
||||||
[Authorize(Policy = "PlatformScope")]
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
|
public IActionResult RestartSimple3([FromQuery] string launchMode = "webonly")
|
||||||
{
|
{
|
||||||
var result = _launcher.Restart(launchMode);
|
var result = _launcher.Restart(launchMode);
|
||||||
var diag = _launcher.GetDiagnostics();
|
var diag = _launcher.GetDiagnostics();
|
||||||
@@ -55,12 +55,12 @@ public class HealthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
/// 关闭 Simple3、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||||
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
/// 会终止本机全部 Simple3 进程并重启,仅 Platform 管理端可调。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("simplelite/restart-for-update")]
|
[HttpPost("simple3/restart-for-update")]
|
||||||
[Authorize(Policy = "PlatformScope")]
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
|
public IActionResult RestartSimple3ForUpdate([FromQuery] string launchMode = "webonly")
|
||||||
{
|
{
|
||||||
var result = _launcher.RestartForUpdate(launchMode);
|
var result = _launcher.RestartForUpdate(launchMode);
|
||||||
var diag = _launcher.GetDiagnostics();
|
var diag = _launcher.GetDiagnostics();
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ using System.Globalization;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
|
using MiGu.Server.Logs;
|
||||||
|
|
||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <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)暴露给
|
/// (<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 端额外提供「按标签合订」视图。
|
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
|
||||||
///
|
///
|
||||||
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>,
|
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>,
|
||||||
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||||
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
||||||
///
|
///
|
||||||
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</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 =
|
private static readonly Regex NumericFieldRegex =
|
||||||
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
|
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 IConfiguration _config;
|
||||||
private readonly ILogger<LogsController> _log;
|
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;
|
_launcher = launcher;
|
||||||
_config = config;
|
_config = config;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_cleanup = cleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────── 概览 ──
|
// ─────────────────────────────────────────────────────────── 概览 ──
|
||||||
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
|
|||||||
{
|
{
|
||||||
var (root, workdir, error) = ResolveLogRoot();
|
var (root, workdir, error) = ResolveLogRoot();
|
||||||
if (root == null)
|
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))
|
if (!Directory.Exists(root))
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
exists = false, root, workingDirectory = workdir,
|
exists = false, root, workingDirectory = workdir,
|
||||||
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
|
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
|
||||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
|
||||||
|
disk = DiskDto()
|
||||||
});
|
});
|
||||||
|
|
||||||
var files = EnumerateLogFiles(root);
|
var files = EnumerateLogFiles(root);
|
||||||
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
|
|||||||
totalFiles = files.Count,
|
totalFiles = files.Count,
|
||||||
totalBytes = files.Sum(f => f.Bytes),
|
totalBytes = files.Sum(f => f.Bytes),
|
||||||
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
|
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,
|
root = normRoot, exists = false, path = "", parent = (string?)null,
|
||||||
dirCount = 0, fileCount = 0,
|
dirCount = 0, fileCount = 0,
|
||||||
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
|
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
|
||||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
|
||||||
});
|
});
|
||||||
|
|
||||||
var target = SafeResolveDir(normRoot, path);
|
var target = SafeResolveDir(normRoot, path);
|
||||||
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
|
|||||||
return PhysicalFile(full, "application/octet-stream", downloadName);
|
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 ──
|
// ─────────────────────────────────────────────────────── 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()
|
private (string? root, string? workdir, string? error) ResolveLogRoot()
|
||||||
{
|
{
|
||||||
var overrideRoot = _config["Logs:Root"];
|
var overrideRoot = _config["Logs:Root"];
|
||||||
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
|
|||||||
var wd = _launcher.ResolveWorkingDirectory();
|
var wd = _launcher.ResolveWorkingDirectory();
|
||||||
if (string.IsNullOrWhiteSpace(wd))
|
if (string.IsNullOrWhiteSpace(wd))
|
||||||
return (null, null,
|
return (null, null,
|
||||||
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory," +
|
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory," +
|
||||||
"或显式设置 Logs:Root 指向日志根目录。");
|
"或显式设置 Logs:Root 指向日志根目录。");
|
||||||
|
|
||||||
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ using MiGu.Server.Launcher;
|
|||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
|
/// 读取 Simple3 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
|
||||||
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
|
/// 先向 Simple3 拉取 maps 列表拿到 directory,再读本机同路径文件(与 Simple3 同机部署)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -18,13 +18,13 @@ public class MapsContentController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly InternalTokenStore _internalToken;
|
private readonly InternalTokenStore _internalToken;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly ILogger<MapsContentController> _log;
|
private readonly ILogger<MapsContentController> _log;
|
||||||
|
|
||||||
public MapsContentController(
|
public MapsContentController(
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
InternalTokenStore internalToken,
|
InternalTokenStore internalToken,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
ILogger<MapsContentController> log)
|
ILogger<MapsContentController> log)
|
||||||
{
|
{
|
||||||
_httpFactory = httpFactory;
|
_httpFactory = httpFactory;
|
||||||
@@ -47,7 +47,7 @@ public class MapsContentController : ControllerBase
|
|||||||
{
|
{
|
||||||
var directory = await FetchMapsDirectoryAsync(ct);
|
var directory = await FetchMapsDirectoryAsync(ct);
|
||||||
if (string.IsNullOrWhiteSpace(directory))
|
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 fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
|
||||||
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
|
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
|
||||||
@@ -86,7 +86,7 @@ public class MapsContentController : ControllerBase
|
|||||||
using var resp = await client.SendAsync(msg, ct);
|
using var resp = await client.SendAsync(msg, ct);
|
||||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
if (!resp.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(body);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|||||||
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
|
|||||||
/// M4 修复(运维操作真实下发 + 审计落库):
|
/// M4 修复(运维操作真实下发 + 审计落库):
|
||||||
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
|
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
|
||||||
/// “暂停成功”但内核毫无反应,且重启审计全丢);
|
/// “暂停成功”但内核毫无反应,且重启审计全丢);
|
||||||
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute,
|
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute,
|
||||||
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
|
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
|
||||||
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
|
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
|
||||||
///
|
///
|
||||||
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
|
/// 关于映射:
|
||||||
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
|
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
|
||||||
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
|
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
|
||||||
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发。
|
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/sl/ops")]
|
[Route("api/sl/ops")]
|
||||||
public class OpsController : ControllerBase
|
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);
|
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||||
|
|
||||||
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
"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"
|
"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 OpsAuditStore _audits;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly InternalTokenStore _internalToken;
|
private readonly InternalTokenStore _internalToken;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly ILogger<OpsController> _log;
|
private readonly ILogger<OpsController> _log;
|
||||||
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
|
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
|
||||||
|
|
||||||
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
|
|||||||
OpsAuditStore audits,
|
OpsAuditStore audits,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
InternalTokenStore internalToken,
|
InternalTokenStore internalToken,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
ILogger<OpsController> log)
|
ILogger<OpsController> log)
|
||||||
{
|
{
|
||||||
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
|
|||||||
// AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。
|
// AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。
|
||||||
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
||||||
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
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}" });
|
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";
|
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||||
|
|
||||||
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
|
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
|
||||||
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
|
|||||||
if (req.OpCode == "monitor.note.write")
|
if (req.OpCode == "monitor.note.write")
|
||||||
return Ok(Done(user, scope, req, "ok", req.Reason));
|
return Ok(Done(user, scope, req, "ok", req.Reason));
|
||||||
|
|
||||||
|
if (req.OpCode == CarExecuteOp)
|
||||||
|
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
|
||||||
|
|
||||||
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
|
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
|
||||||
if (!_dispatch.TryGetValue(req.OpCode, out var map))
|
if (!_dispatch.TryGetValue(req.OpCode, out var map))
|
||||||
return Ok(Done(user, scope, req, "unmapped",
|
return Ok(Done(user, scope, req, "unmapped",
|
||||||
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
|
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
|
||||||
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
|
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
|
||||||
ok: false));
|
ok: false));
|
||||||
|
|
||||||
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
|
|||||||
if (numericId is null)
|
if (numericId is null)
|
||||||
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
|
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
|
||||||
|
|
||||||
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
|
var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user);
|
||||||
string result;
|
return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok));
|
||||||
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"));
|
/// <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")]
|
[HttpGet("audits")]
|
||||||
public IActionResult Audits200() => Ok(_audits.Recent());
|
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
|
||||||
|
|
||||||
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
|
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
|
||||||
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
|
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);
|
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>
|
/// <summary>
|
||||||
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
|
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
|
||||||
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
|
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
|
||||||
@@ -195,4 +251,206 @@ public class OpsController : ControllerBase
|
|||||||
catch { /* ignore,下面回退裁剪原文 */ }
|
catch { /* ignore,下面回退裁剪原文 */ }
|
||||||
return body.Length <= 200 ? body : body[..200] + "…";
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ public class OtaController : ControllerBase
|
|||||||
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
|
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
var ops = User.FindFirst("ops")?.Value ?? "";
|
var ops = User.FindFirst("ops")?.Value ?? "";
|
||||||
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
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)
|
private bool DenyWrite(out ActionResult denied)
|
||||||
@@ -147,12 +150,32 @@ public class OtaController : ControllerBase
|
|||||||
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
|
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
|
||||||
var time = DateTime.Now.ToString("yyyyMMddHHmmss");
|
var time = DateTime.Now.ToString("yyyyMMddHHmmss");
|
||||||
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
|
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
|
||||||
// 等待文件落盘
|
// 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包
|
||||||
await Task.Delay(1500, 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);
|
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);
|
await Task.Delay(500, ct);
|
||||||
}
|
}
|
||||||
_store.ClearActivePull(car.Ip);
|
_store.ClearActivePull(car.Ip);
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ namespace MiGu.Server.Controllers;
|
|||||||
/// WatchDog 回传包接收端。
|
/// WatchDog 回传包接收端。
|
||||||
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey},
|
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey},
|
||||||
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
|
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
|
||||||
|
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public class OtaReceiveController : ControllerBase
|
public class OtaReceiveController : ControllerBase
|
||||||
{
|
{
|
||||||
|
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
|
||||||
|
|
||||||
private readonly OtaStore _store;
|
private readonly OtaStore _store;
|
||||||
private readonly ILogger<OtaReceiveController> _log;
|
private readonly ILogger<OtaReceiveController> _log;
|
||||||
|
|
||||||
@@ -40,15 +43,15 @@ public class OtaReceiveController : ControllerBase
|
|||||||
[RequestSizeLimit(512_000_000)]
|
[RequestSizeLimit(512_000_000)]
|
||||||
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
|
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = ResolveTcpRemoteIp();
|
||||||
if (!_store.TryGetActivePullId(ip, out _))
|
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");
|
return BadRequest("no active pull session");
|
||||||
}
|
}
|
||||||
|
|
||||||
var day = DateTime.Now.ToString("yyyy-MM-dd");
|
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);
|
Directory.CreateDirectory(dir);
|
||||||
var file = await ReadFirstFileAsync(ct);
|
var file = await ReadFirstFileAsync(ct);
|
||||||
if (file == null || file.Length == 0) return BadRequest("empty");
|
if (file == null || file.Length == 0) return BadRequest("empty");
|
||||||
@@ -58,6 +61,7 @@ public class OtaReceiveController : ControllerBase
|
|||||||
var path = Path.Combine(dir, safeName);
|
var path = Path.Combine(dir, safeName);
|
||||||
await using var fs = System.IO.File.Create(path);
|
await using var fs = System.IO.File.Create(path);
|
||||||
await file.CopyToAsync(fs, ct);
|
await file.CopyToAsync(fs, ct);
|
||||||
|
_store.NotePullReceive(ip);
|
||||||
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
|
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
|
||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
@@ -66,7 +70,7 @@ public class OtaReceiveController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var clientIp = ResolveTcpRemoteIp();
|
||||||
if (!_store.TryGetActivePullId(clientIp, out _))
|
if (!_store.TryGetActivePullId(clientIp, out _))
|
||||||
{
|
{
|
||||||
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown");
|
_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)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||||
await using (var fs = System.IO.File.Create(dest))
|
await using (var fs = System.IO.File.Create(dest))
|
||||||
await file.CopyToAsync(fs, ct);
|
await file.CopyToAsync(fs, ct);
|
||||||
|
_store.NotePullReceive(clientIp);
|
||||||
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length);
|
_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)
|
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)
|
private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!Request.HasFormContentType) return null;
|
if (!Request.HasFormContentType) return null;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
/// 投影 API 占位:真实落地时由 YARP 反代到 Simple3 WebAPI 的 /api/projection/* 路径。
|
||||||
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
/// 本地 Mock 数据仅用于无 Simple3 运行时的开发联调。
|
||||||
///
|
///
|
||||||
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ public class RbacController : ControllerBase
|
|||||||
new("ops.car.gohome", "车辆 · 回库"),
|
new("ops.car.gohome", "车辆 · 回库"),
|
||||||
new("ops.car.resetSession", "车辆 · 重置会话"),
|
new("ops.car.resetSession", "车辆 · 重置会话"),
|
||||||
new("ops.car.manualCharge", "车辆 · 手动充电"),
|
new("ops.car.manualCharge", "车辆 · 手动充电"),
|
||||||
|
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
|
||||||
new("ops.task.pause", "任务 · 暂停"),
|
new("ops.task.pause", "任务 · 暂停"),
|
||||||
new("ops.task.cancel", "任务 · 取消"),
|
new("ops.task.cancel", "任务 · 取消"),
|
||||||
new("ops.task.reassign", "任务 · 改派"),
|
new("ops.task.reassign", "任务 · 改派"),
|
||||||
new("ops.task.boostPriority", "任务 · 提升优先级"),
|
new("ops.task.boostPriority", "任务 · 提升优先级"),
|
||||||
|
new("ops.ota", "OTA · 运维读写"),
|
||||||
|
new("ops.ota.write", "OTA · 写操作"),
|
||||||
new("monitor.note.write", "监控 · 写运营备注"),
|
new("monitor.note.write", "监控 · 写运营备注"),
|
||||||
new("auth.manage", "系统 · 权限与角色管理"),
|
new("auth.manage", "系统 · 权限与角色管理"),
|
||||||
};
|
};
|
||||||
@@ -59,18 +62,35 @@ public class RbacController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
|
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
|
||||||
[HttpGet("catalog")]
|
[HttpGet("catalog")]
|
||||||
public IActionResult Catalog() => Ok(new
|
public IActionResult Catalog()
|
||||||
|
{
|
||||||
|
var actorPages = ActorPlatformPages();
|
||||||
|
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
|
||||||
|
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
|
||||||
|
return Ok(new
|
||||||
{
|
{
|
||||||
pages = PageCatalog.All,
|
pages = PageCatalog.All,
|
||||||
ops = KnownOps,
|
ops = KnownOps,
|
||||||
widgets = KnownWidgets,
|
widgets = KnownWidgets,
|
||||||
scopes = new[]
|
scopes = new[]
|
||||||
{
|
{
|
||||||
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
|
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
|
||||||
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
|
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
|
||||||
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
|
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());
|
public IActionResult ListRoles() => Ok(_store.ListRoles());
|
||||||
|
|
||||||
[HttpPost("roles")]
|
[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}")]
|
[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}")]
|
[HttpDelete("roles/{id}")]
|
||||||
public IActionResult DeleteRole(string id) => Guard(() =>
|
public IActionResult DeleteRole(string id) => Guard(() =>
|
||||||
@@ -134,4 +156,43 @@ public class RbacController : ControllerBase
|
|||||||
|
|
||||||
private string? CurrentUserId() =>
|
private string? CurrentUserId() =>
|
||||||
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
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>platform.db</c> 的 <c>simple_fields</c> 表。
|
||||||
///
|
///
|
||||||
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
|
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
|
||||||
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
/// 前端主流程为「刷新」读库、「默认」从 Simple3 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ namespace MiGu.Server.Controllers;
|
|||||||
/// </list>
|
/// </list>
|
||||||
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
||||||
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
||||||
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
|
/// 驱动 Simple3 下次启动选择性加载选定的导航场景插件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -25,10 +25,10 @@ namespace MiGu.Server.Controllers;
|
|||||||
public class WizardController : ControllerBase
|
public class WizardController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ConfigStore _store;
|
private readonly ConfigStore _store;
|
||||||
private readonly SimpleLiteLauncher _launcher;
|
private readonly Simple3Launcher _launcher;
|
||||||
private readonly ILogger<WizardController> _log;
|
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;
|
_store = store;
|
||||||
_launcher = launcher;
|
_launcher = launcher;
|
||||||
@@ -81,12 +81,11 @@ public class WizardController : ControllerBase
|
|||||||
|
|
||||||
_store.PutDeployment(profile);
|
_store.PutDeployment(profile);
|
||||||
|
|
||||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
// 平台 → 内核联动:把导航选型写入 Simple3 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手)。
|
||||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
var sceneIds = profile.ToLauncherSceneIds();
|
||||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
|
||||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||||
|
|
||||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||||
@@ -108,7 +107,7 @@ public class WizardController : ControllerBase
|
|||||||
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
|
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
|
||||||
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
|
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
|
||||||
/// </summary>
|
/// </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,
|
configured = dp.Configured,
|
||||||
platformType = dp.PlatformType,
|
platformType = dp.PlatformType,
|
||||||
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
|
|||||||
navigationKinds = dp.NavigationKinds,
|
navigationKinds = dp.NavigationKinds,
|
||||||
scenarios = dp.Scenarios,
|
scenarios = dp.Scenarios,
|
||||||
updatedBy = dp.UpdatedBy,
|
updatedBy = dp.UpdatedBy,
|
||||||
activeSceneIds = dp.ToActiveSceneIds(),
|
activeSceneIds = dp.ToLauncherSceneIds(),
|
||||||
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
||||||
activeScenesWrite = write == null ? null : new
|
activeScenesWrite = write == null ? null : new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -192,10 +192,6 @@ public sealed class WmsController : ControllerBase
|
|||||||
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q, [FromQuery] Guid? containerId) =>
|
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q, [FromQuery] Guid? containerId) =>
|
||||||
_service.ContainerMaterials(q, 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")]
|
[HttpPost("container-materials/bind")]
|
||||||
public async Task<IActionResult> BindMaterial([FromBody] BindMaterialRequest req) =>
|
public async Task<IActionResult> BindMaterial([FromBody] BindMaterialRequest req) =>
|
||||||
Ok(await _service.BindMaterial(req, User.ActorName()));
|
Ok(await _service.BindMaterial(req, User.ActorName()));
|
||||||
|
|||||||
@@ -17,16 +17,23 @@ public static class DashboardShortcutCatalog
|
|||||||
["platform-config"] = "admin-map-editor",
|
["platform-config"] = "admin-map-editor",
|
||||||
["mission"] = "admin-task-templates",
|
["mission"] = "admin-task-templates",
|
||||||
["cars"] = "admin-cars",
|
["cars"] = "admin-cars",
|
||||||
["auth"] = "admin-config-system-center",
|
["auth"] = "admin-config-auth",
|
||||||
["system"] = "admin-config-system-center",
|
["system"] = "admin-config-system",
|
||||||
|
["admin-config-system-center"] = "admin-config-auth",
|
||||||
["ops"] = "admin-config-ops-center",
|
["ops"] = "admin-config-ops-center",
|
||||||
["tasks"] = "admin-config-strategy",
|
["tasks"] = "admin-task-templates",
|
||||||
|
["admin-config-strategy"] = "admin-task-templates",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||||
[
|
[
|
||||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
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-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-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||||
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
||||||
new("admin-project-properties", "admin-project-properties", 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-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||||
new("admin-simple-fields", "admin-simple-fields", 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-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||||
new("admin-config-facility", "admin-config-facility", 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-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 =
|
private static readonly ShortcutDef[] MonitorShortcuts =
|
||||||
[
|
[
|
||||||
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
|
|
||||||
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
|
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
|
||||||
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
|
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
|
||||||
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
|
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
|
||||||
@@ -78,16 +86,16 @@ public static class DashboardShortcutCatalog
|
|||||||
"admin-map-monitor",
|
"admin-map-monitor",
|
||||||
"admin-vehicle-hub",
|
"admin-vehicle-hub",
|
||||||
"admin-task-templates",
|
"admin-task-templates",
|
||||||
"admin-config-system-center",
|
"admin-config-auth",
|
||||||
"admin-config-ops-center",
|
"admin-config-ops-center",
|
||||||
"admin-config-strategy"
|
"admin-config-facility"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
|
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
|
||||||
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
|
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
|
||||||
|
|
||||||
private static readonly HashSet<string> ExcludedKeys =
|
private static readonly HashSet<string> ExcludedKeys =
|
||||||
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
|
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard" };
|
||||||
|
|
||||||
public static bool IsValidKey(string key) =>
|
public static bool IsValidKey(string key) =>
|
||||||
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
|
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Dashboard;
|
|||||||
|
|
||||||
public sealed class DashboardShortcutService
|
public sealed class DashboardShortcutService
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
private readonly RbacStore _rbac;
|
private readonly RbacStore _rbac;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
@@ -15,7 +15,7 @@ public sealed class DashboardShortcutService
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
};
|
};
|
||||||
|
|
||||||
public DashboardShortcutService(PlatformDbContext db, RbacStore rbac)
|
public DashboardShortcutService(MiGuDbContext db, RbacStore rbac)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_rbac = rbac;
|
_rbac = rbac;
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ using MiGu.Server.Persistence;
|
|||||||
namespace MiGu.Server.Fleet;
|
namespace MiGu.Server.Fleet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
|
/// 单例:轮询 Simple3 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
|
||||||
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
|
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;Simple3 离线仍可查最近记录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AlarmCollector
|
public sealed class AlarmCollector
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly InternalTokenStore _token;
|
private readonly InternalTokenStore _token;
|
||||||
private readonly ILogger<AlarmCollector> _log;
|
private readonly ILogger<AlarmCollector> _log;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
@@ -28,7 +28,7 @@ public sealed class AlarmCollector
|
|||||||
public AlarmCollector(
|
public AlarmCollector(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
InternalTokenStore token,
|
InternalTokenStore token,
|
||||||
ILogger<AlarmCollector> log)
|
ILogger<AlarmCollector> log)
|
||||||
{
|
{
|
||||||
@@ -76,7 +76,7 @@ public sealed class AlarmCollector
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
|
||||||
await ReconcileAsync(db, current, ct);
|
await ReconcileAsync(db, current, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -161,25 +161,33 @@ 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 now = DateTimeOffset.UtcNow;
|
||||||
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
|
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
|
||||||
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
|
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
|
||||||
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
|
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
|
||||||
|
|
||||||
// 出现 / 更新
|
// 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
|
||||||
foreach (var cur in current.Values)
|
foreach (var cur in current.Values)
|
||||||
{
|
{
|
||||||
if (activeByCar.TryGetValue(cur.CarId, out var rec))
|
if (activeByCar.TryGetValue(cur.CarId, out var rec))
|
||||||
{
|
{
|
||||||
rec.Info = cur.Info;
|
var same =
|
||||||
rec.Level = cur.Level;
|
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
|
||||||
|
rec.Level == cur.Level;
|
||||||
|
if (same)
|
||||||
|
{
|
||||||
rec.CarName = cur.CarName;
|
rec.CarName = cur.CarName;
|
||||||
rec.LastAt = now;
|
rec.LastAt = now;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
rec.Status = "cleared";
|
||||||
|
rec.ResolvedAt = now;
|
||||||
|
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
db.VehicleAlarms.Add(new VehicleAlarmRecord
|
db.VehicleAlarms.Add(new VehicleAlarmRecord
|
||||||
{
|
{
|
||||||
CarId = cur.CarId,
|
CarId = cur.CarId,
|
||||||
@@ -191,7 +199,6 @@ public sealed class AlarmCollector
|
|||||||
LastAt = now
|
LastAt = now
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 消失 → 恢复
|
// 消失 → 恢复
|
||||||
foreach (var rec in active)
|
foreach (var rec in active)
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ public sealed class CdmTaskDto
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
|
/// 单例:从 Simple3 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
|
||||||
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
|
/// 同时维护「Simple3 是否在线 / 最近同步时间」,供任务页离线降级展示。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CdmTaskSyncer
|
public sealed class CdmTaskSyncer
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly InternalTokenStore _token;
|
private readonly InternalTokenStore _token;
|
||||||
private readonly ILogger<CdmTaskSyncer> _log;
|
private readonly ILogger<CdmTaskSyncer> _log;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
@@ -53,7 +53,7 @@ public sealed class CdmTaskSyncer
|
|||||||
public CdmTaskSyncer(
|
public CdmTaskSyncer(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
InternalTokenStore token,
|
InternalTokenStore token,
|
||||||
ILogger<CdmTaskSyncer> log)
|
ILogger<CdmTaskSyncer> log)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ public sealed class CdmTaskSyncer
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
|
||||||
await UpsertAsync(db, dtos, ct);
|
await UpsertAsync(db, dtos, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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();
|
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
|
||||||
if (valid.Count == 0) return;
|
if (valid.Count == 0) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MiGu.Server.Fleet;
|
namespace MiGu.Server.Fleet;
|
||||||
|
|
||||||
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
|
/// <summary>与 Simple3 GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
|
||||||
public sealed class FleetHealthRowDto
|
public sealed class FleetHealthRowDto
|
||||||
{
|
{
|
||||||
public int CarId { get; set; }
|
public int CarId { get; set; }
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ using MiGu.Server.Ota;
|
|||||||
namespace MiGu.Server.Fleet;
|
namespace MiGu.Server.Fleet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
|
/// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
|
||||||
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
|
/// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FleetHealthService
|
public sealed class FleetHealthService
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly InternalTokenStore _token;
|
private readonly InternalTokenStore _token;
|
||||||
private readonly WatchDogClient _wd;
|
private readonly WatchDogClient _wd;
|
||||||
private readonly ILogger<FleetHealthService> _log;
|
private readonly ILogger<FleetHealthService> _log;
|
||||||
@@ -25,7 +25,7 @@ public sealed class FleetHealthService
|
|||||||
|
|
||||||
public FleetHealthService(
|
public FleetHealthService(
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
InternalTokenStore token,
|
InternalTokenStore token,
|
||||||
WatchDogClient wd,
|
WatchDogClient wd,
|
||||||
ILogger<FleetHealthService> log)
|
ILogger<FleetHealthService> log)
|
||||||
@@ -39,7 +39,7 @@ public sealed class FleetHealthService
|
|||||||
|
|
||||||
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
|
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rows = await FetchSimpleLiteHealthAsync(ct);
|
var rows = await FetchSimple3HealthAsync(ct);
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
rows = await BuildRowsFromCarsAsync(ct);
|
rows = await BuildRowsFromCarsAsync(ct);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public sealed class FleetHealthService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(row.Ip))
|
if (string.IsNullOrWhiteSpace(row.Ip))
|
||||||
{
|
{
|
||||||
// 无 IP 时保留 SimpleLite 原探测结果
|
// 无 IP 时保留 Simple3 原探测结果
|
||||||
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
|
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ public sealed class FleetHealthService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
|
// WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达
|
||||||
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
|
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
|
||||||
{
|
{
|
||||||
row.LatencySource = "onboard";
|
row.LatencySource = "onboard";
|
||||||
@@ -93,7 +93,7 @@ public sealed class FleetHealthService
|
|||||||
return rows;
|
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;
|
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||||
try
|
try
|
||||||
@@ -114,7 +114,7 @@ public sealed class FleetHealthService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
|
_log.LogDebug(ex, "fleet/health from Simple3 failed");
|
||||||
return new();
|
return new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
global using PlatformDbContext = MiGu.DB.Kernel.Context.MiGuDbContext;
|
global using MiGu.DB.Kernel.Context;
|
||||||
global using EntityBase = MiGu.DB.Kernel.Entities.AggregateRoot;
|
global using MiGu.DB.Kernel.Entities;
|
||||||
global using SimpleField = MiGu.DB.Domains.SimpleFields.SimpleField;
|
global using SimpleField = MiGu.DB.Domains.SimpleFields.SimpleField;
|
||||||
global using UserDashboardShortcut = MiGu.DB.Domains.Dashboard.UserDashboardShortcut;
|
global using UserDashboardShortcut = MiGu.DB.Domains.Dashboard.UserDashboardShortcut;
|
||||||
global using Warehouse = MiGu.DB.Domains.Wms.Warehouse;
|
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 WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
|
||||||
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
|
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
|
||||||
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
|
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
|
||||||
|
global using CdmTaskRecord = MiGu.DB.Domains.Fleet.CdmTaskRecord;
|
||||||
|
global using VehicleAlarmRecord = MiGu.DB.Domains.Fleet.VehicleAlarmRecord;
|
||||||
|
|||||||
+19
-17
@@ -4,45 +4,46 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace MiGu.Server.Launcher;
|
namespace MiGu.Server.Launcher;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
|
/// 将 <c>obj/Debug</c> 下最新编译的 Simple3 同步到 <c>bin/Debug</c>(仅当 Simple3 未运行时)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SimpleLiteBuildSync
|
public static class Simple3BuildSync
|
||||||
{
|
{
|
||||||
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
|
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
|
||||||
{
|
{
|
||||||
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
|
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var binDll = Path.Combine(binDir, "SimpleLite.dll");
|
var binDll = Path.Combine(binDir, "Simple3.dll");
|
||||||
var binExe = Path.Combine(binDir, "SimpleLite.exe");
|
var binExe = Path.Combine(binDir, "Simple3.exe");
|
||||||
|
|
||||||
if (!File.Exists(objDll))
|
if (!File.Exists(objDll))
|
||||||
{
|
{
|
||||||
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
|
log?.LogDebug("[Simple3BuildSync] obj DLL 不存在: {Path}", objDll);
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var objTime = File.GetLastWriteTimeUtc(objDll);
|
var objTime = File.GetLastWriteTimeUtc(objDll);
|
||||||
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
|
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
|
||||||
{
|
{
|
||||||
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
|
log?.LogDebug("[Simple3BuildSync] bin 已是最新,无需同步");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(binDir);
|
Directory.CreateDirectory(binDir);
|
||||||
File.Copy(objDll, binDll, true);
|
File.Copy(objDll, binDll, true);
|
||||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
|
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
|
||||||
|
|
||||||
if (File.Exists(objExe))
|
if (File.Exists(objExe))
|
||||||
{
|
{
|
||||||
File.Copy(objExe, binExe, true);
|
File.Copy(objExe, binExe, true);
|
||||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
|
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncRuntimeDeps(objDll, binDir, log);
|
SyncRuntimeDeps(objDll, binDir, log);
|
||||||
@@ -51,7 +52,7 @@ public static class SimpleLiteBuildSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
|
/// 探测 Simple3 是否带「前往站点」路由(区分新旧 DLL)。
|
||||||
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
|
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
|
||||||
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
|
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
|
||||||
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
|
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
|
||||||
@@ -96,7 +97,7 @@ public static class SimpleLiteBuildSync
|
|||||||
|
|
||||||
var dst = Path.Combine(binDir, name);
|
var dst = Path.Combine(binDir, name);
|
||||||
File.Copy(src, dst, true);
|
File.Copy(src, dst, true);
|
||||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
|
log?.LogInformation("[Simple3BuildSync] 已同步依赖 {Name}", name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +107,11 @@ public static class SimpleLiteBuildSync
|
|||||||
binDir = "";
|
binDir = "";
|
||||||
var repo = FindRepoRoot(contentRoot);
|
var repo = FindRepoRoot(contentRoot);
|
||||||
if (repo == null) return false;
|
if (repo == null) return false;
|
||||||
var sl = Path.Combine(repo, "Simple", "SimpleLite");
|
var sl = Path.Combine(repo, "Simple", "Simple3");
|
||||||
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
|
objDll = Path.Combine(sl, "obj", "Debug", "Simple3.dll");
|
||||||
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
|
objExe = Path.Combine(sl, "obj", "Debug", "Simple3.exe");
|
||||||
binDir = Path.Combine(sl, "bin", "Debug");
|
binDir = Path.Combine(sl, "bin", "Debug");
|
||||||
return true;
|
return Directory.Exists(sl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindRepoRoot(string contentRoot)
|
private static string? FindRepoRoot(string contentRoot)
|
||||||
@@ -118,7 +119,8 @@ public static class SimpleLiteBuildSync
|
|||||||
var dir = new DirectoryInfo(contentRoot);
|
var dir = new DirectoryInfo(contentRoot);
|
||||||
while (dir != null)
|
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;
|
return dir.FullName;
|
||||||
dir = dir.Parent;
|
dir = dir.Parent;
|
||||||
}
|
}
|
||||||
+128
-109
@@ -7,21 +7,21 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace MiGu.Server.Launcher;
|
namespace MiGu.Server.Launcher;
|
||||||
|
|
||||||
/// <summary>
|
/// <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> 脱离父进程组/控制台。
|
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
|
||||||
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
|
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 Simple3。
|
||||||
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
/// - 命令行透传:把 LaunchMode 翻成 Simple3 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||||
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
/// - 启动后阻塞等待 Simple3 Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SimpleLiteLauncher : IDisposable
|
public sealed class Simple3Launcher : IDisposable
|
||||||
{
|
{
|
||||||
private readonly SimpleLiteOptions _opts;
|
private readonly Simple3Options _opts;
|
||||||
private readonly ILogger<SimpleLiteLauncher> _log;
|
private readonly ILogger<Simple3Launcher> _log;
|
||||||
private readonly IHostEnvironment _env;
|
private readonly IHostEnvironment _env;
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
private string? _lastLaunchMode;
|
private string? _lastLaunchMode;
|
||||||
private bool _disposed;
|
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;
|
_opts = opts.Value;
|
||||||
_log = log;
|
_log = log;
|
||||||
_env = env;
|
_env = env;
|
||||||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— Simple3 是「独立程序」,MiGu.Server 关掉
|
||||||
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
// 不应该带走 Simple3。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||||
if (_opts.FollowParent)
|
if (_opts.FollowParent)
|
||||||
{
|
{
|
||||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||||||
@@ -53,11 +53,11 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
|
|
||||||
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||||||
|
|
||||||
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
/// <summary>外部已存在的 Simple3(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||||
internal const string ExternalReuseLaunchMode = "external";
|
internal const string ExternalReuseLaunchMode = "external";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
/// 按 launchMode 拉起 Simple3(已运行则跳过)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||||||
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||||||
@@ -67,20 +67,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
|
|
||||||
if (!_opts.Enabled)
|
if (!_opts.Enabled)
|
||||||
{
|
{
|
||||||
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
|
_log.LogInformation("[Simple3] auto-start disabled (appsettings: Simple3.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||||||
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
|
return new LaunchResult(false, "Disabled", "appsettings:Simple3:Enabled=false", DisplayMode: null,
|
||||||
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
|
Warning: "Simple3 自动拉起已被 appsettings:Simple3:Enabled=false 关闭;登录已成功但 Simple3 未启动,/api/sl/* 反代请求会返回 502。");
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (_proc is { HasExited: false })
|
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) &&
|
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
|
||||||
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
||||||
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
? $"Simple3 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||||||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
|
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 Simple3 后重新登录。"
|
||||||
: null;
|
: null;
|
||||||
return new LaunchResult(true, "AlreadyRunning",
|
return new LaunchResult(true, "AlreadyRunning",
|
||||||
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||||||
@@ -88,44 +88,44 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
Warning: warning);
|
Warning: warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 Simple3 可能仍在跑(因为
|
||||||
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||||||
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
// 避免「allowMultiple=false 时新 Simple3 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||||
//
|
//
|
||||||
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
// 注意:探测仅判断「有 Simple3 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 Simple3 再登录。
|
||||||
//
|
//
|
||||||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 Simple3」,避免被某个偶然占
|
||||||
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
||||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
|
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)
|
if (!probeOk)
|
||||||
{
|
{
|
||||||
_log.LogWarning(
|
_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);
|
_opts.ProjectionPort);
|
||||||
return new LaunchResult(false, "PortOccupied",
|
return new LaunchResult(false, "PortOccupied",
|
||||||
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
||||||
DisplayMode: null,
|
DisplayMode: null,
|
||||||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
|
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 Simple3 Projection 服务。" +
|
||||||
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
|
"请关闭占用该端口的进程,或调整 Simple3:ProjectionPort / Simple3 配置后重试。");
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.LogInformation(
|
_log.LogInformation(
|
||||||
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
|
"[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 SimpleLite window and login again.",
|
"Skip launch. If user picked a different LaunchMode this session, please close the existing Simple3 window and login again.",
|
||||||
_opts.ProjectionPort);
|
_opts.ProjectionPort);
|
||||||
|
|
||||||
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||||||
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||||||
_lastLaunchMode = ExternalReuseLaunchMode;
|
_lastLaunchMode = ExternalReuseLaunchMode;
|
||||||
|
|
||||||
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
var reuseWarning = $"检测到 Simple3 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
|
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 Simple3 窗口后重新登录。";
|
||||||
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
|
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",
|
return new LaunchResult(true, "ReusingExisting",
|
||||||
@@ -134,18 +134,18 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
Warning: reuseWarning);
|
Warning: reuseWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||||
|
|
||||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||||
if (resolved == null)
|
if (resolved == null)
|
||||||
{
|
{
|
||||||
_log.LogWarning(
|
_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);
|
_env.ContentRootPath);
|
||||||
return new LaunchResult(false, "ExecutableNotFound",
|
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,
|
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)
|
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||||||
@@ -156,12 +156,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
|
var proc = StartSimple3Process(resolved, arguments, workdir);
|
||||||
if (proc == null)
|
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,
|
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||||||
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
|
Warning: $"启动 Simple3 进程失败;exe={resolved}");
|
||||||
}
|
}
|
||||||
|
|
||||||
WireProcessExitHandler(proc);
|
WireProcessExitHandler(proc);
|
||||||
@@ -171,20 +171,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
|
|
||||||
if (_opts.FollowParent) AttachToJobObject(proc);
|
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);
|
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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,
|
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 —— 进程拉起后立即返回,不再同步阻塞最长
|
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
|
||||||
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
|
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
|
||||||
// GET /api/health/simplelite 获知就绪状态。
|
// GET /api/health/simple3 获知就绪状态。
|
||||||
if (!waitForReady)
|
if (!waitForReady)
|
||||||
{
|
{
|
||||||
return new LaunchResult(true, "Starting",
|
return new LaunchResult(true, "Starting",
|
||||||
@@ -200,12 +200,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
DisplayMode: displayMode,
|
DisplayMode: displayMode,
|
||||||
Warning: ready
|
Warning: ready
|
||||||
? null
|
? null
|
||||||
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
: $"Simple3 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 手动关闭 SimpleLite 子进程。
|
/// 手动关闭 Simple3 子进程。
|
||||||
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
/// 会话 N+2 起默认不调(FollowParent=false)—— Simple3 是独立程序,MiGu.Server 关闭不带走它。
|
||||||
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -227,7 +227,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
|
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
|
||||||
if (proc is { HasExited: false })
|
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 */ }
|
try { proc?.Dispose(); } catch { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
@@ -236,7 +236,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
if (proc is { HasExited: false })
|
if (proc is { HasExited: false })
|
||||||
{
|
{
|
||||||
try { proc.Kill(entireProcessTree: true); }
|
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 */ }
|
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,21 +248,21 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
|
|
||||||
private static string NormalizeDisplayMode(string launchMode)
|
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
|
return launchMode?.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"webonly" or "web" or "web-only" => "web",
|
"desktopandweb" or "web+local" or "weblocal" or "local" => "web+local",
|
||||||
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
// Simple3 无本地桌面端:默认 / 未知一律 web
|
||||||
_ => "web+local",
|
_ => "web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildArguments(string displayMode, string extra)
|
private string BuildArguments(string displayMode, string extra)
|
||||||
{
|
{
|
||||||
var args = $"--display-mode={displayMode}";
|
var args = $"--display-mode={displayMode}";
|
||||||
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
|
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 Simple3 WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||||||
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
|
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:Simple3:EmbeddedCanvas=false 关闭。
|
||||||
if (_opts.EmbeddedCanvas) args += " --migu";
|
if (_opts.EmbeddedCanvas) args += " --migu";
|
||||||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||||||
var sceneArg = ReadActiveScenesArg();
|
var sceneArg = ReadActiveScenesArg();
|
||||||
@@ -271,7 +271,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>,
|
/// <summary>解析 Simple3 工作目录(与拉起时一致):优先显式 <see cref="Simple3Options.WorkingDirectory"/>,
|
||||||
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
|
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
|
||||||
public string? ResolveWorkingDirectory()
|
public string? ResolveWorkingDirectory()
|
||||||
{
|
{
|
||||||
@@ -281,7 +281,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
|
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
/// <summary>Simple3 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
||||||
public string? ResolvePluginsDir()
|
public string? ResolvePluginsDir()
|
||||||
{
|
{
|
||||||
var wd = ResolveWorkingDirectory();
|
var wd = ResolveWorkingDirectory();
|
||||||
@@ -289,16 +289,16 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
|
/// 把「配置向导选定的导航场景」写入 Simple3 的 <c>plugins/active-scenes.json</c> ——
|
||||||
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
|
/// 这是「平台 → 内核」选择性加载的主通道。下次 Simple3 启动即据此只加载选定导航场景插件;
|
||||||
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite 端
|
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 Simple3 端
|
||||||
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
|
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
|
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
|
||||||
{
|
{
|
||||||
var pluginsDir = ResolvePluginsDir();
|
var pluginsDir = ResolvePluginsDir();
|
||||||
if (pluginsDir == null)
|
if (pluginsDir == null)
|
||||||
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
|
return new ActiveScenesWriteResult(false, null, "未找到 Simple3 工作目录/可执行文件,无法定位 plugins 目录");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(pluginsDir);
|
Directory.CreateDirectory(pluginsDir);
|
||||||
@@ -313,13 +313,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
updatedAt = DateTime.UtcNow
|
updatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
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));
|
path, string.Join(",", payload.activeScenes));
|
||||||
return new ActiveScenesWriteResult(true, path, null);
|
return new ActiveScenesWriteResult(true, path, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
|
_log.LogWarning(ex, "[Simple3] 写 active-scenes.json 失败");
|
||||||
return new ActiveScenesWriteResult(false, null, ex.Message);
|
return new ActiveScenesWriteResult(false, null, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,11 +349,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||||||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
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()
|
public int StopAll()
|
||||||
{
|
{
|
||||||
var killed = 0;
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -361,12 +362,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
proc.Kill(entireProcessTree: true);
|
proc.Kill(entireProcessTree: true);
|
||||||
killed++;
|
killed++;
|
||||||
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
|
_log.LogInformation("[Simple3] stop: killed {Name} pid={Pid}", name, proc.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -385,7 +386,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
return killed;
|
return killed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
|
/// <summary>关闭全部 Simple3 后按 launchMode 重新拉起(不同步 DLL)。</summary>
|
||||||
public LaunchResult Restart(string launchMode = "webonly")
|
public LaunchResult Restart(string launchMode = "webonly")
|
||||||
{
|
{
|
||||||
StopAll();
|
StopAll();
|
||||||
@@ -393,19 +394,19 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
/// 关闭所有 Simple3 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||||
{
|
{
|
||||||
StopAll();
|
StopAll();
|
||||||
|
|
||||||
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
var synced = Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||||
var result = MaybeStart(launchMode);
|
var result = MaybeStart(launchMode);
|
||||||
if (!synced && result.Warning == null)
|
if (!synced && result.Warning == null)
|
||||||
{
|
{
|
||||||
return result with
|
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 DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
|
||||||
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simple3 与启动日志)。</summary>
|
||||||
public SimpleLiteDiagnostics GetDiagnostics()
|
public Simple3Diagnostics GetDiagnostics()
|
||||||
{
|
{
|
||||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||||
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
|
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 Simple3 发探测请求。
|
||||||
bool? gotoSite = null;
|
bool? gotoSite = null;
|
||||||
if (projectionUp)
|
if (projectionUp)
|
||||||
{
|
{
|
||||||
@@ -431,7 +432,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
gotoSite = Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||||||
if (gotoSite is not null)
|
if (gotoSite is not null)
|
||||||
{
|
{
|
||||||
_gotoSiteProbeCache = gotoSite;
|
_gotoSiteProbeCache = gotoSite;
|
||||||
@@ -440,9 +441,9 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var deployHint = gotoSite == false
|
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;
|
: null;
|
||||||
return new SimpleLiteDiagnostics(
|
return new Simple3Diagnostics(
|
||||||
Enabled: _opts.Enabled,
|
Enabled: _opts.Enabled,
|
||||||
FollowParent: _opts.FollowParent,
|
FollowParent: _opts.FollowParent,
|
||||||
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||||||
@@ -456,26 +457,26 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
ProjectionPortReachable: projectionUp,
|
ProjectionPortReachable: projectionUp,
|
||||||
GotoSiteApiAvailable: gotoSite,
|
GotoSiteApiAvailable: gotoSite,
|
||||||
DeployHint: deployHint,
|
DeployHint: deployHint,
|
||||||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 Simple3 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
||||||
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
|
" FollowParent=false 时关闭 MiGu.Server 不会结束 Simple3。");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite。
|
/// 拉起 Simple3。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 Simple3。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
|
private Process? StartSimple3Process(string resolved, string arguments, string workdir)
|
||||||
{
|
{
|
||||||
if (_opts.FollowParent)
|
if (_opts.FollowParent)
|
||||||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
|
return StartSimple3DetachedWindows(resolved, arguments, workdir)
|
||||||
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
?? 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
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -493,14 +494,15 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
|
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
|
||||||
/// </summary>
|
/// </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 exeName = Path.GetFileNameWithoutExtension(resolved);
|
||||||
var beforeIds = new HashSet<int>(
|
var beforeIds = new HashSet<int>(
|
||||||
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
|
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
|
||||||
.Where(id => id > 0));
|
.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
|
var shimPsi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "cmd.exe",
|
FileName = "cmd.exe",
|
||||||
@@ -521,7 +523,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
if (p.HasExited) continue;
|
if (p.HasExited) continue;
|
||||||
if (beforeIds.Contains(p.Id)) 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);
|
return AttachToExistingProcess(p.Id);
|
||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
@@ -529,7 +531,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
Thread.Sleep(200);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +545,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,7 +556,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
int exit;
|
int exit;
|
||||||
try { exit = proc.ExitCode; } catch { exit = -1; }
|
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)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(_proc, proc))
|
if (ReferenceEquals(_proc, proc))
|
||||||
@@ -577,16 +579,26 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
var cwd = _env.ContentRootPath;
|
var cwd = _env.ContentRootPath;
|
||||||
var staticCandidates = new[]
|
var staticCandidates = new[]
|
||||||
{
|
{
|
||||||
|
Path.Combine(cwd, "Simple3.exe"),
|
||||||
Path.Combine(cwd, "SimpleLite.exe"),
|
Path.Combine(cwd, "SimpleLite.exe"),
|
||||||
|
Path.Combine(cwd, "Simple3", "Simple3.exe"),
|
||||||
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
||||||
|
Path.Combine(cwd, "..", "Simple3.exe"),
|
||||||
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
||||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
// 发布布局(本机常见):D:\MDCS\Release\Simple\Simple3
|
||||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
@"D:\MDCS\Release\Simple\Simple3\Simple3.exe",
|
||||||
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
|
// 源码并列:Migu2.0/MiGu.Server → ../../Simple/Simple3(OutputPath 常直接写到 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", "Debug", "SimpleLite.exe"),
|
||||||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "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", "Debug", "SimpleLite.exe"),
|
||||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "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)
|
foreach (var c in staticCandidates)
|
||||||
{
|
{
|
||||||
@@ -599,6 +611,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
foreach (var sub in new[]
|
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/Debug/SimpleLite.exe",
|
||||||
"SimpleLite/bin/Release/SimpleLite.exe",
|
"SimpleLite/bin/Release/SimpleLite.exe",
|
||||||
"Simple/SimpleLite/bin/Debug/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)))
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Thread.Sleep(interval);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,10 +713,10 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
/// 在 TCP 通的基础上读取 Simple3 Projection 的强类型 JSON 端点,判别「真的是 Simple3」。
|
||||||
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
private static bool ProbeSimple3Http(string host, int port, TimeSpan timeout)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -718,20 +737,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 拉起 SimpleLite 的结果摘要。
|
/// 拉起 Simple3 的结果摘要。
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
||||||
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
||||||
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
||||||
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
||||||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
|
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 Simple3,对应模式未知。</item>
|
||||||
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
||||||
string? DisplayMode = null, string? Warning = null);
|
string? DisplayMode = null, string? Warning = null);
|
||||||
|
|
||||||
public sealed record SimpleLiteDiagnostics(
|
public sealed record Simple3Diagnostics(
|
||||||
bool Enabled,
|
bool Enabled,
|
||||||
bool FollowParent,
|
bool FollowParent,
|
||||||
string ConfiguredExecutablePath,
|
string ConfiguredExecutablePath,
|
||||||
@@ -807,7 +826,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,7 +837,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
_job = CreateJobObject(IntPtr.Zero, null);
|
_job = CreateJobObject(IntPtr.Zero, null);
|
||||||
if (_job == IntPtr.Zero)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,17 +850,17 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
{
|
{
|
||||||
Marshal.StructureToPtr(info, ptr, false);
|
Marshal.StructureToPtr(info, ptr, false);
|
||||||
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
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); }
|
finally { Marshal.FreeHGlobal(ptr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AssignProcessToJobObject(_job, child.Handle))
|
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)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-23
@@ -1,31 +1,30 @@
|
|||||||
namespace MiGu.Server.Launcher;
|
namespace MiGu.Server.Launcher;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
|
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 Simple3 子进程配置。
|
||||||
///
|
///
|
||||||
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
|
/// 会话 N+1(启动反转):原架构是 Simple3 启动 → 拉 MiGu.Server;现在反过来:
|
||||||
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe,
|
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 Simple3.exe,
|
||||||
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
|
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 Simple3 的 Configuration。
|
||||||
///
|
///
|
||||||
/// 绑定 <c>appsettings.json:SimpleLite</c>。
|
/// 绑定 <c>appsettings.json:Simple3</c>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SimpleLiteOptions
|
public sealed class Simple3Options
|
||||||
{
|
{
|
||||||
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
|
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
/// Simple3 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
||||||
/// 1) <c>./SimpleLite.exe</c>(合并发布布局:SimpleLite 与 MiGu.Server 同目录)
|
/// 1) <c>./Simple3.exe</c>(合并发布布局;仍兼容探测旧名 <c>./SimpleLite.exe</c>)
|
||||||
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
|
/// 2) <c>./Simple3/Simple3.exe</c>
|
||||||
/// 3) <c>../SimpleLite.exe</c>(MiGu.Server 部署到子目录的常见布局)
|
/// 3) 开发态:兄弟仓库 <c>../Simple/Simple3/</c> 产出目录或 <c>bin/Debug|Release</c>
|
||||||
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出)
|
/// 4) 发布目录常见布局:<c>D:\MDCS\Release\Simple\Simple3\Simple3.exe</c>(仅当该绝对路径存在)
|
||||||
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
|
/// 5) 沿父目录上行寻找 Simple3 产出
|
||||||
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ExecutablePath { get; set; } = "";
|
public string ExecutablePath { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||||
public string WorkingDirectory { get; set; } = "";
|
public string WorkingDirectory { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
||||||
@@ -33,7 +32,7 @@ public sealed class SimpleLiteOptions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
||||||
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
|
/// 0 = 不等待(登录立即返回,前端可能还连不上 Simple3 WebApi);
|
||||||
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReadinessTimeoutMs { get; set; } = 8000;
|
public int ReadinessTimeoutMs { get; set; } = 8000;
|
||||||
@@ -41,17 +40,17 @@ public sealed class SimpleLiteOptions
|
|||||||
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
||||||
public int ReadinessPollIntervalMs { get; set; } = 250;
|
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;
|
public int ProjectionPort { get; set; } = 8222;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
/// 是否把 Simple3 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||||
///
|
///
|
||||||
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite;
|
/// 设计原则:Simple3 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 Simple3;
|
||||||
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false:
|
/// MiGu.Server 关闭不应该带走 Simple3,反之亦然。所以 FollowParent 默认 false:
|
||||||
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
||||||
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
||||||
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
/// - Simple3 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||||
///
|
///
|
||||||
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
||||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||||
@@ -59,11 +58,11 @@ public sealed class SimpleLiteOptions
|
|||||||
public bool FollowParent { get; set; } = false;
|
public bool FollowParent { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <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 时序失败时也安全回退到纯画布。
|
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
|
||||||
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
/// false:平台拉起的 Simple3 web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
||||||
///
|
///
|
||||||
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
<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="OpenApi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simple3-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="..\..\Simple\Simple3\Docs\openapi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\Simple3\Docs\openapi\simple3-projection.json')" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+8
-5
@@ -5,10 +5,10 @@ using Swashbuckle.AspNetCore.SwaggerGen;
|
|||||||
namespace MiGu.Server.OpenApi;
|
namespace MiGu.Server.OpenApi;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
/// 将 Simple3 EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
||||||
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
|
/// 源文件:Simple/Simple3/Docs/openapi/simple3-projection.json
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
public sealed class Simple3OpenApiDocumentFilter : IDocumentFilter
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -175,9 +175,12 @@ public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
|||||||
|
|
||||||
foreach (var root in roots)
|
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, "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"));
|
||||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,35 @@ public sealed class OtaJobRunner
|
|||||||
_wd = wd;
|
_wd = wd;
|
||||||
_vehicles = vehicles;
|
_vehicles = vehicles;
|
||||||
_log = log;
|
_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)
|
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ public sealed class OtaSettings
|
|||||||
public int MaxCar { get; set; } = 2;
|
public int MaxCar { get; set; } = 2;
|
||||||
public bool LatencyEnabled { get; set; }
|
public bool LatencyEnabled { get; set; }
|
||||||
public int RttThresholdMs { get; set; } = 200;
|
public int RttThresholdMs { get; set; } = 200;
|
||||||
/// <summary>skip | confirm</summary>
|
/// <summary>skip | allow(历史值 confirm 视为 allow)</summary>
|
||||||
public string OverThreshold { get; set; } = "skip";
|
public string OverThreshold { get; set; } = "skip";
|
||||||
|
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
|
||||||
public int BackupPeriodMinutes { get; set; } = 60;
|
public int BackupPeriodMinutes { get; set; } = 60;
|
||||||
|
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
|
||||||
public bool BackupExe { get; set; }
|
public bool BackupExe { get; set; }
|
||||||
public string? NewVersionName { get; set; }
|
public string? NewVersionName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class OtaStore
|
|||||||
private string? _lastPullId;
|
private string? _lastPullId;
|
||||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private long _jobSeq;
|
private long _jobSeq;
|
||||||
|
|
||||||
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
|
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
|
||||||
@@ -144,29 +146,44 @@ public sealed class OtaStore
|
|||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
var ip = NormalizeIp(sourceIp);
|
var ip = NormalizeIp(sourceIp);
|
||||||
|
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
|
||||||
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
|
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
|
||||||
{
|
{
|
||||||
id = byIp;
|
id = byIp;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ip == null && _lastPullId != null)
|
|
||||||
{
|
|
||||||
id = _lastPullId;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
id = null;
|
id = null;
|
||||||
return false;
|
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)
|
public void ClearActivePull(string? sourceIp = null)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
var ip = NormalizeIp(sourceIp);
|
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;
|
if (_pullByIp.IsEmpty) _lastPullId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ namespace MiGu.Server.Ota;
|
|||||||
public sealed class OtaVehicleSource
|
public sealed class OtaVehicleSource
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly SimpleLiteOptions _sl;
|
private readonly Simple3Options _sl;
|
||||||
private readonly InternalTokenStoreAccessor _token;
|
private readonly InternalTokenStoreAccessor _token;
|
||||||
private readonly ILogger<OtaVehicleSource> _log;
|
private readonly ILogger<OtaVehicleSource> _log;
|
||||||
|
|
||||||
public OtaVehicleSource(
|
public OtaVehicleSource(
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
IOptions<SimpleLiteOptions> sl,
|
IOptions<Simple3Options> sl,
|
||||||
InternalTokenStoreAccessor token,
|
InternalTokenStoreAccessor token,
|
||||||
ILogger<OtaVehicleSource> log)
|
ILogger<OtaVehicleSource> log)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,8 +21,4 @@ public static class PlatformPersistence
|
|||||||
services.AddScoped<Dashboard.DashboardShortcutService>();
|
services.AddScoped<Dashboard.DashboardShortcutService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>启动建库/迁移入口(内部即 MigrateMiGuDbAsync:Migrate + 基线 + DataMigrator)。</summary>
|
|
||||||
public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
|
|
||||||
=> services.MigrateMiGuDbAsync();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-23
@@ -8,6 +8,8 @@ using MiGu.Server.Configs;
|
|||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
using MiGu.Server.OpenApi;
|
using MiGu.Server.OpenApi;
|
||||||
using MiGu.Server.Ota;
|
using MiGu.Server.Ota;
|
||||||
|
using MiGu.Server.Signal;
|
||||||
|
using MiGu.DB.Kernel.Hosting;
|
||||||
using MiGu.Server.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
|
||||||
@@ -122,11 +124,11 @@ builder.Services.AddSwaggerGen(c =>
|
|||||||
{
|
{
|
||||||
c.SwaggerDoc("v1", new()
|
c.SwaggerDoc("v1", new()
|
||||||
{
|
{
|
||||||
Title = "MiGu.Server + SimpleLite",
|
Title = "MiGu.Server + Simple3",
|
||||||
Version = "v1",
|
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 输入框,便于手工测带鉴权的端点。
|
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
{
|
{
|
||||||
@@ -160,7 +162,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
|||||||
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||||||
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||||||
// PLATFORM__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 自动追加)。
|
// 共享密钥(YARP transform 自动追加)。
|
||||||
builder.Services.AddSingleton<RbacStore>();
|
builder.Services.AddSingleton<RbacStore>();
|
||||||
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||||||
@@ -215,7 +217,7 @@ builder.Services.AddAuthorization(opts =>
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。
|
// YARP + transform:把 Platform 内部 token 透传给 Simple3 8222(AR-1/AR-2 配套)。
|
||||||
builder.Services.AddReverseProxy()
|
builder.Services.AddReverseProxy()
|
||||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||||
.AddTransforms(tctx =>
|
.AddTransforms(tctx =>
|
||||||
@@ -253,16 +255,19 @@ builder.Services.AddReverseProxy()
|
|||||||
|
|
||||||
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||||||
builder.Services.AddSingleton<ConfigStore>();
|
builder.Services.AddSingleton<ConfigStore>();
|
||||||
|
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
|
||||||
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
|
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
|
||||||
builder.Services.AddSingleton<OpsAuditStore>();
|
builder.Services.AddSingleton<OpsAuditStore>();
|
||||||
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
|
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
builder.Services.AddPlatformPersistence(builder.Configuration);
|
builder.Services.AddPlatformPersistence(builder.Configuration);
|
||||||
|
|
||||||
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
|
||||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
|
||||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
builder.Services.AddSingleton<Simple3Launcher>();
|
||||||
|
builder.Services.AddSingleton<SignalDataStore>();
|
||||||
|
|
||||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||||
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
||||||
@@ -279,42 +284,47 @@ builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
|
|||||||
builder.Services.AddHttpClient(nameof(WatchDogClient));
|
builder.Services.AddHttpClient(nameof(WatchDogClient));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
await app.Services.EnsurePlatformDatabaseAsync();
|
await app.Services.MigrateMiGuDbAsync();
|
||||||
|
|
||||||
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||||||
_ = app.Services.GetRequiredService<JwtIssuer>();
|
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||||||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||||
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
|
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
|
||||||
_ = app.Services.GetRequiredService<RbacStore>();
|
_ = app.Services.GetRequiredService<RbacStore>();
|
||||||
// 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
// 主动实例化 Simple3Launcher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
||||||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
|
||||||
{
|
{
|
||||||
var sl = simpleLiteLauncher.GetDiagnostics();
|
var sl = simple3Launcher.GetDiagnostics();
|
||||||
app.Logger.LogInformation(
|
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.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||||||
sl.ProjectionPort, sl.ProjectionPortReachable);
|
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
|
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
|
||||||
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
|
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
|
||||||
{
|
{
|
||||||
app.Lifetime.ApplicationStopping.Register(() =>
|
app.Lifetime.ApplicationStopping.Register(() =>
|
||||||
{
|
{
|
||||||
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
|
||||||
catch { /* shutdown best-effort */ }
|
catch { /* shutdown best-effort */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
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();
|
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
|
||||||
app.UseSwaggerUI();
|
ctx.Connection.RemoteIpAddress?.ToString();
|
||||||
}
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
|
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
|
||||||
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
|
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
|
||||||
|
|||||||
+36
-36
@@ -5,8 +5,8 @@
|
|||||||
>
|
>
|
||||||
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
||||||
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
||||||
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`,
|
> 后端 `AuthController.Login` 调用 `Simple3Launcher.MaybeStart(launchMode)` 按所选模式拉起 `Simple3.exe`,
|
||||||
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
|
> 通过 `--display-mode=web|web+local` 透传给 Simple3 的 `Configuration.displayMode`。
|
||||||
>
|
>
|
||||||
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
||||||
|
|
||||||
@@ -19,83 +19,83 @@
|
|||||||
cd MiGu.Server
|
cd MiGu.Server
|
||||||
dotnet build MiGu.Server.csproj -c Debug
|
dotnet build MiGu.Server.csproj -c Debug
|
||||||
|
|
||||||
# 2) 启动(登录后按所选模式拉起 SimpleLite)
|
# 2) 启动(登录后按所选模式拉起 Simple3)
|
||||||
dotnet run
|
dotnet run
|
||||||
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
||||||
|
|
||||||
## 配置 SimpleLite 路径(必读)
|
## 配置 Simple3 路径(必读)
|
||||||
|
|
||||||
> **配置文件位置(无 Web 界面):**
|
> **配置文件位置(无 Web 界面):**
|
||||||
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
|
> **`MiGu.Server/appsettings.json`** → 搜索 **`"Simple3"`** 节点。
|
||||||
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
|
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `Simple3.exe`。
|
||||||
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
|
> 启动后可在浏览器打开 **`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 仓库并列)
|
### 本机开发(与 Simple 仓库并列)
|
||||||
|
|
||||||
仓库默认已在 `appsettings.Development.json` 中写好:
|
仓库默认已在 `appsettings.Development.json` 中写好:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"SimpleLite": {
|
"Simple3": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
|
||||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug",
|
||||||
"ProjectionPort": 8222,
|
"ProjectionPort": 8222,
|
||||||
"ReadinessTimeoutMs": 8000,
|
"ReadinessTimeoutMs": 8000,
|
||||||
"FollowParent": false
|
"FollowParent": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
请先编译 SimpleLite(在 Simple 仓库):
|
请先编译 Simple3(在 Simple 仓库):
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
cd ..\Simple
|
cd ..\Simple
|
||||||
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
dotnet build Simple3\Simple3.csproj -c Debug
|
||||||
```
|
```
|
||||||
|
|
||||||
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
|
||||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
|
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置项说明
|
### 配置项说明
|
||||||
|
|
||||||
| 键 | 含义 |
|
| 键 | 含义 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI) |
|
| `Enabled` | `false` 时永不拉起 Simple3(只调试平台 UI) |
|
||||||
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs`) |
|
| `ExecutablePath` | `Simple3.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/Simple3Options.cs`) |
|
||||||
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
||||||
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
||||||
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
|
| `ReadinessTimeoutMs` | 登录后等待 Simple3 WebAPI 就绪的最长时间(毫秒) |
|
||||||
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
|
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 Simple3;默认 `false`(两进程独立) |
|
||||||
|
|
||||||
### 环境变量覆盖
|
### 环境变量覆盖
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
|
$env:Simple3__ExecutablePath = "D:\apps\Simple3.exe"
|
||||||
$env:SimpleLite__WorkingDirectory = "D:\apps"
|
$env:Simple3__WorkingDirectory = "D:\apps"
|
||||||
$env:SimpleLite__Enabled = "true"
|
$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`:
|
浏览器访问 `http://localhost:8080/login`:
|
||||||
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
||||||
2. 选 scope(管理员 / 运营);
|
2. 选 scope(管理员 / 运营);
|
||||||
3. 选 **SimpleLite 启动模式**:
|
3. 选 **Simple3 启动模式**:
|
||||||
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
||||||
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
|
- **仅 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()
|
builder.Build()
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
EnsurePlatformDatabaseAsync() # → MiGu.DB.MigrateMiGuDbAsync()
|
MigrateMiGuDbAsync() # Schema 初始化 + 可选 IDataMigrator
|
||||||
│ # Schema 初始化 + 可选 IDataMigrator
|
│
|
||||||
▼
|
▼
|
||||||
… 其余中间件 …
|
… 其余中间件 …
|
||||||
UseMiddleware<HttpActorContextMiddleware> # 请求级写入 IActorContext(审计戳)
|
UseMiddleware<HttpActorContextMiddleware> # 请求级写入 IActorContext(审计戳)
|
||||||
@@ -198,7 +198,7 @@ SchemaMode = Migrate
|
|||||||
>
|
>
|
||||||
> 详见代码审核 ISSUE-03(`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`)。
|
> 详见代码审核 ISSUE-03(`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`)。
|
||||||
>
|
>
|
||||||
> **SimpleLite 8222 内部 API(RV-04)**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
|
> **Simple3 8222 内部 API(RV-04)**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
|
||||||
|
|
||||||
## 能力清单(本轮)
|
## 能力清单(本轮)
|
||||||
|
|
||||||
@@ -208,11 +208,11 @@ SchemaMode = Migrate
|
|||||||
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
|
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
|
||||||
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
|
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
|
||||||
- YARP 反向代理:
|
- 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 限制);
|
- `/vr/{**catch-all}` → `http://127.0.0.1:8223/`(webVRender;可用于同源 iframe 解决 X-Frame-Options 限制);
|
||||||
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
|
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
|
||||||
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `SimpleLiteLauncher` 据此拉起子进程;
|
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `Simple3Launcher` 据此拉起子进程;
|
||||||
- SimpleLite 子进程编排:`Launcher/SimpleLiteLauncher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:SimpleLite`;
|
- Simple3 子进程编排:`Launcher/Simple3Launcher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:Simple3`;
|
||||||
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
||||||
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
||||||
- 健康检查:`/api/health`;
|
- 健康检查:`/api/health`;
|
||||||
@@ -255,7 +255,7 @@ MiGu.Server/
|
|||||||
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 独立启动(仅当你不通过 SimpleLite 自启时)
|
## 独立启动(仅当你不通过 Simple3 自启时)
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
# 1) 还原 + 编译 + 运行
|
# 1) 还原 + 编译 + 运行
|
||||||
@@ -278,7 +278,7 @@ dotnet run
|
|||||||
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...` 或 `WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
|
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...` 或 `WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
|
||||||
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`。
|
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`。
|
||||||
> 仓库根 `build-platform-frontend.bat` 仍可用于「打部署包」场景(把 dist robocopy /MIR 同步到 wwwroot)。
|
> 仓库根 `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——两种启动方式共用同一份前端产物。
|
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
|
||||||
|
|
||||||
## 与前端联调
|
## 与前端联调
|
||||||
@@ -292,7 +292,7 @@ dotnet run
|
|||||||
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
||||||
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
||||||
|
|
||||||
## YARP 与 SimpleLite
|
## YARP 与 Simple3
|
||||||
|
|
||||||
YARP 路由配置见 `appsettings.json`:
|
YARP 路由配置见 `appsettings.json`:
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ YARP 路由配置见 `appsettings.json`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
||||||
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`,SimpleLite 未启时会得到 502/连接被拒。
|
- 其他 `/api/sl/*` 由 YARP 透传到 Simple3 `:8222`,Simple3 未启时会得到 502/连接被拒。
|
||||||
|
|
||||||
## iframe 嵌入 webVRender 的两种方式
|
## iframe 嵌入 webVRender 的两种方式
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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)
|
public async Task<List<SimpleField>> ListAsync(string? fieldType = null, string? carType = null, string? q = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed record ContainerMaterialSnapshot(
|
|||||||
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
|
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
|
||||||
|
|
||||||
public sealed record MasterDataRequest(
|
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);
|
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
|
||||||
|
|
||||||
public sealed record AreaRequest(
|
public sealed record AreaRequest(
|
||||||
@@ -57,8 +57,3 @@ public sealed record ContainerLocationRequest(
|
|||||||
public sealed record BindMaterialRequest(
|
public sealed record BindMaterialRequest(
|
||||||
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
|
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
|
||||||
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
|
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);
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ namespace MiGu.Server.Wms;
|
|||||||
|
|
||||||
public sealed class WmsReferenceValidator
|
public sealed class WmsReferenceValidator
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
|
|
||||||
public WmsReferenceValidator(PlatformDbContext db)
|
public WmsReferenceValidator(MiGuDbContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ namespace MiGu.Server.Wms;
|
|||||||
|
|
||||||
public sealed class WmsService
|
public sealed class WmsService
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly WmsReferenceValidator _refs;
|
private readonly WmsReferenceValidator _refs;
|
||||||
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
public WmsService(
|
public WmsService(
|
||||||
PlatformDbContext db,
|
MiGuDbContext db,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
WmsReferenceValidator refs)
|
WmsReferenceValidator refs)
|
||||||
@@ -158,7 +158,7 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.Warehouses.Add(entity);
|
_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.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
entity.Type = req.Type.TrimOr("Default");
|
entity.Type = req.Type.TrimOr("Default");
|
||||||
@@ -185,7 +185,7 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.WarehouseAreas.Add(entity);
|
_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.WarehouseId = warehouseId;
|
||||||
entity.Code = req.Code.Trim();
|
entity.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
@@ -214,7 +214,7 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.Storages.Add(entity);
|
_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.AreaId = req.AreaId;
|
||||||
entity.Code = req.Code.Trim();
|
entity.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
@@ -227,7 +227,7 @@ public sealed class WmsService
|
|||||||
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
|
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
|
||||||
entity.Barcode = req.Barcode.TrimOr("");
|
entity.Barcode = req.Barcode.TrimOr("");
|
||||||
entity.Capacity = 1;
|
entity.Capacity = 1;
|
||||||
var status = StorageStatuses.Normalize(req.Status);
|
var status = StorageStatuses.ParseOr(req.Status);
|
||||||
if (status == StorageStatuses.Disabled || req.Enabled == false)
|
if (status == StorageStatuses.Disabled || req.Enabled == false)
|
||||||
entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
|
entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
|
||||||
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
|
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
|
||||||
@@ -326,7 +326,7 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.Containers.Add(entity);
|
_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.AreaId = req.AreaId;
|
||||||
entity.Code = req.Code.Trim();
|
entity.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
@@ -353,7 +353,7 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.MaterialTypes.Add(entity);
|
_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.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
entity.Spec = req.Spec.TrimOr("");
|
entity.Spec = req.Spec.TrimOr("");
|
||||||
@@ -378,9 +378,9 @@ public sealed class WmsService
|
|||||||
StampCreate(entity, actor);
|
StampCreate(entity, actor);
|
||||||
_db.Materials.Add(entity);
|
_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))
|
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.Code = req.Code.Trim();
|
||||||
entity.Name = req.Name.Trim();
|
entity.Name = req.Name.Trim();
|
||||||
entity.TypeCode = req.TypeCode.TrimOr("");
|
entity.TypeCode = req.TypeCode.TrimOr("");
|
||||||
@@ -425,9 +425,9 @@ public sealed class WmsService
|
|||||||
await _uow.SaveChangesAsync();
|
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 (typeof(T) == typeof(WarehouseArea))
|
||||||
{
|
{
|
||||||
if (await _db.Storages.AnyAsync(x => x.AreaId == id))
|
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))
|
if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
|
||||||
throw new InvalidOperationException("物料仍在绑定中,不能删除");
|
throw new InvalidOperationException("物料仍在绑定中,不能删除");
|
||||||
}
|
}
|
||||||
entity.IsDeleted = true;
|
|
||||||
entity.DeletedAt = DateTimeOffset.UtcNow;
|
await Repo<T>().SoftDeleteAsync(id, version);
|
||||||
entity.DeletedBy = actor;
|
|
||||||
entity.UpdatedBy = actor;
|
|
||||||
await _uow.SaveChangesAsync();
|
await _uow.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,8 +495,7 @@ public sealed class WmsService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
EnsureVersion(current, req.Version);
|
await Repo<ContainerLocation>().GetEditableAsync(current.Id, req.Version);
|
||||||
EnsureUnlocked(current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current.LocationType = locationType;
|
current.LocationType = locationType;
|
||||||
@@ -540,7 +537,7 @@ public sealed class WmsService
|
|||||||
{
|
{
|
||||||
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId)
|
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId)
|
||||||
?? throw new InvalidOperationException("容器当前位置不存在");
|
?? throw new InvalidOperationException("容器当前位置不存在");
|
||||||
EnsureUnlocked(current);
|
await Repo<ContainerLocation>().GetEditableAsync(current.Id, null);
|
||||||
Guid? fromStorageId = null;
|
Guid? fromStorageId = null;
|
||||||
if (current.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(current.LocationId, out var fs))
|
if (current.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(current.LocationId, out var fs))
|
||||||
fromStorageId = fs;
|
fromStorageId = fs;
|
||||||
@@ -614,15 +611,9 @@ public sealed class WmsService
|
|||||||
return current;
|
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)
|
public async Task UnbindMaterial(Guid bindingId, string actor, string reason = "", bool archive = false)
|
||||||
{
|
{
|
||||||
var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == bindingId)
|
var current = await Repo<ContainerMaterial>().GetEditableAsync(bindingId, null);
|
||||||
?? throw new InvalidOperationException("绑定不存在");
|
|
||||||
EnsureUnlocked(current);
|
|
||||||
var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == current.MaterialId);
|
var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == current.MaterialId);
|
||||||
var before = Snapshot(current);
|
var before = Snapshot(current);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
@@ -718,46 +709,6 @@ public sealed class WmsService
|
|||||||
return wh;
|
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)
|
public async Task<List<ContainerLocationHistory>> LocationHistory(Guid? containerId = null)
|
||||||
{
|
{
|
||||||
var rows = await (containerId.HasValue
|
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)
|
private Task<T> FindEditable<T>(Guid id, long? version)
|
||||||
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
|
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
|
||||||
=> _services.GetRequiredService<IEditableRepository<T>>().GetEditableAsync(id, version);
|
=> Repo<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||||
{
|
{
|
||||||
@@ -882,19 +830,13 @@ public sealed class WmsService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureUnlocked(EntityBase entity)
|
private static void StampCreate(AggregateRoot entity, string actor)
|
||||||
{
|
|
||||||
if (entity.IsLock)
|
|
||||||
throw new MiGu.DB.Abstractions.Exceptions.EntityLockedException(entity.GetType().Name, entity.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void StampCreate(EntityBase entity, string actor)
|
|
||||||
{
|
{
|
||||||
entity.CreatedBy = actor;
|
entity.CreatedBy = actor;
|
||||||
entity.UpdatedBy = 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.IsLock = req.IsLock;
|
||||||
entity.Remark = req.Remark.TrimOr("");
|
entity.Remark = req.Remark.TrimOr("");
|
||||||
@@ -902,11 +844,6 @@ public sealed class WmsService
|
|||||||
entity.UpdatedBy = actor;
|
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)
|
private string NormalizeExtend(string? extend)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(extend)) return "{}";
|
if (string.IsNullOrWhiteSpace(extend)) return "{}";
|
||||||
|
|||||||
@@ -16,4 +16,3 @@ global using WmsDefaults = MiGu.DB.Domains.Wms.WmsDefaults;
|
|||||||
global using WmsTransportTaskStatuses = MiGu.DB.Domains.Transport.WmsTransportTaskStatuses;
|
global using WmsTransportTaskStatuses = MiGu.DB.Domains.Transport.WmsTransportTaskStatuses;
|
||||||
global using WmsTransportTriggerTypes = MiGu.DB.Domains.Transport.WmsTransportTriggerTypes;
|
global using WmsTransportTriggerTypes = MiGu.DB.Domains.Transport.WmsTransportTriggerTypes;
|
||||||
global using WmsReservationStatuses = MiGu.DB.Domains.Transport.WmsReservationStatuses;
|
global using WmsReservationStatuses = MiGu.DB.Domains.Transport.WmsReservationStatuses;
|
||||||
global using WmsDispatchStatuses = MiGu.DB.Domains.Transport.WmsDispatchStatuses;
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ namespace MiGu.Server.Wms;
|
|||||||
|
|
||||||
public sealed class WmsTransportPlanner
|
public sealed class WmsTransportPlanner
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
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)
|
public async Task<TransportCandidatePreview> PreviewAsync(WmsTransportRequest request)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ namespace MiGu.Server.Wms;
|
|||||||
|
|
||||||
public sealed class WmsTransportRuleService
|
public sealed class WmsTransportRuleService
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
private readonly IEditableRepository<WmsTransportRule> _rules;
|
private readonly IEditableRepository<WmsTransportRule> _rules;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public WmsTransportRuleService(
|
public WmsTransportRuleService(
|
||||||
PlatformDbContext db,
|
MiGuDbContext db,
|
||||||
IEditableRepository<WmsTransportRule> rules,
|
IEditableRepository<WmsTransportRule> rules,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
@@ -71,13 +71,13 @@ public sealed class WmsTransportRuleService
|
|||||||
await _uow.SaveChangesAsync();
|
await _uow.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StampCreate(EntityBase entity, string actor)
|
private static void StampCreate(AggregateRoot entity, string actor)
|
||||||
{
|
{
|
||||||
entity.CreatedBy = actor;
|
entity.CreatedBy = actor;
|
||||||
entity.UpdatedBy = 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.IsLock = req.IsLock;
|
||||||
entity.Remark = req.Remark.TrimOr("");
|
entity.Remark = req.Remark.TrimOr("");
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ namespace MiGu.Server.Wms;
|
|||||||
|
|
||||||
public sealed class WmsTransportTaskService
|
public sealed class WmsTransportTaskService
|
||||||
{
|
{
|
||||||
private readonly PlatformDbContext _db;
|
private readonly MiGuDbContext _db;
|
||||||
private readonly WmsTransportPlanner _planner;
|
private readonly WmsTransportPlanner _planner;
|
||||||
private readonly WmsService _wms;
|
private readonly WmsService _wms;
|
||||||
private readonly IWmsDispatchAdapter _dispatch;
|
private readonly IWmsDispatchAdapter _dispatch;
|
||||||
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
public WmsTransportTaskService(
|
public WmsTransportTaskService(
|
||||||
PlatformDbContext db,
|
MiGuDbContext db,
|
||||||
WmsTransportPlanner planner,
|
WmsTransportPlanner planner,
|
||||||
WmsService wms,
|
WmsService wms,
|
||||||
IWmsDispatchAdapter dispatch)
|
IWmsDispatchAdapter dispatch)
|
||||||
@@ -331,7 +331,7 @@ public sealed class WmsTransportTaskService
|
|||||||
GeneratedAt = DateTimeOffset.UtcNow
|
GeneratedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void StampCreate(EntityBase entity, string actor)
|
private static void StampCreate(AggregateRoot entity, string actor)
|
||||||
{
|
{
|
||||||
entity.CreatedBy = actor;
|
entity.CreatedBy = actor;
|
||||||
entity.UpdatedBy = actor;
|
entity.UpdatedBy = actor;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
"Database": {
|
"Database": {
|
||||||
"SchemaMode": "EnsureCreated"
|
"SchemaMode": "EnsureCreated"
|
||||||
},
|
},
|
||||||
"SimpleLite": {
|
"Simple3": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
|
||||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
|
||||||
"ProjectionPort": 8222,
|
"ProjectionPort": 8222,
|
||||||
"ReadinessTimeoutMs": 8000,
|
"ReadinessTimeoutMs": 8000,
|
||||||
"FollowParent": false
|
"FollowParent": false
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
"Internal": {
|
"Internal": {
|
||||||
"Token": "REPLACE_ME"
|
"Token": "REPLACE_ME"
|
||||||
},
|
},
|
||||||
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
"_comment_Simple3": "登录后拉起调度内核 Simple3.exe;改路径请编辑下方 Simple3 节点。诊断: http://localhost:8080/api/health/simple3",
|
||||||
"SimpleLite": {
|
"Simple3": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
|
||||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
|
||||||
"Arguments": "",
|
"Arguments": "",
|
||||||
"ReadinessTimeoutMs": 15000,
|
"ReadinessTimeoutMs": 15000,
|
||||||
"ReadinessPollIntervalMs": 250,
|
"ReadinessPollIntervalMs": 250,
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"ops": { "Password": "ops" }
|
"ops": { "Password": "ops" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method',Method 必须是 SimpleLite 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
|
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method',Method 必须是 Simple3 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
|
||||||
"Ops": {
|
"Ops": {
|
||||||
"Dispatch": {
|
"Dispatch": {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"section": "system",
|
"section": "system",
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
|
"updatedAt": "2026-08-21T08:06:13.7109039+00:00",
|
||||||
"payload": {
|
"payload": {
|
||||||
"dispatchLoopHz": 50,
|
"dispatchLoopHz": 50,
|
||||||
"log": {
|
"log": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"jwtExpireMin": 1440,
|
"jwtExpireMin": 1440,
|
||||||
"enableSwagger": false,
|
"enableSwagger": true,
|
||||||
"corsWhitelist": [
|
"corsWhitelist": [
|
||||||
"http://localhost:5173"
|
"http://localhost:5173"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,12 +19,18 @@
|
|||||||
"id": "role-ops",
|
"id": "role-ops",
|
||||||
"name": "\u8FD0\u8425\u4EBA\u5458",
|
"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",
|
"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": [
|
"pages": [
|
||||||
"monitor-dashboard",
|
"monitor-notes",
|
||||||
"monitor-map",
|
"admin-dashboard",
|
||||||
|
"admin-map-monitor",
|
||||||
|
"admin-tasks",
|
||||||
|
"admin-alarms",
|
||||||
|
"admin-config-warehouse",
|
||||||
"monitor-ops",
|
"monitor-ops",
|
||||||
"monitor-notes"
|
"admin-config-ops-center",
|
||||||
|
"admin-logs",
|
||||||
|
"admin-playback"
|
||||||
],
|
],
|
||||||
"ops": [
|
"ops": [
|
||||||
"ops.car.pause",
|
"ops.car.pause",
|
||||||
@@ -87,8 +93,8 @@
|
|||||||
"roleIds": [
|
"roleIds": [
|
||||||
"role-ops"
|
"role-ops"
|
||||||
],
|
],
|
||||||
"salt": "G6c5\u002BnLEjf3fEor1LgNjyg==",
|
"salt": "F6wxGwnf/nvdbbAKp6hYoQ==",
|
||||||
"passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg="
|
"passwordHash": "\u002BFvJCZgUYReNSr4T6xIpZU7FoajVHe067JVaHf/Evb8="
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+26
-26
@@ -1,43 +1,43 @@
|
|||||||
# 平台 v3 重构 — 阶段 0~6 实际改动
|
# 平台 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 章节;后续以本文件为准。
|
> 基线:上一份方案见 `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 错误
|
- `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
|
- `build-platform-frontend.bat` —— vite build 46.5s,无 vue-tsc / ts 错误,robocopy /MIR 已同步 MiGu.Server/wwwroot
|
||||||
- 旧 `SimpleComposer` (WinForms) 在 v3 之外,不在本次构建目标里
|
- 旧 `SimpleComposer` (WinForms) 在 v3 之外,不在本次构建目标里
|
||||||
- 旧 `HttpClientHelper` 提升 LangVersion=latest 修通
|
- 旧 `HttpClientHelper` 提升 LangVersion=latest 修通
|
||||||
|
|
||||||
## 阶段 0:后端 SimpleLite 平台化(已完成)
|
## 阶段 0:后端 Simple3 平台化(已完成)
|
||||||
|
|
||||||
### 新增文件
|
### 新增文件
|
||||||
|
|
||||||
| 文件 | 作用 |
|
| 文件 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `SimpleLite/Web/MapEditApiController.cs` | 写操作 8 路 API:create/delete/batch/copy-to/pick/dashboard/upload/ai-generate;阶段 6 又补:**project/save**、**project/load**、**project/current**、**sample-sites-along-track** |
|
| `Simple3/Web/MapEditApiController.cs` | 写操作 8 路 API:create/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 API;listKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
|
| `Simple3/Web/ReflectionApiController.cs` | 只读 + execute API;listKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
|
||||||
| `SimpleLite/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
|
| `Simple3/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
|
||||||
| `SimpleLite/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
|
| `Simple3/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
|
||||||
| `SimpleLite/Web/AiConfigController.cs` | AI 服务配置(endpoint/apiKey/model/system prompt/temperature/maxTokens/timeout)读写 |
|
| `Simple3/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 |
|
| `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 |
|
||||||
| `SimpleLite/Web/ProjectionStreamModule.cs` | 平台 SSE 总线,对外广播 `object-created` / `object-deleted` / `object-patched` / `object-batch-changed` / `alarm` / `pick-result` / `project-saved` / `project-loaded` |
|
| `Simple3/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/PluginAssemblyScan.cs` | 反射扫描 plugins/*.dll,把 `MethodMember` 标注的方法 + Car/Mission/CarProgram/Site/Track 子类型暴露给反射 API |
|
||||||
|
|
||||||
### 修改文件
|
### 修改文件
|
||||||
|
|
||||||
| 文件 | 关键改动 |
|
| 文件 | 关键改动 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `SimpleLite/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
|
| `Simple3/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
|
||||||
| `SimpleLite/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
|
| `Simple3/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
|
||||||
| `SimpleLite/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
|
| `Simple3/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
|
||||||
| `SimpleLite/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
|
| `Simple3/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
|
||||||
| `SimpleLite/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
|
| `Simple3/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
|
||||||
| `SimpleLite/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
|
| `Simple3/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
|
||||||
| `SimpleLite/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel) |
|
| `Simple3/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel) |
|
||||||
|
|
||||||
### 关键 API 路径
|
### 关键 API 路径
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ SSE /projection/stream # 所有 SSE 事件
|
|||||||
- 报警来源走 `useMapEditStream.onAlarm`(订阅 SSE 的 `alarm` 事件,由 AlarmStreamService 1s diff 推送)
|
- 报警来源走 `useMapEditStream.onAlarm`(订阅 SSE 的 `alarm` 事件,由 AlarmStreamService 1s diff 推送)
|
||||||
- KPI 3 卡:在线车辆 / 运行中任务 / 排队任务
|
- KPI 3 卡:在线车辆 / 运行中任务 / 排队任务
|
||||||
- 实时列表:左侧 Workspace3D + 右侧 VehicleMonitorPanel + SelectionDetailPanel
|
- 实时列表:左侧 Workspace3D + 右侧 VehicleMonitorPanel + SelectionDetailPanel
|
||||||
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 SimpleLite 3D 高亮该车(视觉上等效 flyTo)
|
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 Simple3 3D 高亮该车(视觉上等效 flyTo)
|
||||||
|
|
||||||
## 阶段 5:OrchestrationView 设计与编排(已完成)
|
## 阶段 5:OrchestrationView 设计与编排(已完成)
|
||||||
|
|
||||||
@@ -159,9 +159,9 @@ Tab 状态缓存到 sessionStorage。
|
|||||||
|
|
||||||
### 编译相关清理
|
### 编译相关清理
|
||||||
|
|
||||||
- `SimpleLite/Recording/`(之前的录制/回放实验目录)误用 `SimpleLite.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
|
- `Simple3/Recording/`(之前的录制/回放实验目录)误用 `Simple3.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
|
||||||
- `SimpleLite/Props/UISite.cs` 在 `SimpleLite.Props` namespace 下 `Workspace.AddProp` 被解析成 `SimpleLite.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
|
- `Simple3/Props/UISite.cs` 在 `Simple3.Props` namespace 下 `Workspace.AddProp` 被解析成 `Simple3.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
|
||||||
- `SimpleLite/Workspace/WorkspaceBottomBar.cs` 缺 `using SimpleLite.Workspace;` → 补
|
- `Simple3/Workspace/WorkspaceBottomBar.cs` 缺 `using Simple3.Workspace;` → 补
|
||||||
- `HttpClientHelper.csproj` LangVersion 7.3 → latest
|
- `HttpClientHelper.csproj` LangVersion 7.3 → latest
|
||||||
|
|
||||||
## 已知 TODO(不阻塞 v3 上线)
|
## 已知 TODO(不阻塞 v3 上线)
|
||||||
@@ -176,16 +176,16 @@ Tab 状态缓存到 sessionStorage。
|
|||||||
## 启动方式
|
## 启动方式
|
||||||
|
|
||||||
```
|
```
|
||||||
# 1. 后端 build(SimpleLite + MiGu.Server)
|
# 1. 后端 build(Simple3 + MiGu.Server)
|
||||||
dotnet build SimpleLite\SimpleLite.csproj
|
dotnet build Simple3\Simple3.csproj
|
||||||
dotnet build MiGu.Server\MiGu.Server.csproj
|
dotnet build MiGu.Server\MiGu.Server.csproj
|
||||||
|
|
||||||
# 2. 前端打包并同步到 wwwroot
|
# 2. 前端打包并同步到 wwwroot
|
||||||
.\build-platform-frontend.bat
|
.\build-platform-frontend.bat
|
||||||
|
|
||||||
# 3. 启动平台 + SimpleLite
|
# 3. 启动平台 + Simple3
|
||||||
dotnet run --project MiGu.Server # 监听 0.0.0.0:8080
|
dotnet run --project MiGu.Server # 监听 0.0.0.0:8080
|
||||||
# SimpleLite 端口 8222/8223 由 SimpleLite 自己启动
|
# Simple3 端口 8222/8223 由 Simple3 自己启动
|
||||||
|
|
||||||
# 4. 访问
|
# 4. 访问
|
||||||
http://127.0.0.1:8080/admin/map-editor # 地图编辑(v3 四栏壳)
|
http://127.0.0.1:8080/admin/map-editor # 地图编辑(v3 四栏壳)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Migu2.0
|
# Migu2.0
|
||||||
|
|
||||||
迷毂智能调度平台主仓库(平台后端 + 平台前端)。
|
迷毂智能调度平台主仓库(平台后端 + 平台前端)。
|
||||||
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `SimpleLite` 仍在相邻仓库 `../Simple` 中维护。
|
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `Simple3` 仍在相邻仓库 `../Simple` 中维护。
|
||||||
|
|
||||||
## 仓库定位
|
## 仓库定位
|
||||||
|
|
||||||
- **平台后端**:`MiGu.Server`(ASP.NET Core 8 + YARP,默认 `:8080`)
|
- **平台后端**:`MiGu.Server`(ASP.NET Core 8 + YARP,默认 `:8080`)
|
||||||
- **平台前端**:`frontends/apps/simple-platform-vue`(Vue 3 + Vite)
|
- **平台前端**:`frontends/apps/simple-platform-vue`(Vue 3 + Vite)
|
||||||
- **调度内核**:`SimpleLite`(不在本仓库,位于 `../Simple/SimpleLite`)
|
- **调度内核**:`Simple3`(不在本仓库,位于 `../Simple/Simple3`)
|
||||||
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `SimpleLite.exe`
|
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `Simple3.exe`
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
Migu2.0/
|
Migu2.0/
|
||||||
├── MiGu.Server/ # 平台后端入口工程
|
├── MiGu.Server/ # 平台后端入口工程
|
||||||
│ ├── Controllers/ # Auth/Config/Ops/Health 等 API
|
│ ├── Controllers/ # Auth/Config/Ops/Health 等 API
|
||||||
│ ├── Launcher/ # SimpleLite 子进程拉起与健康探测
|
│ ├── Launcher/ # Simple3 子进程拉起与健康探测
|
||||||
│ ├── Auth/ # JWT / 用户鉴权 / 内部 token
|
│ ├── Auth/ # JWT / 用户鉴权 / 内部 token
|
||||||
│ ├── Configs/ # 平台配置中心模型与存取
|
│ ├── Configs/ # 平台配置中心模型与存取
|
||||||
│ ├── wwwroot/ # 已构建前端静态资源(部署用)
|
│ ├── wwwroot/ # 已构建前端静态资源(部署用)
|
||||||
│ ├── appsettings.json # 启动、YARP、SimpleLite 路径配置
|
│ ├── appsettings.json # 启动、YARP、Simple3 路径配置
|
||||||
│ └── README.md # 后端详细说明
|
│ └── README.md # 后端详细说明
|
||||||
├── frontends/ # 平台前端源码 workspace
|
├── frontends/ # 平台前端源码 workspace
|
||||||
│ └── apps/simple-platform-vue/
|
│ └── apps/simple-platform-vue/
|
||||||
@@ -31,35 +31,35 @@ Migu2.0/
|
|||||||
└── Doc/
|
└── Doc/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 与 SimpleLite 的关系
|
## 与 Simple3 的关系
|
||||||
|
|
||||||
`Migu2.0` 与 `SimpleLite` 是分仓协作关系:
|
`Migu2.0` 与 `Simple3` 是分仓协作关系:
|
||||||
|
|
||||||
- `MiGu.Server` 提供登录、权限、配置中心、平台 UI 托管与网关能力
|
- `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/`
|
- **接口代理**:`/api/sl/*` 通过 YARP 转发到 `http://127.0.0.1:8222/`
|
||||||
- **3D 页面代理**:`/vr/*` 转发到 `http://127.0.0.1:8223/`
|
- **3D 页面代理**:`/vr/*` 转发到 `http://127.0.0.1:8223/`
|
||||||
|
|
||||||
默认开发路径(可改)在 `MiGu.Server/appsettings.json`:
|
默认开发路径(可改)在 `MiGu.Server/appsettings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"SimpleLite": {
|
"Simple3": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
|
||||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
|
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug",
|
||||||
"ProjectionPort": 8222
|
"ProjectionPort": 8222
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速启动(开发机)
|
## 快速启动(开发机)
|
||||||
|
|
||||||
### 1) 先编译 SimpleLite
|
### 1) 先编译 Simple3
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd ..\Simple
|
cd ..\Simple
|
||||||
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
dotnet build Simple3\Simple3.csproj -c Debug
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2) 启动 MiGu.Server
|
### 2) 启动 MiGu.Server
|
||||||
@@ -74,7 +74,7 @@ dotnet run
|
|||||||
- `admin / admin`
|
- `admin / admin`
|
||||||
- `ops / ops`
|
- `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`
|
- `Jwt.Secret`
|
||||||
- `Internal.Token`
|
- `Internal.Token`
|
||||||
- `Auth.Users.*.Password`
|
- `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`:后端健康
|
||||||
- `http://localhost:8080/api/health/simplelite`:SimpleLite 路径与端口诊断
|
- `http://localhost:8080/api/health/simple3`:Simple3 路径与端口诊断
|
||||||
- 访问平台时若出现 `502`:通常表示 `SimpleLite` 的 `8222` 未就绪或未启动
|
- 访问平台时若出现 `502`:通常表示 `Simple3` 的 `8222` 未就绪或未启动
|
||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -5,7 +5,7 @@
|
|||||||
> 通过路由前缀 `/admin/*` 与 `/monitor/*` 切换,后续可按 ARCHITECTURE.md §13 拆分为独立工程。
|
> 通过路由前缀 `/admin/*` 与 `/monitor/*` 切换,后续可按 ARCHITECTURE.md §13 拆分为独立工程。
|
||||||
>
|
>
|
||||||
> 落地范围与依据:本目录是 [ARCHITECTURE.md](../ARCHITECTURE.md) v1.7 §6/§9/§13/§17/§18 中前端形态的初版骨架;
|
> 落地范围与依据:本目录是 [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。
|
> 品牌主色:默认 **工业紫** `#6a1b9a`(6 套主题可切换),详见 ARCHITECTURE.md §17.7。
|
||||||
> 工程包名 `simple-platform-vue` / 仓库目录 `frontends/apps/simple-platform-vue` 保持不动,避免破坏 pnpm/CI 脚本,仅用户可见文案统一为「迷毂」。
|
> 工程包名 `simple-platform-vue` / 仓库目录 `frontends/apps/simple-platform-vue` 保持不动,避免破坏 pnpm/CI 脚本,仅用户可见文案统一为「迷毂」。
|
||||||
@@ -82,7 +82,7 @@ pnpm dev
|
|||||||
## 地图监控页与 webVRender
|
## 地图监控页与 webVRender
|
||||||
|
|
||||||
地图监控页(`/admin/map-monitor` 与 `/monitor/map`)通过 `<Workspace3D>` 组件以 iframe 嵌入
|
地图监控页(`/admin/map-monitor` 与 `/monitor/map`)通过 `<Workspace3D>` 组件以 iframe 嵌入
|
||||||
SimpleLite WebTerminal 提供的 webVRender,默认 URL:
|
Simple3 WebTerminal 提供的 webVRender,默认 URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:8223/?scope=Platform&token=<jwt>&ro=0 # 管理员,可交互
|
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)
|
### 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
|
- 若浏览器控制台报「Refused to display 'http://localhost:8223/' in a frame」,请在 Simple3 WebTerminal
|
||||||
侧加上 `X-Frame-Options: ALLOWALL` 或 `Content-Security-Policy: frame-ancestors *`(本轮代码未改动 SimpleLite)。
|
侧加上 `X-Frame-Options: ALLOWALL` 或 `Content-Security-Policy: frame-ancestors *`(本轮代码未改动 Simple3)。
|
||||||
- 若需通过 `Platform.Server` 走同源代理,可以让 iframe 指向 `http://localhost:8080/vr/`,由 YARP
|
- 若需通过 `Platform.Server` 走同源代理,可以让 iframe 指向 `http://localhost:8080/vr/`,由 YARP
|
||||||
转发到 `http://127.0.0.1:8223/`(见 `Platform.Server/appsettings.json` 的 `vrender-route`)。
|
转发到 `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/config/{section}` → `Platform.Server` `ConfigController`(14 维度占位)
|
||||||
- `/api/health` → `Platform.Server` `HealthController`
|
- `/api/health` → `Platform.Server` `HealthController`
|
||||||
- `/api/projection/*` → `Platform.Server` `ProjectionController`(占位)
|
- `/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`(白名单网关占位)
|
- `/api/sl/ops/execute` 与 `/api/sl/ops/audits` → `Platform.Server` `OpsController`(白名单网关占位)
|
||||||
|
|
||||||
### Mock 开关:`VITE_USE_MOCK`
|
### Mock 开关:`VITE_USE_MOCK`
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ declare module 'vue' {
|
|||||||
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
|
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ScopeSwitcher: typeof import('./src/components/ScopeSwitcher.vue')['default']
|
|
||||||
SelectionDetailPanel: typeof import('./src/components/workbench/SelectionDetailPanel.vue')['default']
|
SelectionDetailPanel: typeof import('./src/components/workbench/SelectionDetailPanel.vue')['default']
|
||||||
SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default']
|
SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default']
|
||||||
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
|
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"wcs-proto:selfcheck": "npx --yes tsx src/wcs-proto/selfcheck.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import http from './http'
|
|||||||
/**
|
/**
|
||||||
* AI 助手 API:会话/工具走 axios(带鉴权拦截器);对话走原生 fetch 流式(SSE),
|
* AI 助手 API:会话/工具走 axios(带鉴权拦截器);对话走原生 fetch 流式(SSE),
|
||||||
* 因为 EventSource 只能 GET、且无法设置 Authorization header。fetch 这里手动对齐
|
* 因为 EventSource 只能 GET、且无法设置 Authorization header。fetch 这里手动对齐
|
||||||
* 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'
|
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) {
|
if (!resp.ok || !resp.body) {
|
||||||
let msg = `请求失败(HTTP ${resp.status})`
|
let msg = `请求失败(HTTP ${resp.status})`
|
||||||
if (resp.status === 403) msg = '无权使用 AI 助手(需要 Platform 权限)。'
|
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 {
|
else {
|
||||||
try {
|
try {
|
||||||
const t = await resp.text()
|
const t = await resp.text()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function listDeliveries(opts?: {
|
|||||||
return Array.isArray(data) ? data : []
|
return Array.isArray(data) ? data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
|
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(Simple3 关闭时仍可读,含完整历史)。 */
|
||||||
export interface CdmTaskFeed {
|
export interface CdmTaskFeed {
|
||||||
online: boolean
|
online: boolean
|
||||||
lastSyncAt: string | null
|
lastSyncAt: string | null
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function fetchFleetHealth(): Promise<FleetHealthRow[]> {
|
|||||||
await new Promise((r) => setTimeout(r, 120))
|
await new Promise((r) => setTimeout(r, 120))
|
||||||
return mockFleetHealth()
|
return mockFleetHealth()
|
||||||
}
|
}
|
||||||
// 平台侧聚合:SimpleLite 指标 + WatchDog(:9776) TCP RTT。
|
// 平台侧聚合:Simple3 指标 + WatchDog(:9776) TCP RTT。
|
||||||
// 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。
|
// 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get<FleetHealthRow[]>('/fleet/health')
|
const { data } = await http.get<FleetHealthRow[]>('/fleet/health')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface HealthInfo {
|
|||||||
uptimeSec: number
|
uptimeSec: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleLiteDiagnostics {
|
export interface Simple3Diagnostics {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
isRunning: boolean
|
isRunning: boolean
|
||||||
lastLaunchMode?: string | null
|
lastLaunchMode?: string | null
|
||||||
@@ -18,7 +18,7 @@ export interface SimpleLiteDiagnostics {
|
|||||||
deployHint?: string | null
|
deployHint?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleLiteLaunchResult {
|
export interface Simple3LaunchResult {
|
||||||
started: boolean
|
started: boolean
|
||||||
status: string
|
status: string
|
||||||
detail: string
|
detail: string
|
||||||
@@ -30,29 +30,26 @@ export function getHealth() {
|
|||||||
return http.get<HealthInfo>('/health')
|
return http.get<HealthInfo>('/health')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSimpleLiteDiagnostics() {
|
export function getSimple3Diagnostics() {
|
||||||
return http.get<SimpleLiteDiagnostics>('/health/simplelite')
|
return http.get<Simple3Diagnostics>('/health/simple3')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSimpleLite() {
|
export function stopSimple3() {
|
||||||
return http.post<{ killed: number; diagnostics: SimpleLiteDiagnostics }>('/health/simplelite/stop')
|
return http.post<{ killed: number; diagnostics: Simple3Diagnostics }>('/health/simple3/stop')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restartSimpleLite(launchMode: LaunchMode) {
|
export function restartSimple3(launchMode: LaunchMode) {
|
||||||
return http.post<{ restart: SimpleLiteLaunchResult; diagnostics: SimpleLiteDiagnostics }>(
|
return http.post<{ restart: Simple3LaunchResult; diagnostics: Simple3Diagnostics }>(
|
||||||
'/health/simplelite/restart',
|
'/health/simple3/restart',
|
||||||
null,
|
null,
|
||||||
{ params: { launchMode }, timeout: 60_000 }
|
{ params: { launchMode }, timeout: 60_000 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从诊断/会话 runMode 推断重启时使用的 launchMode。 */
|
/** Simple3 仅 Web;重启一律 WebOnly(保留函数签名供旧调用方)。 */
|
||||||
export function resolveRestartLaunchMode(
|
export function resolveRestartLaunchMode(
|
||||||
lastLaunchMode: string | null | undefined,
|
_lastLaunchMode?: string | null,
|
||||||
runMode: RunMode | null | undefined
|
_runMode?: RunMode | null
|
||||||
): LaunchMode {
|
): LaunchMode {
|
||||||
const mode = (lastLaunchMode ?? '').toLowerCase()
|
return 'WebOnly'
|
||||||
if (mode === 'web') return 'WebOnly'
|
|
||||||
if (mode === 'web+local') return 'DesktopAndWeb'
|
|
||||||
return runMode === 'WebOnly' ? 'WebOnly' : 'DesktopAndWeb'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const http: AxiosInstance = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
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 会自动带;
|
// 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带;
|
||||||
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
|
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
|
||||||
const token = localStorage.getItem('simple.auth.token')
|
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。把这两种
|
* 如果 Simple3 没启动 / 端口未监听,YARP 一定回 502;超时一般是 504。把这两种
|
||||||
* 情况单独识别,写成「SimpleLite 未连接,请先启动 SimpleLite (端口 8222)」之类,
|
* 情况单独识别,写成「Simple3 未连接,请先启动 Simple3 (端口 8222)」之类,
|
||||||
* 比直接抛 `Request failed with status code 502` 友好得多,也避免用户怀疑前端 bug。
|
* 比直接抛 `Request failed with status code 502` 友好得多,也避免用户怀疑前端 bug。
|
||||||
*/
|
*/
|
||||||
function describeError(err: AxiosError): string {
|
function describeError(err: AxiosError): string {
|
||||||
const status = err.response?.status
|
const status = err.response?.status
|
||||||
const url = err.config?.url ?? ''
|
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 (status === 502 || status === 504 || err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK') {
|
||||||
if (isSimpleLite) {
|
if (isSimple3) {
|
||||||
return 'SimpleLite 未连接:请先启动 SimpleLite 后端(监听端口 8222)后重试。'
|
return 'Simple3 未连接:请先启动 Simple3 后端(监听端口 8222)后重试。'
|
||||||
}
|
}
|
||||||
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
|
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import http from './http'
|
|||||||
/**
|
/**
|
||||||
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController`(`/api/logs/*`)。
|
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController`(`/api/logs/*`)。
|
||||||
*
|
*
|
||||||
* 数据是 SimpleLite 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
|
* 数据是 Simple3 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
|
||||||
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
|
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
|
||||||
* 「按标签合订」/ 原文 / 下载。
|
* 「按标签合订」/ 原文 / 下载。
|
||||||
*
|
*
|
||||||
@@ -14,10 +14,10 @@ import http from './http'
|
|||||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
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 }
|
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'
|
const SL_DIAG = '/sl/projection/diagnosis'
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
@@ -47,6 +47,31 @@ export interface LogDayStat {
|
|||||||
bytes: number
|
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 {
|
export interface LogOverview {
|
||||||
exists: boolean
|
exists: boolean
|
||||||
root: string | null
|
root: string | null
|
||||||
@@ -56,6 +81,7 @@ export interface LogOverview {
|
|||||||
latestFileTime?: string | null
|
latestFileTime?: string | null
|
||||||
days?: LogDayStat[]
|
days?: LogDayStat[]
|
||||||
message?: string
|
message?: string
|
||||||
|
disk?: LogDiskStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogFilesResult {
|
export interface LogFilesResult {
|
||||||
@@ -116,7 +142,7 @@ export interface DigestQuery {
|
|||||||
maxPerTag?: number
|
maxPerTag?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
|
/** 实时诊断单条:对应 Simple3 内核 Diagnosis 的内存态 Post/Toast。 */
|
||||||
export interface LiveDiagItem {
|
export interface LiveDiagItem {
|
||||||
index: number
|
index: number
|
||||||
time: string
|
time: string
|
||||||
@@ -225,12 +251,25 @@ export interface AnalyzeQuery {
|
|||||||
function mockOverview(): LogOverview {
|
function mockOverview(): LogOverview {
|
||||||
return {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
|
root: 'E:\\...\\Simple3\\bin\\Debug\\log',
|
||||||
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
|
workingDirectory: 'E:\\...\\Simple3\\bin\\Debug',
|
||||||
totalFiles: 3,
|
totalFiles: 3,
|
||||||
totalBytes: 5_233_649,
|
totalBytes: 5_233_649,
|
||||||
latestFileTime: new Date().toISOString(),
|
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: 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: 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: 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' }
|
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
|
||||||
]
|
]
|
||||||
const taggedCount = items.filter((i) => i.tagged).length
|
const taggedCount = items.filter((i) => i.tagged).length
|
||||||
@@ -373,7 +412,7 @@ export const logsApi = {
|
|||||||
? Promise.resolve(mockDigest())
|
? Promise.resolve(mockDigest())
|
||||||
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
|
: 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
|
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
|
||||||
? Promise.resolve(mockLiveDiagnosis())
|
? Promise.resolve(mockLiveDiagnosis())
|
||||||
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
|
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
|
||||||
@@ -398,5 +437,17 @@ export const logsApi = {
|
|||||||
|
|
||||||
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
|
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
|
||||||
downloadUrl: (file: string): string =>
|
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'
|
import http from './http'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 SimpleLite `MapEditApiController` 对应的前端胶水。
|
* 与 Simple3 `MapEditApiController` 对应的前端胶水。
|
||||||
* 路径前缀:`/sl/projection/map-edit`(YARP → SimpleLite `/projection/map-edit`)。
|
* 路径前缀:`/sl/projection/map-edit`(YARP → Simple3 `/projection/map-edit`)。
|
||||||
*
|
*
|
||||||
* 也覆盖 AI 服务配置 `/sl/projection/ai-config`(GET / POST),由 AiConfigController 提供。
|
* 也覆盖 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 })),
|
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
|
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
|
||||||
*/
|
*/
|
||||||
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
|
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
|
||||||
@@ -264,8 +264,15 @@ export const mapEditApi = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
|
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
|
||||||
pick: (opts?: { snap?: boolean }) =>
|
pick: (opts?: { snap?: boolean; signal?: AbortSignal }) =>
|
||||||
unwrap<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
|
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: () =>
|
dashboardSummary: () =>
|
||||||
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
||||||
@@ -300,9 +307,9 @@ export const mapEditApi = {
|
|||||||
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
|
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在 SimpleLite 桌面进程上弹 Windows 原生「打开文件」对话框,
|
* 在 Simple3 桌面进程上弹 Windows 原生「打开文件」对话框,
|
||||||
* 让用户挑一个项目 JSON。返回 { path, cancelled };cancelled=true 时 path 为 null。
|
* 让用户挑一个项目 JSON。返回 { path, cancelled };cancelled=true 时 path 为 null。
|
||||||
* initialDir 默认 SimpleLite 程序工作目录。
|
* initialDir 默认 Simple3 程序工作目录。
|
||||||
*/
|
*/
|
||||||
projectNativePickOpen: (initialDir?: string) =>
|
projectNativePickOpen: (initialDir?: string) =>
|
||||||
unwrap<{ path: string | null; cancelled: boolean }>(
|
unwrap<{ path: string | null; cancelled: boolean }>(
|
||||||
@@ -328,7 +335,7 @@ export const mapEditApi = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取后端「过滤」菜单当前的三组开关。编辑器载入时拉一次,让前端 reactive 状态
|
* 读取后端「过滤」菜单当前的三组开关。编辑器载入时拉一次,让前端 reactive 状态
|
||||||
* 与 SimpleLite 真实开关一致(避免「前端勾着、后端没改」的偏差)。
|
* 与 Simple3 真实开关一致(避免「前端勾着、后端没改」的偏差)。
|
||||||
*/
|
*/
|
||||||
getViewFilter: () => unwrap<ViewFilterSnapshot>(http.get(`${BASE}/view-filter`)),
|
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 {
|
export interface MapListItem {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface OpsExecuteReq {
|
|||||||
targetId: string
|
targetId: string
|
||||||
reason?: string
|
reason?: string
|
||||||
idempotencyKey?: string
|
idempotencyKey?: string
|
||||||
|
method?: string
|
||||||
|
params?: Record<string, string>
|
||||||
|
siteId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsExecuteResp {
|
export interface OpsExecuteResp {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export async function uploadOtaPackage(file: File): Promise<OtaPackageInfo> {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
|
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 300000
|
timeout: 300000
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
@@ -116,7 +115,6 @@ export async function pushOtaCustomFile(opts: {
|
|||||||
form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1]))
|
form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1]))
|
||||||
for (const f of opts.files) form.append('files', f)
|
for (const f of opts.files) form.append('files', f)
|
||||||
const { data } = await http.post<OtaJob>('/ota/custom-file', form, {
|
const { data } = await http.post<OtaJob>('/ota/custom-file', form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 300000
|
timeout: 300000
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Mission, MissionStatus } from '@/types/mission'
|
|||||||
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
|
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
|
||||||
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
|
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)。 */
|
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
|
||||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ async function enrichCarsRuntime(cars: Car[]): Promise<Car[]> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 优先走 SimpleLite /projection/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
|
/** 优先走 Simple3 /projection/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
|
||||||
export async function listCars(): Promise<Car[]> {
|
export async function listCars(): Promise<Car[]> {
|
||||||
if (MOCK) return mockCars()
|
if (MOCK) return mockCars()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 SimpleLite `ReflectionApiController` 一一对应的前端胶水。
|
* 与 Simple3 `ReflectionApiController` 一一对应的前端胶水。
|
||||||
* 路径前缀:`/sl/projection/reflection`(迷榖平台 YARP 反代下游 → SimpleLite EmbedIO)。
|
* 路径前缀:`/sl/projection/reflection`(迷榖平台 YARP 反代下游 → Simple3 EmbedIO)。
|
||||||
*
|
*
|
||||||
* 该客户端覆盖了 SimpleLite 主体以及通过 plugins/*.dll 动态加载的 Standard 等
|
* 该客户端覆盖了 Simple3 主体以及通过 plugins/*.dll 动态加载的 Standard 等
|
||||||
* 插件中所有标注了 `MethodMember` 的方法与所有继承自 Car/Mission/CarProgram/Site/Track
|
* 插件中所有标注了 `MethodMember` 的方法与所有继承自 Car/Mission/CarProgram/Site/Track
|
||||||
* 的子类型,平台前端可据此动态渲染列表、属性、动作按钮。
|
* 的子类型,平台前端可据此动态渲染列表、属性、动作按钮。
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +39,7 @@ export type ReflectionKind =
|
|||||||
| 'map'
|
| 'map'
|
||||||
| 'special'
|
| 'special'
|
||||||
| 'script'
|
| 'script'
|
||||||
// 顶级合成 kind:等于 site + track + special 的并集,由 SimpleLite 后端 /objects/scene 直接返回,
|
// 顶级合成 kind:等于 site + track + special 的并集,由 Simple3 后端 /objects/scene 直接返回,
|
||||||
// 每行带 subKind 字段,前端 bundle / execute / setField 走对应底层 kind。
|
// 每行带 subKind 字段,前端 bundle / execute / setField 走对应底层 kind。
|
||||||
| 'scene'
|
| 'scene'
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export interface ScriptExceptionStatusPayload {
|
|||||||
// 地图监控可见性配置(与后端 MonitorVisibilityConfig / MonitorVisibilityForKind 对应)
|
// 地图监控可见性配置(与后端 MonitorVisibilityConfig / MonitorVisibilityForKind 对应)
|
||||||
//
|
//
|
||||||
// `config` = 管理员勾选的白名单。空数组 = 显示全部;非空 = 仅显示列表中的 key。
|
// `config` = 管理员勾选的白名单。空数组 = 显示全部;非空 = 仅显示列表中的 key。
|
||||||
// `available` = 当前 SimpleLite 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
|
// `available` = 当前 Simple3 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
|
||||||
// 运行时实例的 Prop.fields / status 反射键)。是 GET 返回的副产物,
|
// 运行时实例的 Prop.fields / status 反射键)。是 GET 返回的副产物,
|
||||||
// POST 写入时不需要、也不该回传。
|
// POST 写入时不需要、也不该回传。
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
@@ -253,17 +253,23 @@ export function formatReflectionExecuteMessage(
|
|||||||
): string {
|
): string {
|
||||||
if (result.returnValue) return `已执行:${result.returnValue}`
|
if (result.returnValue) return `已执行:${result.returnValue}`
|
||||||
if (result.accepted && result.completed === false) {
|
if (result.accepted && result.completed === false) {
|
||||||
return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)`
|
return `已受理:${label}(后台继续执行,请稍后在 Simple3 查看结果)`
|
||||||
}
|
}
|
||||||
if (result.accepted) return `已执行 ${label}`
|
if (result.accepted) return `已执行 ${label}`
|
||||||
return `已执行 ${label}`
|
return `已执行 ${label}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflectionExecuteOptions {
|
export interface ReflectionExecuteOptions {
|
||||||
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
|
/** 已在平台侧完成二次确认时带上;新内核仍需再带 428 下发的 confirmToken。 */
|
||||||
platformConfirmed?: boolean
|
platformConfirmed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlatformConfirmChallenge {
|
||||||
|
message: string
|
||||||
|
token: string
|
||||||
|
header: string
|
||||||
|
}
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
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)
|
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>,
|
params?: Record<string, string | number | boolean>,
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
try {
|
||||||
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
|
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)
|
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
|
||||||
return data.data as T
|
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> {
|
async function del<T>(path: string): Promise<T> {
|
||||||
@@ -297,22 +363,24 @@ async function executeWithPlatformConfirm<T>(
|
|||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
opts?: ReflectionExecuteOptions
|
opts?: ReflectionExecuteOptions
|
||||||
): Promise<T> {
|
): 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 {
|
try {
|
||||||
return await post<T>(path, params, headers)
|
return await post<T>(path, params, Object.keys(headers).length ? headers : undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
|
const challenge = parseConfirmChallenge(e)
|
||||||
const confirmMessage =
|
if (!challenge) throw e
|
||||||
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|
if (!opts?.platformConfirmed) {
|
||||||
|| '此操作需要确认'
|
await ElMessageBox.confirm(challenge.message, '确认', {
|
||||||
await ElMessageBox.confirm(confirmMessage, '确认', {
|
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消'
|
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}`),
|
: del<{ kind: string; id: number; deleted: boolean }>(`/objects/${kind}/${id}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触发 SimpleLite 重新扫描 ./plugins 目录、加载新增 dll 并重建 UiDiscoveryCache。
|
* 触发 Simple3 重新扫描 ./plugins 目录、加载新增 dll 并重建 UiDiscoveryCache。
|
||||||
* 已加载的 dll 不会重复加载。
|
* 已加载的 dll 不会重复加载。
|
||||||
* 返回:本次发现的 dll 总数、新加载的 assembly 数、当前可创建的 mission/car 类型计数。
|
* 返回:本次发现的 dll 总数、新加载的 assembly 数、当前可创建的 mission/car 类型计数。
|
||||||
*/
|
*/
|
||||||
@@ -515,7 +583,7 @@ export const reflectionApi = {
|
|||||||
return data.data as MonitorConfigSaveBody
|
return data.data as MonitorConfigSaveBody
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选中同步:让 SimpleLite 3D 场景同步高亮被点击对象
|
// 选中同步:让 Simple3 3D 场景同步高亮被点击对象
|
||||||
getSelection: () => MOCK
|
getSelection: () => MOCK
|
||||||
? Promise.resolve<ReflectionSelection>({ kind: null, id: 0, name: '' })
|
? Promise.resolve<ReflectionSelection>({ kind: null, id: 0, name: '' })
|
||||||
: get<ReflectionSelection>('/selection'),
|
: get<ReflectionSelection>('/selection'),
|
||||||
@@ -685,7 +753,7 @@ export const reflectionApi = {
|
|||||||
: post<{ path: string }>('/car-style/save')
|
: post<{ path: string }>('/car-style/save')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与 SimpleLite 视口样式对话框 / Configuration.conf.viewport 同步。 */
|
/** 与 Simple3 视口样式对话框 / Configuration.conf.viewport 同步。 */
|
||||||
export interface ViewportStyleSite {
|
export interface ViewportStyleSite {
|
||||||
drawScale: number
|
drawScale: number
|
||||||
dotRadiusNormal: 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 VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
|
||||||
|
|
||||||
|
/** 地图监控车辆列表快捷动作 */
|
||||||
|
export type VehicleQuickAction =
|
||||||
|
| 'online'
|
||||||
|
| 'offline'
|
||||||
|
| 'endTask'
|
||||||
|
| 'disable'
|
||||||
|
| 'enable'
|
||||||
|
|
||||||
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
|
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
|
||||||
online: 'OnlineCar',
|
online: 'OnlineCar',
|
||||||
offline: 'OfflineCar',
|
offline: 'OfflineCar',
|
||||||
@@ -9,6 +18,29 @@ const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
|
|||||||
blown: 'Blown'
|
blown: 'Blown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 结束任务:按车型依次尝试(Dummy=ForceStop,Clumsy=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(
|
export async function setVehicleMaintenance(
|
||||||
carId: number,
|
carId: number,
|
||||||
mode: VehicleMaintenanceMode
|
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 {
|
export function openOnboardWeb(url?: string | null, ip?: string | null): void {
|
||||||
const target = url ?? (ip ? `http://${ip}:8081` : null)
|
const target = url ?? (ip ? `http://${ip}:8081` : null)
|
||||||
if (!target) return
|
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 = {
|
let mockProfile: DeploymentProfileDto = {
|
||||||
configured: false,
|
configured: false,
|
||||||
platformType: 'standard',
|
platformType: 'standard',
|
||||||
@@ -61,7 +79,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
|
|||||||
navigationKinds: req.navigationKinds ?? [],
|
navigationKinds: req.navigationKinds ?? [],
|
||||||
scenarios: req.scenarios ?? [],
|
scenarios: req.scenarios ?? [],
|
||||||
configured: true,
|
configured: true,
|
||||||
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
|
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [], req.scenarios ?? [])
|
||||||
}
|
}
|
||||||
return mockProfile
|
return mockProfile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import http from './http'
|
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
|
* `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`) 提供 8 个按钮,平台 iframe
|
||||||
* 嵌入时该底栏会盖在画布上影响交互。本会话把这条底栏整体迁到 Vue 端
|
* 嵌入时该底栏会盖在画布上影响交互。本会话把这条底栏整体迁到 Vue 端
|
||||||
* `WorkspaceCanvasToolbar.vue` 渲染,状态读写改走该 HTTP API。
|
* `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'
|
const BASE = '/sl/projection/toolbar'
|
||||||
@@ -155,7 +155,7 @@ export const workspaceToolbarApi = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次性把地图相机定位(居中 + 2D 俯视)到指定车辆,不开启持续跟随。
|
* 一次性把地图相机定位(居中 + 2D 俯视)到指定车辆,不开启持续跟随。
|
||||||
* 对应 SimpleLite `WorkspaceToolbarApiController.LocateCamera`(与原生「双击车辆行 = 选中+定位」一致)。
|
* 对应 Simple3 `WorkspaceToolbarApiController.LocateCamera`(与原生「双击车辆行 = 选中+定位」一致)。
|
||||||
*/
|
*/
|
||||||
locateCamera: (carId: number) =>
|
locateCamera: (carId: number) =>
|
||||||
unwrap<ToolbarState>(
|
unwrap<ToolbarState>(
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<PermissionGuard widget-id="ConfigCenter">
|
<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>
|
<template #header>
|
||||||
<div class="cpb-header">
|
<div class="cpb-header">
|
||||||
<span class="cpb-title">{{ title }}</span>
|
<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>
|
<el-tag size="small" type="success" effect="plain" v-if="envelope?.updatedAt">{{ relativeTime }}</el-tag>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -50,11 +62,11 @@ const props = defineProps<{
|
|||||||
defaults: T
|
defaults: T
|
||||||
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
|
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
|
||||||
normalizePayload?: (payload: T) => T
|
normalizePayload?: (payload: T) => T
|
||||||
/** 加载完成后二次合并(如从 SimpleLite 拉 monitor) */
|
/** 加载完成后二次合并(如从 Simple3 拉 monitor) */
|
||||||
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
|
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
|
||||||
/** 写入 Platform 之前先执行(如先落盘 SimpleLite monitor) */
|
/** 写入 Platform 之前先执行(如先落盘 Simple3 monitor) */
|
||||||
beforeSave?: (payload: T) => void | Promise<void>
|
beforeSave?: (payload: T) => void | Promise<void>
|
||||||
/** 保存成功后副作用(如再次同步 SimpleLite) */
|
/** 保存成功后副作用(如再次同步 Simple3) */
|
||||||
afterSave?: (payload: T) => void | Promise<void>
|
afterSave?: (payload: T) => void | Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -98,7 +110,8 @@ async function reload(force = false) {
|
|||||||
await reloadFromServer(force, force)
|
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
|
saving.value = true
|
||||||
let body = payload.value
|
let body = payload.value
|
||||||
if (props.normalizePayload) body = props.normalizePayload(body)
|
if (props.normalizePayload) body = props.normalizePayload(body)
|
||||||
@@ -109,7 +122,7 @@ async function save() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
|
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
saving.value = false
|
saving.value = false
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,14 +135,16 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
await props.afterSave(body)
|
await props.afterSave(body)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
monitorSyncWarn = `(平台配置已保存,但 SimpleLite 同步失败:${e instanceof Error ? e.message : String(e)})`
|
monitorSyncWarn = `(平台配置已保存,但 Simple3 同步失败:${e instanceof Error ? e.message : String(e)})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await reloadFromServer(true, false)
|
await reloadFromServer(true, false)
|
||||||
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 SimpleLite' : ''
|
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 Simple3' : ''
|
||||||
ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
|
if (showToast) ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user