Compare commits
29
Commits
bdcd88608c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0788fad74a | ||
|
|
3968f5dc1f | ||
|
|
c9e835aaf7 | ||
|
|
c5d5f7a726 | ||
|
|
59ccd7934a | ||
|
|
79c9e36d89 | ||
|
|
4180140ae4 | ||
|
|
3686abdc78 | ||
|
|
912c44c9bb | ||
|
|
4962d768d4 | ||
|
|
95205cbcde | ||
|
|
4ee6379c26 | ||
|
|
c5c1578696 | ||
|
|
134c7480b3 | ||
|
|
5d277cf17d | ||
|
|
125372b157 | ||
|
|
1f72488a8d | ||
|
|
bc3e9c5172 | ||
|
|
51c3fc1994 | ||
|
|
7af29fe07f | ||
|
|
beb6563060 | ||
|
|
8299c3dc5c | ||
|
|
b1dd4f9005 | ||
|
|
ef63dda228 | ||
|
|
95c1a74122 | ||
|
|
6986370e9d | ||
|
|
7056cf0872 | ||
|
|
4223a572c5 | ||
|
|
f4f4cd1d1b |
+12
-1
@@ -13,6 +13,8 @@ MiGu.Server/data/*.db
|
||||
MiGu.Server/data/*.db-shm
|
||||
MiGu.Server/data/*.db-wal
|
||||
MiGu.Server/data/.internal-token
|
||||
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
|
||||
MiGu.Server/data/ota/
|
||||
|
||||
# 前端构建产物
|
||||
MiGu.Server/wwwroot/
|
||||
@@ -20,6 +22,8 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
|
||||
|
||||
# 临时构建输出
|
||||
.tmp-build*/
|
||||
_build_out.txt
|
||||
.tools/
|
||||
|
||||
# SimpleLite 运行目录
|
||||
/SimpleLite/
|
||||
@@ -30,5 +34,12 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
|
||||
*.swp
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
/.cursor/rules
|
||||
/.cursor/
|
||||
/MiGu.Server/wwwroot
|
||||
/MiGu.Server/data/platform.db
|
||||
|
||||
# 临时脚本 / 工具产物(不应入库)
|
||||
.codex-temp/
|
||||
frontends/apps/simple-platform-vue/imgui.ini
|
||||
/.cursor/plans
|
||||
/tmp-*.json
|
||||
|
||||
+116
-116
@@ -1,10 +1,10 @@
|
||||
# SimpleLite 三端系统架构设计
|
||||
# Simple3 三端系统架构设计
|
||||
|
||||
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。SimpleLite 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
|
||||
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。Simple3 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
|
||||
|
||||
> 文档版本:v1.7.0
|
||||
> 更新日期:2026-05-20
|
||||
> 范围:SimpleLite(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 SimpleLite**)
|
||||
> 范围:Simple3(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 Simple3**)
|
||||
|
||||
> v1.7.0 变更(多主题色板 · 工业紫默认):
|
||||
> - **6 套可切换主题**:`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
|
||||
@@ -37,13 +37,13 @@
|
||||
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
|
||||
|
||||
> v1.4 变更(保留):
|
||||
> - **明确进程编排归属**:Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 SimpleLite 内置 `SystemMission`(`StartPlatformMission`)拉起、被 SimpleLite Bootstrapper 守护的**受控子进程**;RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
|
||||
> - **明确进程编排归属**:Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 Simple3 内置 `SystemMission`(`StartPlatformMission`)拉起、被 Simple3 Bootstrapper 守护的**受控子进程**;RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
|
||||
> - **整体定位章节重写**:明确"两类后端可执行文件 + 一个进程编排者"的关系,避免读者误以为 Platform 是用 Windows Service / systemd 独立部署的服务。
|
||||
|
||||
> v1.3 变更(保留):
|
||||
> 1. **取消 RCSMonitor.Server**:`rcsmonitor-vue` 不再有独立后端进程,直接连 MiGu.Server;由 JWT 中的 `scope=RCSMonitor` + 角色权限码 + `WidgetGrant` 三层限制可见控件与可调用 API。
|
||||
> 2. **3D 渲染基座定为 webVRender**:复用 SimpleLite WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
|
||||
> 3. **反向代理选定 YARP**:MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → SimpleLite WebAPI 的反代;带鉴权与审计中间件。
|
||||
> 2. **3D 渲染基座定为 webVRender**:复用 Simple3 WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
|
||||
> 3. **反向代理选定 YARP**:MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → Simple3 WebAPI 的反代;带鉴权与审计中间件。
|
||||
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
|
||||
> 5. **Web-Enabled 下严禁同时本地操作**:CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
|
||||
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
|
||||
@@ -79,43 +79,43 @@
|
||||
|
||||
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
|
||||
|
||||
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 SimpleLite 统一编排:**
|
||||
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 Simple3 统一编排:**
|
||||
|
||||
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|
||||
|----|----------|----------------|------------|------|--------|
|
||||
| **SimpleLite 桌面端** | CycleGUI 客户端 | `SimpleLite.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission)** | **全功能** |
|
||||
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起**(Web-Enabled 时自动 spawn;崩溃由 SimpleLite Watchdog 重启) | **SimpleLite 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 SimpleLite) |
|
||||
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 SimpleLite 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
|
||||
| **Simple3 桌面端** | CycleGUI 客户端 | `Simple3.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission)** | **全功能** |
|
||||
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **Simple3 内置 `SystemMission.StartPlatformMission` 拉起**(Web-Enabled 时自动 spawn;崩溃由 Simple3 Watchdog 重启) | **Simple3 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 Simple3) |
|
||||
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 Simple3 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
|
||||
|
||||
**关键澄清(v1.4 重点):**
|
||||
|
||||
- **后端可执行文件只有 2 个**:`SimpleLite.exe`、`MiGu.Server.exe`。
|
||||
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `SimpleLite.exe`,MiGu.Server 由 SimpleLite 自己拉起,**不需要也不允许独立安装为 Windows Service**。
|
||||
- **进程编排归属 SimpleLite**:`SystemMission` 是 SimpleLite 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
|
||||
- **后端可执行文件只有 2 个**:`Simple3.exe`、`MiGu.Server.exe`。
|
||||
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `Simple3.exe`,MiGu.Server 由 Simple3 自己拉起,**不需要也不允许独立安装为 Windows Service**。
|
||||
- **进程编排归属 Simple3**:`SystemMission` 是 Simple3 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
|
||||
- **Vue 两份产物,同一进程托管**:MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`。
|
||||
- **鉴权与限权完全在 MiGu.Server 完成**:RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
|
||||
- **Desktop-Only 模式下,SystemMission 不触发**:MiGu.Server 根本不会启动,Vue 端无法访问。
|
||||
|
||||
> 一句话:**用户点 SimpleLite.exe,由 SimpleLite 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
|
||||
> 一句话:**用户点 Simple3.exe,由 Simple3 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
|
||||
|
||||
### 1.2 核心拆分原则
|
||||
|
||||
1. **Crash-Isolation(搞不崩主服务)**
|
||||
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 SimpleLite 的 `/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`。
|
||||
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 Simple3 的 `/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`。
|
||||
|
||||
2. **共享数据模型,独立运行时**
|
||||
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 SimpleLite 里跑,Platform 只持有"投影"。
|
||||
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 Simple3 里跑,Platform 只持有"投影"。
|
||||
|
||||
3. **库分离 + Provider 抽象**(详见 §7)
|
||||
- `simple_main` 由 SimpleLite 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`。
|
||||
- `simple_main` 由 Simple3 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`。
|
||||
- 持久层基于 **EF Core**,**通过 Provider 切换底层数据库**:默认 **SQLite**(边缘单机,配 ROSE HA);可选 **MySQL / PostgreSQL / SQL Server**,匹配现场已有 IT 基础设施与企业级 HA 能力(详见 §7.4–§7.7)。
|
||||
- 高可用方案随 Provider 不同:SQLite 用 ROSE HA;MySQL 用 InnoDB Cluster;PostgreSQL 用 Patroni;SQL Server 用 Always On AG。
|
||||
|
||||
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. **统一身份**
|
||||
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
|
||||
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),Simple3 在 Desktop-Only 下用本地用户表降级。
|
||||
|
||||
6. **运行模式互斥(强约束)**
|
||||
- **Desktop-Only**:CycleGUI 客户端独占;WebAPI / Platform **不启动**。
|
||||
@@ -139,7 +139,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Host["主控宿主机 / 边缘服务器"]
|
||||
subgraph SL["SimpleLite 主进程 (CycleGUI + ASP.NET Core 8)"]
|
||||
subgraph SL["Simple3 主进程 (CycleGUI + ASP.NET Core 8)"]
|
||||
direction TB
|
||||
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
|
||||
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
|
||||
@@ -155,7 +155,7 @@ flowchart TB
|
||||
direction TB
|
||||
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
|
||||
PL_API["Platform WebAPI<br/>(配置中心 / 外部对接 / 库位 / 账号)"]
|
||||
PL_YARP["YARP 反代<br/>/api/sl/* → SimpleLite WebAPI<br/>+ Auth/审计中间件"]
|
||||
PL_YARP["YARP 反代<br/>/api/sl/* → Simple3 WebAPI<br/>+ Auth/审计中间件"]
|
||||
PL_Auth["SimpleAuth<br/>(JWT 签发 + Scope 校验)"]
|
||||
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
|
||||
PL_DB[("SQLite: platform.db<br/>WAL")]
|
||||
@@ -203,11 +203,11 @@ flowchart TB
|
||||
|
||||
**要点:**
|
||||
|
||||
- **只有两个后端可执行文件**:`SimpleLite.exe` 与 `MiGu.Server.exe`;RCSMonitor 不再有独立后端。
|
||||
- **`MiGu.Server.exe` 由 SimpleLite 的 SystemMission 拉起**:用户只启动 SimpleLite;Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
|
||||
- **只有两个后端可执行文件**:`Simple3.exe` 与 `MiGu.Server.exe`;RCSMonitor 不再有独立后端。
|
||||
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3;Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
|
||||
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
|
||||
- **`PL_YARP` 把 `/api/sl/*` 反代到 SimpleLite**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
|
||||
- **3D 由 SimpleLite 的 webVRender 提供**:Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
|
||||
- **`PL_YARP` 把 `/api/sl/*` 反代到 Simple3**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
|
||||
- **3D 由 Simple3 的 webVRender 提供**:Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
|
||||
- **Desktop-Only 模式**:`SL_API`、`PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
|
||||
- **Web-Enabled 模式**:CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
|
||||
|
||||
@@ -219,8 +219,8 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([SimpleLite.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
|
||||
CheckPrev -- 否 --> Login[弹出登录窗<br/>SimpleLite Boot Login]
|
||||
Start([Simple3.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
|
||||
CheckPrev -- 否 --> Login[弹出登录窗<br/>Simple3 Boot Login]
|
||||
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
|
||||
Login --> Form
|
||||
AutoLogin --> ModeSwitch
|
||||
@@ -269,7 +269,7 @@ flowchart TB
|
||||
|
||||
| 元素 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 标题 | Label | "SimpleLite · 启动登录" |
|
||||
| 标题 | Label | "Simple3 · 启动登录" |
|
||||
| 用户名 | TextInput | 默认上次登录用户 |
|
||||
| 密码 | TextInput (password) | — |
|
||||
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
|
||||
@@ -281,7 +281,7 @@ flowchart TB
|
||||
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
|
||||
| 退出按钮 | Button | 不进入主程序,退出进程 |
|
||||
|
||||
> 实现位置:新增 `SimpleLite/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
|
||||
> 实现位置:新增 `Simple3/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
|
||||
|
||||
#### simple.json 增量字段
|
||||
|
||||
@@ -310,18 +310,18 @@ flowchart TB
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
|
||||
| Desktop-Only → Web-Enabled | **必须退出 SimpleLite 重新登录**;不支持热切(避免半启动状态) |
|
||||
| Desktop-Only → Web-Enabled | **必须退出 Simple3 重新登录**;不支持热切(避免半启动状态) |
|
||||
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
|
||||
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
|
||||
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
|
||||
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `SimpleLite.exe --re-login` 强制弹出登录窗 |
|
||||
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `Simple3.exe --re-login` 强制弹出登录窗 |
|
||||
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset` 把 `rememberRunMode` 置为 `false`,**仅在下次重启时生效** |
|
||||
|
||||
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────┐
|
||||
│ SimpleLite Service Status │
|
||||
│ Simple3 Service Status │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 模式: Web-Enabled │
|
||||
│ 启动时间: 2026-05-18 09:00:00 │
|
||||
@@ -348,7 +348,7 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["SimpleLite.exe<br/>启动 + 登录"] --> Mode{运行模式}
|
||||
A["Simple3.exe<br/>启动 + 登录"] --> Mode{运行模式}
|
||||
|
||||
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
|
||||
D --> SchedD[调度引擎]
|
||||
@@ -360,7 +360,7 @@ flowchart LR
|
||||
WHost --> WV[WebTerminal :8223<br/>webVRender]
|
||||
WHost --> StatusPanel[CycleGUI 状态窗]
|
||||
|
||||
subgraph SysMis["SimpleLite SystemMission 体系"]
|
||||
subgraph SysMis["Simple3 SystemMission 体系"]
|
||||
E["StartPlatformMission<br/>(SystemMission 子类)"]
|
||||
SM_Reg["SystemMissionRegistry"]
|
||||
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
|
||||
@@ -380,24 +380,24 @@ flowchart LR
|
||||
|
||||
### 4.2 SystemMission 体系(v1.4 新增章节)
|
||||
|
||||
SimpleLite 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
|
||||
Simple3 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
|
||||
|
||||
**关键类:**
|
||||
|
||||
| 类型 | 类型 / 命名空间 | 职责 |
|
||||
|------|------------------|------|
|
||||
| `SystemMission`(抽象) | `SimpleLite.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
|
||||
| `StartPlatformMission` | `SimpleLite.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
|
||||
| `SystemMissionRegistry` | `SimpleLite.Bootstrap` | 单例,注册所有 SystemMission;Web-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
|
||||
| `ProcessHost` | `SimpleLite.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
|
||||
| `HeartbeatChannel` | `SimpleLite.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`;3 个周期未达 → 标记不健康 |
|
||||
| `ProcessWatchdog` | `SimpleLite.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
|
||||
| `RunMode`(枚举) | `SimpleLite.Bootstrap` | `DesktopOnly` / `WebEnabled` |
|
||||
| `SystemMission`(抽象) | `Simple3.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
|
||||
| `StartPlatformMission` | `Simple3.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
|
||||
| `SystemMissionRegistry` | `Simple3.Bootstrap` | 单例,注册所有 SystemMission;Web-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
|
||||
| `ProcessHost` | `Simple3.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
|
||||
| `HeartbeatChannel` | `Simple3.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`;3 个周期未达 → 标记不健康 |
|
||||
| `ProcessWatchdog` | `Simple3.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
|
||||
| `RunMode`(枚举) | `Simple3.Bootstrap` | `DesktopOnly` / `WebEnabled` |
|
||||
|
||||
**生命周期:**
|
||||
|
||||
```text
|
||||
SimpleLite 启动 (Web-Enabled)
|
||||
Simple3 启动 (Web-Enabled)
|
||||
├─ Kestrel Up :7001/7002
|
||||
├─ WebTerminal Up :8223
|
||||
├─ SchedulerRuntime.Start()
|
||||
@@ -414,7 +414,7 @@ SimpleLite 启动 (Web-Enabled)
|
||||
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
|
||||
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
|
||||
|
||||
SimpleLite 优雅停机:
|
||||
Simple3 优雅停机:
|
||||
SystemMissionRegistry.StopAll()
|
||||
└─ StartPlatformMission.Stop()
|
||||
├─ 发送 SIGTERM 等价(命名管道 "shutdown" 消息)
|
||||
@@ -424,26 +424,26 @@ SimpleLite 优雅停机:
|
||||
|
||||
**实现要点:**
|
||||
|
||||
- **位置**:新建 `SimpleLite/Bootstrap/` 与 `SimpleLite/RCS/SystemMissions/`。
|
||||
- **位置**:新建 `Simple3/Bootstrap/` 与 `Simple3/RCS/SystemMissions/`。
|
||||
- **不写入主库 Missions 表**:SystemMission 是进程级配置,不参与项目序列化;存储在 `simple_main.db.SystemMissionConfig`(或直接读 `simple.json`)。
|
||||
- **崩溃隔离**:Platform 异常不影响 SimpleLite 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
|
||||
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 SimpleLite 唯一;MiGu.Server 启动时校验"父进程是否为 SimpleLite",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
|
||||
- **崩溃隔离**:Platform 异常不影响 Simple3 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
|
||||
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 Simple3 唯一;MiGu.Server 启动时校验"父进程是否为 Simple3",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
|
||||
- **静态资源**:MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 三端职责矩阵
|
||||
|
||||
| 维度 | SimpleLite 桌面端 | **Platform 管理端(platform-vue)** | **RCSMonitor 运营端(rcsmonitor-vue)** |
|
||||
| 维度 | Simple3 桌面端 | **Platform 管理端(platform-vue)** | **RCSMonitor 运营端(rcsmonitor-vue)** |
|
||||
|------|---------------------|------------------------------------------|------------------------------------------|
|
||||
| 地图(站点/轨道/区域)编辑 | **读写**(Desktop-Only) | **全功能读写**(Vue 地图控件内嵌SimpleLite地图) | 只读投影,不可编辑 |
|
||||
| 地图(站点/轨道/区域)编辑 | **读写**(Desktop-Only) | **全功能读写**(Vue 地图控件内嵌Simple3地图) | 只读投影,不可编辑 |
|
||||
| 任务编排(Mission/Recipe) | **读写** | **全功能读写**(含 Demand / Recipe) | 基础动作:暂停/取消/重派/优先级 |
|
||||
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
|
||||
| CAD 工具 | **可用** | **可用**(Vue 调用 `/api/sl/cad/*`) | 不可用 |
|
||||
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
|
||||
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
|
||||
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
|
||||
| 3D 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
|
||||
| 3D 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 Simple3 webVRender**(全功能交互) | **嵌入 Simple3 webVRender**(只读 + 圈选) |
|
||||
| 系统级配置 | 局部(Desktop-Only) | **全局编辑 + 下发** | 不可编辑 |
|
||||
| 外部对接(MES/WMS/RCS) | 不涉及 | **唯一入口** | 不涉及 |
|
||||
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
|
||||
@@ -549,7 +549,7 @@ Platform 将典型业务场景抽象为标准模板,支持开箱即用和后
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 顶层["顶层(按端隔离)"]
|
||||
L1A["SimpleLite (CycleGUI + WebAPI Host)"]
|
||||
L1A["Simple3 (CycleGUI + WebAPI Host)"]
|
||||
L1B["MiGu.Server (ASP.NET Core + YARP)"]
|
||||
L1B2["platform-vue (SPA, 管理员)"]
|
||||
L1B3["rcsmonitor-vue (SPA, 运营)"]
|
||||
@@ -608,17 +608,17 @@ flowchart TB
|
||||
| `SimpleShared.Persistence` | net8.0 | EF Core 8 多 Provider 抽象(**SQLite / MySQL / PostgreSQL / SQL Server**)+ 迁移 + `DbProviderOptions` + 连接串构建器 + `WalSqliteOptions`(仅 SQLite) |
|
||||
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
|
||||
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
|
||||
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 SimpleLite** + 静态托管两份 Vue dist;**启动时校验父进程为 SimpleLite,否则拒绝运行**(防误用) |
|
||||
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 SimpleLite 所有控件 |
|
||||
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 Simple3** + 静态托管两份 Vue dist;**启动时校验父进程为 Simple3,否则拒绝运行**(防误用) |
|
||||
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 Simple3 所有控件 |
|
||||
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
|
||||
| `packages/sl-controls`(pnpm workspace) | Vue 3 组件库 | Workspace3D(嵌 webVRender)/ 地图列表 / 任务表 / CAD 工具栏等共享组件 |
|
||||
|
||||
### 6.1 Platform Vue 控件复刻清单(与 SimpleLite CycleGUI 对照)
|
||||
### 6.1 Platform Vue 控件复刻清单(与 Simple3 CycleGUI 对照)
|
||||
|
||||
| SimpleLite CycleGUI 面板 | Vue 组件(位于 `sl-controls`) | 备注 |
|
||||
| Simple3 CycleGUI 面板 | Vue 组件(位于 `sl-controls`) | 备注 |
|
||||
|--------------------------|--------------------------------|------|
|
||||
| 主菜单栏 / 工具栏 | `<AppShell>` 顶栏 + 侧栏 | platform-vue 全功能;rcsmonitor-vue 仅显示运营菜单 |
|
||||
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 SimpleLite webVRender**;交互事件用 `postMessage` 桥接 |
|
||||
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 Simple3 webVRender**;交互事件用 `postMessage` 桥接 |
|
||||
| 地图面板(Sites / Tracks) | `<MapEditor>` + `<TrackTable>` | 表格 + 拓扑视图 |
|
||||
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
|
||||
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
|
||||
@@ -674,7 +674,7 @@ function onReady() {
|
||||
</script>
|
||||
```
|
||||
|
||||
`SimpleLite/Rendering/SimpleSceneRenderer.cs` 端按 `scope` 和 `ro` 控制:
|
||||
`Simple3/Rendering/SimpleSceneRenderer.cs` 端按 `scope` 和 `ro` 控制:
|
||||
- `scope=RCSMonitor` 或 `ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
|
||||
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
|
||||
|
||||
@@ -690,7 +690,7 @@ builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(ctx =>
|
||||
{
|
||||
// 透传当前 JWT 的 userId / scope 给 SimpleLite
|
||||
// 透传当前 JWT 的 userId / scope 给 Simple3
|
||||
ctx.AddRequestTransform(async tc =>
|
||||
{
|
||||
var ident = tc.HttpContext.User;
|
||||
@@ -767,7 +767,7 @@ app.Run();
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph App["应用代码 (SimpleLite / MiGu.Server)"]
|
||||
subgraph App["应用代码 (Simple3 / MiGu.Server)"]
|
||||
DOM["领域服务 / Repository"]
|
||||
EFC["EF Core 8 DbContext"]
|
||||
OPT["DbProviderOptions<br/>{ provider, conn, ha }"]
|
||||
@@ -813,7 +813,7 @@ flowchart TB
|
||||
|
||||
| 逻辑库 | 拥有进程 | 主要表/数据 |
|
||||
|--------|----------|-------------|
|
||||
| **simple_main** | SimpleLite | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
|
||||
| **simple_main** | Simple3 | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
|
||||
| **platform** | Platform | Users, Roles, RolePermissions, WidgetGrants, SystemConfig, ExternalAdapters, RoutingPolicies, VehicleMaintenancePolicies, ChargePolicies, TaskAllocationPolicies, TrafficRules, Locations, Inventory, OpsLogs(含 RCSMonitor 来源), Annotations, Snapshots, Versions, CustomWidgets |
|
||||
|
||||
> 原 v1.2 中的 `rcsmonitor_local` 取消;其表(OpsLogs / Annotations / Snapshots / WidgetLayoutCache)合并到 `platform`,加 `source='RCSMonitor'` 字段区分。
|
||||
@@ -824,12 +824,12 @@ flowchart TB
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph A["主节点 NODE-A (Active)"]
|
||||
SL_A["SimpleLite.exe"]
|
||||
SL_A["Simple3.exe"]
|
||||
PL_A["MiGu.Server.exe"]
|
||||
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
|
||||
end
|
||||
subgraph B["备节点 NODE-B (Standby)"]
|
||||
SL_B["SimpleLite.exe (suspended)"]
|
||||
SL_B["Simple3.exe (suspended)"]
|
||||
PL_B["MiGu.Server.exe (suspended)"]
|
||||
D_B[("ROSE 镜像盘 R:\")]
|
||||
end
|
||||
@@ -860,7 +860,7 @@ flowchart TB
|
||||
|------|------|------|
|
||||
| `RES_DISK_R` | Mirror Disk | — |
|
||||
| `RES_VIP_LAN` | Virtual IP | RES_DISK_R |
|
||||
| `RES_SVC_SL` | Service: SimpleLite (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
|
||||
| `RES_SVC_SL` | Service: Simple3 (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
|
||||
| `RES_SVC_PL` | Service: MiGu.Server | RES_DISK_R, RES_VIP_LAN, RES_SVC_SL |
|
||||
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
|
||||
|
||||
@@ -871,7 +871,7 @@ flowchart TB
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph App["应用层 (VIP + 重试)"]
|
||||
SL["SimpleLite.exe"]
|
||||
SL["Simple3.exe"]
|
||||
PL["MiGu.Server.exe"]
|
||||
end
|
||||
subgraph Router["MySQL Router (或 ProxySQL)"]
|
||||
@@ -911,7 +911,7 @@ flowchart TB
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph App["应用层"]
|
||||
SL["SimpleLite.exe"]
|
||||
SL["Simple3.exe"]
|
||||
PL["MiGu.Server.exe"]
|
||||
end
|
||||
subgraph LB["HAProxy / PgBouncer"]
|
||||
@@ -953,7 +953,7 @@ flowchart TB
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph App["应用层"]
|
||||
SL["SimpleLite.exe"]
|
||||
SL["Simple3.exe"]
|
||||
PL["MiGu.Server.exe"]
|
||||
end
|
||||
subgraph Listener["AG Listener (DNS + VIP)"]
|
||||
@@ -983,7 +983,7 @@ flowchart TB
|
||||
- **EF Core Provider**:`Microsoft.EntityFrameworkCore.SqlServer`;支持 SQL Server 2019+(建议 Enterprise 版以启用同步提交副本数 ≥3 + Read-Only Routing)。Standard 版可降级为 2 副本基本可用性组。
|
||||
- **HA 形态**:**WSFC(Windows Server Failover Cluster)+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
|
||||
- **同步策略**:核心库(`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 已能避免。
|
||||
- **备份**:`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
|
||||
- **RTO / RPO**:RTO ≈ 10–30s(AG 自动 failover);RPO ≈ 0(同步提交副本)。
|
||||
@@ -1091,7 +1091,7 @@ flowchart TB
|
||||
"database": "simple_main",
|
||||
"user": "simple_app",
|
||||
"password": "${SECRET_MSSQL_PWD}",
|
||||
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=SimpleLite"
|
||||
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=Simple3"
|
||||
},
|
||||
"platform": {
|
||||
"server": "tcp:rcs-ag.lan,1433",
|
||||
@@ -1158,7 +1158,7 @@ classDiagram
|
||||
|
||||
**落地四件套:**
|
||||
|
||||
1. **认证**:`SimpleShared.Auth` 提供 `JwtIssuer`;Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 SimpleLite 本地签发短期 Token(24h)。
|
||||
1. **认证**:`SimpleShared.Auth` 提供 `JwtIssuer`;Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 Simple3 本地签发短期 Token(24h)。
|
||||
2. **授权**:所有 WebAPI + WS 调用走 `IPermissionEvaluator`;权限码 `<area>.<action>`。
|
||||
3. **范围(Scope)**:`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope`(`/admin/*` → `Platform`,`/monitor/*` → `RCSMonitor`)。
|
||||
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
|
||||
@@ -1173,7 +1173,7 @@ sequenceDiagram
|
||||
participant U as 运营用户
|
||||
participant RV as rcsmonitor-vue
|
||||
participant P as MiGu.Server
|
||||
participant L as SimpleLite (YARP 后侧)
|
||||
participant L as Simple3 (YARP 后侧)
|
||||
participant DBp as platform.db
|
||||
|
||||
U->>RV: 访问 https://host:8080/monitor
|
||||
@@ -1255,7 +1255,7 @@ flowchart LR
|
||||
UI11 --> Sch
|
||||
Sch --> Val --> Ver --> Pub
|
||||
|
||||
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["SimpleLite (主服务)"]
|
||||
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["Simple3 (主服务)"]
|
||||
Pub -- "WS broadcast: config.updated" --> RMVue["rcsmonitor-vue"]
|
||||
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
|
||||
```
|
||||
@@ -1316,7 +1316,7 @@ public record EffectivePermissions(
|
||||
List<WidgetGrantDto> VisibleWidgets);
|
||||
```
|
||||
|
||||
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 SimpleLite,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
|
||||
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 Simple3,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
|
||||
|
||||
---
|
||||
|
||||
@@ -1327,14 +1327,14 @@ public record EffectivePermissions(
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant L as SimpleLite (CycleGUI)
|
||||
participant L as Simple3 (CycleGUI)
|
||||
participant BL as BootLoginPanel
|
||||
participant K as Kestrel (WebAPI)
|
||||
participant P as MiGu.Server (YARP)
|
||||
participant V as platform-vue
|
||||
participant RV as rcsmonitor-vue
|
||||
|
||||
U->>L: 启动 SimpleLite.exe
|
||||
U->>L: 启动 Simple3.exe
|
||||
L->>L: 读取 simple.json
|
||||
alt rememberRunMode = true
|
||||
L->>L: 直接采用 runMode (仍鉴权)
|
||||
@@ -1373,10 +1373,10 @@ sequenceDiagram
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant L as SimpleLite
|
||||
participant L as Simple3
|
||||
participant BL as BootLoginPanel
|
||||
|
||||
U->>L: 启动 SimpleLite.exe
|
||||
U->>L: 启动 Simple3.exe
|
||||
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
|
||||
L->>BL: 必要时弹出登录窗
|
||||
U->>BL: 输入凭据 + 选 Desktop-Only
|
||||
@@ -1387,13 +1387,13 @@ sequenceDiagram
|
||||
L-->>U: 进入设计端主界面
|
||||
```
|
||||
|
||||
### 10.3 platform-vue 编辑地图站点 → SimpleLite 落库
|
||||
### 10.3 platform-vue 编辑地图站点 → Simple3 落库
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant V as platform-vue
|
||||
participant P as MiGu.Server (YARP)
|
||||
participant L as SimpleLite (WebAPI)
|
||||
participant L as Simple3 (WebAPI)
|
||||
participant DBl as simple_main.db
|
||||
participant RV as rcsmonitor-vue (订阅)
|
||||
|
||||
@@ -1406,7 +1406,7 @@ sequenceDiagram
|
||||
P-->>V: 201 + siteId
|
||||
L->>L: WS broadcast { type: "map.site.created", payload }
|
||||
L-->>P: WS event
|
||||
P-->>V: WS event (前端 Workspace3D iframe 收到 SimpleLite 推送的 PutModelObject 同步)
|
||||
P-->>V: WS event (前端 Workspace3D iframe 收到 Simple3 推送的 PutModelObject 同步)
|
||||
P-->>RV: WS event (RCSMonitor 接收并按权限展示)
|
||||
```
|
||||
|
||||
@@ -1416,7 +1416,7 @@ sequenceDiagram
|
||||
sequenceDiagram
|
||||
participant V as platform-vue
|
||||
participant P as Platform
|
||||
participant L as SimpleLite
|
||||
participant L as Simple3
|
||||
participant E as DispatchLoop
|
||||
participant RV as rcsmonitor-vue
|
||||
|
||||
@@ -1440,7 +1440,7 @@ sequenceDiagram
|
||||
participant U as 运营人员
|
||||
participant RV as rcsmonitor-vue
|
||||
participant P as MiGu.Server (YARP)
|
||||
participant L as SimpleLite (Ops 网关)
|
||||
participant L as Simple3 (Ops 网关)
|
||||
participant E as DispatchLoop
|
||||
participant DBp as platform.db
|
||||
participant DBl as simple_main.db
|
||||
@@ -1476,10 +1476,10 @@ sequenceDiagram
|
||||
VIP->>NA: 路由到 Active
|
||||
Note over NA: 节点故障
|
||||
ROSE->>NA: 心跳超时 → 触发切换
|
||||
ROSE->>NA: 强制停止 SimpleLite / Platform
|
||||
ROSE->>NA: 强制停止 Simple3 / Platform
|
||||
ROSE->>NA: 卸载 R: 卷
|
||||
ROSE->>NB: 挂载 R: 卷
|
||||
ROSE->>NB: 启动 SimpleLite (WAL 重放 + 主库自检)
|
||||
ROSE->>NB: 启动 Simple3 (WAL 重放 + 主库自检)
|
||||
ROSE->>NB: 启动 MiGu.Server
|
||||
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
|
||||
CLI--xVIP: 短暂中断
|
||||
@@ -1495,12 +1495,12 @@ sequenceDiagram
|
||||
|
||||
| 通道 | 协议 | 编码 | 端点 | 用途 |
|
||||
|------|------|------|------|------|
|
||||
| MiGu.Server (YARP) ↔ SimpleLite | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
|
||||
| MiGu.Server (YARP) ↔ Simple3 | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
|
||||
| platform-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/admin`) | UI 操作、事件推送 |
|
||||
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor`,`scope=RCSMonitor`) | UI 操作、事件推送(受限) |
|
||||
| Vue Workspace3D ↔ SimpleLite WebTerminal | WSS(webVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe) |
|
||||
| Vue Workspace3D ↔ Simple3 WebTerminal | WSS(webVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe) |
|
||||
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
|
||||
| Vehicle ↔ SimpleLite | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
|
||||
| Vehicle ↔ Simple3 | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
|
||||
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
|
||||
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
|
||||
|
||||
@@ -1510,7 +1510,7 @@ sequenceDiagram
|
||||
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
|
||||
- 幂等:写操作支持 `Idempotency-Key` 请求头
|
||||
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
|
||||
- Swagger:Platform / SimpleLite 都暴露 `/swagger`(生产可关)
|
||||
- Swagger:Platform / Simple3 都暴露 `/swagger`(生产可关)
|
||||
- **YARP Scope 白名单**:`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*` 与 `/api/sl/projection/*` 命中;其他路径直接 403
|
||||
|
||||
---
|
||||
@@ -1521,12 +1521,12 @@ sequenceDiagram
|
||||
flowchart TB
|
||||
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
|
||||
subgraph NA["NODE-A (Active)"]
|
||||
SL_A["SimpleLite.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
|
||||
SL_A["Simple3.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
|
||||
PL_A["MiGu.Server.exe<br/>:8080/8081<br/>(YARP + 双 Vue dist)"]
|
||||
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
|
||||
end
|
||||
subgraph NB["NODE-B (Standby)"]
|
||||
SL_B["SimpleLite.exe (suspended)"]
|
||||
SL_B["Simple3.exe (suspended)"]
|
||||
PL_B["MiGu.Server.exe (suspended)"]
|
||||
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
|
||||
end
|
||||
@@ -1556,12 +1556,12 @@ flowchart TB
|
||||
|
||||
| 端口 | 服务 | 启用条件 |
|
||||
|------|------|----------|
|
||||
| 7001 | SimpleLite WebAPI | Web-Enabled |
|
||||
| 7002 | SimpleLite WebSocket | Web-Enabled |
|
||||
| 7001 | Simple3 WebAPI | Web-Enabled |
|
||||
| 7002 | Simple3 WebSocket | Web-Enabled |
|
||||
| 8080 | Platform WebAPI + platform-vue 静态(/admin)+ rcsmonitor-vue 静态(/monitor) | Web-Enabled |
|
||||
| 8081 | Platform WebSocket | Web-Enabled |
|
||||
| 8222 | SimpleLite 车端 / API(现有) | 所有模式 |
|
||||
| 8223 | SimpleLite WebTerminal(webVRender,Vue 嵌入用) | Web-Enabled |
|
||||
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
|
||||
| 8223 | Simple3 WebTerminal(webVRender,Vue 嵌入用) | Web-Enabled |
|
||||
|
||||
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
|
||||
|
||||
@@ -1588,7 +1588,7 @@ flowchart LR
|
||||
SHH["SimpleShared.WebApiHost"]
|
||||
end
|
||||
subgraph apps["两端后端"]
|
||||
SL["SimpleLite (CycleGUI + WebAPI)"]
|
||||
SL["Simple3 (CycleGUI + WebAPI)"]
|
||||
PSrv["MiGu.Server (WebAPI + YARP)"]
|
||||
end
|
||||
subgraph frontends["前端 (pnpm workspace)"]
|
||||
@@ -1709,7 +1709,7 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
||||
|--------|----------|----------|
|
||||
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProvider;EF Core + SQLite + 迁移 |
|
||||
| **M1 - 启动登录 + 运行模式** | 1 周 | `BootLoginPanel`;`RunMode` 枚举;CycleGUI 状态窗;`simple.json.runMode` 持久化;Web-Enabled 业务面板硬隐藏 |
|
||||
| **M2 - WebAPI 协议层** | 1.5 周 | SimpleLite 集成 Kestrel;定义 `/api/projection/*`、`/api/ops/*`、`/api/config/*`、`/api/map/*` 等;WS 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 等 |
|
||||
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.Server(WebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist)+ 配置中心 11 个 schema + 用户/角色/控件权限 |
|
||||
| **M5 - platform-vue (管理员)** | 2.5 周 | 全功能套壳:登录、地图设计、车辆/任务、CAD、字段/图层、配置中心、库位、自定义控件、回放、设备管理、车队生命周期、场景模板 |
|
||||
@@ -1726,14 +1726,14 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
||||
|---|------|------|
|
||||
| 1 | 运行模式持久化 | **写入 `simple.json.runMode` / `rememberRunMode`**(不使用 %APPDATA%) |
|
||||
| 2 | Web-Enabled 下是否允许同时本地操作 | **不允许**;CycleGUI 仅状态窗;不提供"临时启用本地 UI"开关 |
|
||||
| 3 | 3D 引擎 | **复用 SimpleLite WebTerminal 的 webVRender**,Vue 通过 iframe / Web Component 嵌入 |
|
||||
| 3 | 3D 引擎 | **复用 Simple3 WebTerminal 的 webVRender**,Vue 通过 iframe / Web Component 嵌入 |
|
||||
| 4 | 反向代理 | **YARP**(MiGu.Server 用 YARP 反代 `/api/sl/*`) |
|
||||
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
|
||||
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
|
||||
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
|
||||
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
|
||||
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
|
||||
| 10 | **Platform / RCSMonitor 启动归属(v1.4)** | **由 SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows 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` 决定 |
|
||||
| 12 | **跨 Provider 切换策略** | 不支持热切;切换走停服窗口 + `tools/db/migrate.ps1`;旧文件作为回退包保留 |
|
||||
|
||||
@@ -1743,22 +1743,22 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
||||
|
||||
| 现有文件 | 改动方向 |
|
||||
|----------|----------|
|
||||
| `SimpleLite/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
|
||||
| `SimpleLite/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode`、`rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
|
||||
| `SimpleLite/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUI;Web-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
|
||||
| `SimpleLite/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
|
||||
| `SimpleLite/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
|
||||
| `SimpleLite/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
|
||||
| `SimpleLite/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry,**不进入 DispatchLoop**) |
|
||||
| `SimpleLite/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
|
||||
| `SimpleLite/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
|
||||
| `SimpleLite/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
|
||||
| `SimpleLite/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
|
||||
| `SimpleLite/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
|
||||
| `SimpleLite/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
|
||||
| `SimpleLite/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
|
||||
| `Simple3/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
|
||||
| `Simple3/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode`、`rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
|
||||
| `Simple3/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUI;Web-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
|
||||
| `Simple3/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
|
||||
| `Simple3/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
|
||||
| `Simple3/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
|
||||
| `Simple3/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry,**不进入 DispatchLoop**) |
|
||||
| `Simple3/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
|
||||
| `Simple3/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
|
||||
| `Simple3/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
|
||||
| `Simple3/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
|
||||
| `Simple3/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
|
||||
| `Simple3/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
|
||||
| `Simple3/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
|
||||
| `SimpleScheduler/WebApi.cs` | 调用方从 Nancy 切到 ASP.NET Core Controller;保持 `SchedulerRuntime` 外观稳定 |
|
||||
| `SimpleLite/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
|
||||
| `Simple3/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
|
||||
| `Plugins/Sample` | 示例插件读取 `IConfigSnapshotProvider`;不再读 `Configuration.conf` |
|
||||
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
|
||||
| 新增 `tools/db/*` | 跨 Provider 迁移脚本(`migrate.ps1` / `dump.ps1` / `restore.ps1`);按目标 DB 校验 schema 与索引 |
|
||||
@@ -1909,12 +1909,12 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
||||
|---|-----|------|------|
|
||||
| B1 | Kestrel + Swagger + CORS | ✅ | `Program.cs` |
|
||||
| B2 | Auth / Health / Config / Ops / Projection 控制器 | ✅ 骨架 | Mock/内存存储 |
|
||||
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 SimpleLite 实际监听 |
|
||||
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 Simple3 实际监听 |
|
||||
| B4 | 静态托管 + SPA fallback | ✅ | 单一 `wwwroot/index.html` |
|
||||
| B5 | JWT 真签发 + 库持久化 | ☐ | 仍为 Mock |
|
||||
| B6 | 父进程 SimpleLite 校验 | ☐ | §4、§15 #10 |
|
||||
| B6 | 父进程 Simple3 校验 | ☐ | §4、§15 #10 |
|
||||
|
||||
### 18.3 SimpleLite / 共享内核(ARCHITECTURE 主体)
|
||||
### 18.3 Simple3 / 共享内核(ARCHITECTURE 主体)
|
||||
|
||||
| # | 项 | 状态 | 备注 |
|
||||
|---|-----|------|------|
|
||||
@@ -1942,5 +1942,5 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|
||||
|
||||
## 关联文档
|
||||
|
||||
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — SimpleLite 功能对齐执行计划
|
||||
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — Simple3 功能对齐执行计划
|
||||
- [SIMPLELITE_COMPOSER_PARITY_PLAN.md](./SIMPLELITE_COMPOSER_PARITY_PLAN.md) — 与 SimpleComposer 差距分析
|
||||
|
||||
+19
-19
@@ -1,19 +1,19 @@
|
||||
# SimpleLite 面向咪咕平台数据 API 文档
|
||||
# Simple3 面向咪咕平台数据 API 文档
|
||||
|
||||
> **版本**:与 SimpleLite 源码同步
|
||||
> **版本**:与 Simple3 源码同步
|
||||
> **更新日期**:2026-06-18
|
||||
> **服务栈**:EmbedIO WebApi(非 ASP.NET Core)
|
||||
> **OpenAPI**:`Docs/openapi/simplelite-projection.json`(已在 MiGu.Server Swagger UI 中展示)
|
||||
> **OpenAPI**:`Docs/openapi/simple3-projection.json`(已在 MiGu.Server Swagger UI 中展示)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构与访问路径
|
||||
|
||||
SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 SimpleLite。
|
||||
Simple3 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 Simple3。
|
||||
|
||||
| 层级 | 前缀 | 说明 |
|
||||
|------|------|------|
|
||||
| 直连 SimpleLite | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
|
||||
| 直连 Simple3 | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
|
||||
| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 |
|
||||
|
||||
```
|
||||
@@ -23,7 +23,7 @@ SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1
|
||||
MiGu.Server :8080 (/api/sl/*)
|
||||
│ 追加 X-Platform-Internal-Token
|
||||
▼
|
||||
SimpleLite :8222 (/projection/*)
|
||||
Simple3 :8222 (/projection/*)
|
||||
```
|
||||
|
||||
### 1.1 鉴权
|
||||
@@ -31,12 +31,12 @@ SimpleLite :8222 (/projection/*)
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 经 MiGu.Server 访问 | 需登录 JWT(`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` |
|
||||
| 直连 SimpleLite | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
|
||||
| 直连 Simple3 | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
|
||||
|
||||
Token 解析优先级(SimpleLite 侧):
|
||||
Token 解析优先级(Simple3 侧):
|
||||
|
||||
1. `simple.json` → `platform.internalToken`
|
||||
2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN`
|
||||
2. 环境变量 `SIMPLE3__PLATFORM__INTERNALTOKEN`
|
||||
3. `Platform.Server/data/.internal-token` 或 `MiGu.Server/data/.internal-token`
|
||||
|
||||
### 1.2 通用响应格式
|
||||
@@ -60,8 +60,8 @@ Token 解析优先级(SimpleLite 侧):
|
||||
|
||||
开发环境下启动 MiGu.Server 后访问:
|
||||
|
||||
- **统一文档**:`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi)
|
||||
- SimpleLite 相关接口标签前缀为 `SimpleLite/`
|
||||
- **统一文档**:`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + Simple3 全部 WebApi)
|
||||
- Simple3 相关接口标签前缀为 `Simple3/`
|
||||
|
||||
---
|
||||
|
||||
@@ -112,7 +112,7 @@ Token 解析优先级(SimpleLite 侧):
|
||||
{
|
||||
"id": "C01",
|
||||
"name": "AGV-1",
|
||||
"typeName": "SimpleLite.RCS.CarTypes.Car",
|
||||
"typeName": "Simple3.RCS.CarTypes.Car",
|
||||
"rawId": 1,
|
||||
"x": 1200.5,
|
||||
"y": 800.0,
|
||||
@@ -285,7 +285,7 @@ Token 解析优先级(SimpleLite 侧):
|
||||
|
||||
同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields` 的 `IOArea`、`Speed` 等基类字段)。
|
||||
|
||||
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("SimpleLite")` 向 SimpleLite 开放。**插件在 collectible ALC 中加载时**,SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
|
||||
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("Simple3")` 向 Simple3 开放。**插件在 collectible ALC 中加载时**,SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
|
||||
|
||||
---
|
||||
|
||||
@@ -467,7 +467,7 @@ Token 解析优先级(SimpleLite 侧):
|
||||
|
||||
咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`:
|
||||
|
||||
| 文件 | 对应 SimpleLite 前缀 |
|
||||
| 文件 | 对应 Simple3 前缀 |
|
||||
|------|----------------------|
|
||||
| `reflection.ts` | `/api/sl/projection/reflection` |
|
||||
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
|
||||
@@ -480,16 +480,16 @@ Token 解析优先级(SimpleLite 侧):
|
||||
|
||||
| 服务 | 默认端口 | 路径 |
|
||||
|------|----------|------|
|
||||
| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite |
|
||||
| SimpleLite 投影 API | 8222 | `/projection/*` |
|
||||
| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API) |
|
||||
| MiGu.Server | 8080 | `/api/sl/*` → Simple3 |
|
||||
| Simple3 投影 API | 8222 | `/projection/*` |
|
||||
| Simple3 webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API) |
|
||||
|
||||
**健康检查**:`GET http://localhost:8080/api/health/simplelite`
|
||||
**健康检查**:`GET http://localhost:8080/api/health/simple3`
|
||||
|
||||
**本地联调**:
|
||||
|
||||
```powershell
|
||||
# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起)
|
||||
# 1. 启动 Simple3(或登录 MiGu.Server 后自动拉起)
|
||||
# 2. 启动 MiGu.Server
|
||||
# 3. 浏览器打开 http://localhost:8080/swagger
|
||||
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
|
||||
|
||||
- 平台后端:`MiGu.Server`,ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。
|
||||
- 平台后端:`MiGu.Server`,ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 Simple3 子进程拉起。
|
||||
- 平台前端:`frontends/apps/simple-platform-vue`,Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`。
|
||||
|
||||
调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。
|
||||
调度内核 `Simple3` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/Simple3`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `Simple3` 协作。
|
||||
|
||||
## 2. 技术栈总览
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
|
||||
- 代理:YARP Reverse Proxy。
|
||||
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
|
||||
- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`。
|
||||
- 进程编排:`Simple3Launcher` 拉起或复用 `Simple3.exe`。
|
||||
|
||||
### 前端
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
|
||||
### 外部/相邻系统
|
||||
|
||||
- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
|
||||
- `Simple3.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
|
||||
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
|
||||
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
|
||||
- Simple3 Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
|
||||
|
||||
## 3. 顶层目录职责
|
||||
|
||||
@@ -64,33 +64,33 @@ flowchart LR
|
||||
Server --> Auth["Auth/RBAC/JWT"]
|
||||
Server --> Config["ConfigStore<br/>data/config-*.json"]
|
||||
Server --> Wizard["部署向导<br/>deployment profile"]
|
||||
Server --> Launcher["SimpleLiteLauncher"]
|
||||
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
|
||||
Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"]
|
||||
Server --> Launcher["Simple3Launcher"]
|
||||
Launcher -->|登录后按 launchMode 拉起/复用| SL["Simple3.exe<br/>相邻仓库"]
|
||||
Server -->|YARP /api/sl/*| SLApi["Simple3 :8222"]
|
||||
Server -->|YARP /vr/*| VR["webVRender :8223"]
|
||||
Vue -->|iframe 或 /vr 代理| VR
|
||||
```
|
||||
|
||||
关键说明:
|
||||
|
||||
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite。
|
||||
- 用户登录时可选择 `DesktopAndWeb` 或 `WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local` 或 `--display-mode=web`。
|
||||
- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite。
|
||||
- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
|
||||
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 Simple3。
|
||||
- 用户登录时可选择 `DesktopAndWeb` 或 `WebOnly`,后端转换为 Simple3 命令行 `--display-mode=web+local` 或 `--display-mode=web`。
|
||||
- `Simple3Launcher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 Simple3。
|
||||
- 如果 `8222` 已经有 Simple3 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
|
||||
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
|
||||
|
||||
## 5. 文档蓝图与当前代码的差异
|
||||
|
||||
仓库中存在两类叙述:
|
||||
|
||||
- `README.md` 和 `MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。
|
||||
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
|
||||
- `README.md` 和 `MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `Simple3`”这一当前实现。
|
||||
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“Simple3 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
|
||||
|
||||
当前代码落地状态:
|
||||
|
||||
- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
|
||||
- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
|
||||
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
|
||||
- 已落地:`MiGu.Server` 主入口、登录拉起 Simple3、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
|
||||
- 部分落地:配置中心模型、运维白名单、Simple3 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
|
||||
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 Simple3 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
|
||||
|
||||
## 6. 后端模块
|
||||
|
||||
@@ -101,14 +101,14 @@ flowchart LR
|
||||
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
|
||||
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`。
|
||||
- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`。
|
||||
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、SimpleLiteLauncher。
|
||||
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、Simple3Launcher。
|
||||
- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。
|
||||
- 托管静态资源和 SPA fallback。
|
||||
|
||||
核心关系:
|
||||
|
||||
- 调用 `JwtIssuer` 颁发/验签 JWT。
|
||||
- 调用 `InternalTokenStore` 管理转发给 SimpleLite 的 `X-Platform-Internal-Token`。
|
||||
- 调用 `InternalTokenStore` 管理转发给 Simple3 的 `X-Platform-Internal-Token`。
|
||||
- YARP `sl-route` 会自动注入内部 token。
|
||||
- `app.MapControllers()` 处理平台自身 API,`app.MapReverseProxy()` 处理 `/api/sl/*` 与 `/vr/*`。
|
||||
|
||||
@@ -116,7 +116,7 @@ flowchart LR
|
||||
|
||||
职责:
|
||||
|
||||
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 SimpleLite,签发 JWT 和 Cookie。
|
||||
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 Simple3,签发 JWT 和 Cookie。
|
||||
- `POST /api/auth/logout`:清理 Cookie。
|
||||
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
|
||||
- `POST /api/auth/switch-scope`:同一用户在 `Platform` 与 `RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
|
||||
@@ -125,7 +125,7 @@ flowchart LR
|
||||
|
||||
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
|
||||
- `JwtIssuer`:签发 JWT。
|
||||
- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite。
|
||||
- `Simple3Launcher`:登录后拉起或复用 Simple3。
|
||||
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
|
||||
|
||||
### 6.3 RBAC 权限模块:`Auth/`
|
||||
@@ -136,7 +136,7 @@ flowchart LR
|
||||
- `RbacModels.cs`:用户、角色、DTO。
|
||||
- `PageCatalog.cs`:页面权限目录。
|
||||
- `JwtIssuer.cs`:JWT 颁发和验签参数。
|
||||
- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。
|
||||
- `InternalTokenStore.cs`:平台与 Simple3 内部通信 token。
|
||||
|
||||
职责:
|
||||
|
||||
@@ -182,32 +182,32 @@ flowchart LR
|
||||
|
||||
- 前端 `useConfigStore` 统一读写这些 section。
|
||||
- 许多配置页只是不同 section 的编辑视图。
|
||||
- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。
|
||||
- `deployment` section 还会参与菜单裁剪与 Simple3 插件选择。
|
||||
|
||||
注意:
|
||||
|
||||
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
|
||||
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
|
||||
|
||||
### 6.5 SimpleLite 拉起与诊断:`Launcher/`
|
||||
### 6.5 Simple3 拉起与诊断:`Launcher/`
|
||||
|
||||
主要文件:
|
||||
|
||||
- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。
|
||||
- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`。
|
||||
- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。
|
||||
- `Simple3Launcher.cs`:拉起、复用、重启、诊断、插件选择联动。
|
||||
- `Simple3Options.cs`:绑定 `appsettings.json:Simple3`。
|
||||
- `Simple3BuildSync.cs`:辅助同步/探测 Simple3 构建与 API。
|
||||
|
||||
职责:
|
||||
|
||||
- 将业务启动模式翻译为 SimpleLite 内部 display mode:
|
||||
- 将业务启动模式翻译为 Simple3 内部 display mode:
|
||||
- `WebOnly` -> `--display-mode=web`
|
||||
- `DesktopAndWeb` -> `--display-mode=web+local`
|
||||
- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。
|
||||
- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
|
||||
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite。
|
||||
- 拉起前检查 `8222` 是否已有 Simple3,并用 `/projection/cars` 确认不是其他进程占用端口。
|
||||
- 查找 `Simple3.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
|
||||
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 Simple3。
|
||||
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
|
||||
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite。
|
||||
- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
|
||||
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 Simple3。
|
||||
- `GET /api/health/simple3` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
|
||||
|
||||
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
|
||||
|
||||
@@ -217,7 +217,7 @@ flowchart LR
|
||||
- 二次校验 JWT 的 `ops` claim。
|
||||
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
|
||||
- `monitor.note.write` 仅写审计。
|
||||
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。
|
||||
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 Simple3 反射 API。
|
||||
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
|
||||
|
||||
默认白名单:
|
||||
@@ -229,9 +229,9 @@ flowchart LR
|
||||
重要设计:
|
||||
|
||||
- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。
|
||||
- 真实下发路径为 SimpleLite 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`。
|
||||
- 真实下发路径为 Simple3 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`。
|
||||
|
||||
### 6.7 投影与 SimpleLite 代理
|
||||
### 6.7 投影与 Simple3 代理
|
||||
|
||||
模块组成:
|
||||
|
||||
@@ -249,8 +249,8 @@ flowchart LR
|
||||
职责:
|
||||
|
||||
- `GET /api/health`:平台后端健康检查,返回启动时间、运行时长、端口规划。
|
||||
- `GET /api/health/simplelite`:SimpleLite 路径和端口诊断。
|
||||
- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。
|
||||
- `GET /api/health/simple3`:Simple3 路径和端口诊断。
|
||||
- `POST /api/health/simple3/restart-for-update`:授权用户可触发关闭 Simple3、同步最新 DLL 并重新拉起。
|
||||
|
||||
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
|
||||
|
||||
@@ -258,7 +258,7 @@ flowchart LR
|
||||
|
||||
- 首次部署时收集导航方式、功能模块、业务场景。
|
||||
- 保存后写入 `deployment` 配置 section。
|
||||
- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`。
|
||||
- 将导航方式映射为 Simple3 场景插件 ID,并调用 `Simple3Launcher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`。
|
||||
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`。
|
||||
|
||||
对外 API:
|
||||
@@ -273,7 +273,7 @@ flowchart LR
|
||||
|
||||
职责:
|
||||
|
||||
- 读取 SimpleLite 工作目录下的 `log/` 文件。
|
||||
- 读取 Simple3 工作目录下的 `log/` 文件。
|
||||
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
|
||||
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
|
||||
|
||||
@@ -290,14 +290,14 @@ flowchart LR
|
||||
|
||||
职责:
|
||||
|
||||
- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。
|
||||
- 先调用 Simple3 `/projection/map-edit/maps` 获取地图目录。
|
||||
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
|
||||
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
|
||||
|
||||
关系:
|
||||
|
||||
- 依赖 SimpleLite 与 MiGu.Server 同机部署。
|
||||
- 依赖 `SimpleLiteOptions.ProjectionPort` 和 `InternalTokenStore`。
|
||||
- 依赖 Simple3 与 MiGu.Server 同机部署。
|
||||
- 依赖 `Simple3Options.ProjectionPort` 和 `InternalTokenStore`。
|
||||
|
||||
## 7. 前端模块
|
||||
|
||||
@@ -328,7 +328,7 @@ flowchart LR
|
||||
|
||||
职责:
|
||||
|
||||
- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。
|
||||
- 登录页采集用户名、密码、scope 和 Simple3 启动模式。
|
||||
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
|
||||
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
|
||||
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
|
||||
@@ -346,7 +346,7 @@ flowchart LR
|
||||
- `api/config.ts`:配置中心读写。
|
||||
- `api/projection.ts`:站点、路径、车辆、任务投影。
|
||||
- `api/ops.ts`:运维白名单执行和审计。
|
||||
- `api/reflection.ts`:SimpleLite 反射对象、字段、方法执行。
|
||||
- `api/reflection.ts`:Simple3 反射对象、字段、方法执行。
|
||||
- `api/mapEdit.ts`:地图编辑相关 API。
|
||||
- `api/wizard.ts`:部署向导。
|
||||
- `api/logs.ts`:日志管理。
|
||||
@@ -354,7 +354,7 @@ flowchart LR
|
||||
关系:
|
||||
|
||||
- 所有平台 API 都以 `/api` 为 baseURL。
|
||||
- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite。
|
||||
- `/api/sl/*` 由 MiGu.Server 反代给 Simple3。
|
||||
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
|
||||
|
||||
### 7.4 Shell、主题和权限控制组件
|
||||
@@ -418,7 +418,7 @@ flowchart LR
|
||||
- `useHistory`:撤销/重做命令栈。
|
||||
- `useSelection`:对象选择状态。
|
||||
- `useAlignment`、`useBatchGenerate`、`useClipboard`:编辑辅助能力。
|
||||
- `mapEditApi`、`reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。
|
||||
- `mapEditApi`、`reflectionApi`:与 Simple3 的地图编辑和反射 API 通信。
|
||||
|
||||
#### 地图监控
|
||||
|
||||
@@ -431,7 +431,7 @@ flowchart LR
|
||||
- `VehicleMonitorPanel`:车辆监控台。
|
||||
- `MissionListPanel`:任务列表。
|
||||
- `MonitorSelectionPanel`:选中对象详情和动作。
|
||||
- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。
|
||||
- `useProjectionStream`:订阅 Simple3/平台 SSE 事件。
|
||||
|
||||
### 7.7 运营端页面:`/monitor/*`
|
||||
|
||||
@@ -469,13 +469,13 @@ flowchart LR
|
||||
- 选择导航方式:磁导航、二维码导航、激光导航。
|
||||
- 选择功能模块:当前包含 WMS、PTL。
|
||||
- 选择业务场景模板。
|
||||
- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic`、`scene.qrcode`、`scene.laser`。
|
||||
- 预览将激活的 Simple3 场景插件,如 `scene.magnetic`、`scene.qrcode`、`scene.laser`。
|
||||
|
||||
保存后:
|
||||
|
||||
- 调用 `PUT /api/wizard/profile`。
|
||||
- 后端写入 `deployment` section。
|
||||
- 后端写入 SimpleLite `plugins/active-scenes.json`。
|
||||
- 后端写入 Simple3 `plugins/active-scenes.json`。
|
||||
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
|
||||
|
||||
## 8. 数据与持久化
|
||||
@@ -491,9 +491,9 @@ flowchart LR
|
||||
|
||||
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
|
||||
|
||||
### SimpleLite 侧文件
|
||||
### Simple3 侧文件
|
||||
|
||||
MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
|
||||
MiGu.Server 会直接或间接使用 Simple3 工作目录:
|
||||
|
||||
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
|
||||
- `log/**/*.log`:日志管理模块读取并分析。
|
||||
@@ -511,7 +511,7 @@ MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
|
||||
- `/api/maps/{name}/content`:地图 JSON 预览。
|
||||
- `/api/health/*`:健康检查。
|
||||
|
||||
### 平台后端到 SimpleLite
|
||||
### 平台后端到 Simple3
|
||||
|
||||
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`。
|
||||
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`。
|
||||
@@ -533,7 +533,7 @@ flowchart TB
|
||||
RBAC["RbacStore / JwtIssuer"]
|
||||
Config["ConfigStore"]
|
||||
Wizard["WizardController"]
|
||||
Launcher["SimpleLiteLauncher"]
|
||||
Launcher["Simple3Launcher"]
|
||||
Ops["OpsController"]
|
||||
Logs["LogsController"]
|
||||
Maps["MapsContentController"]
|
||||
@@ -551,8 +551,8 @@ flowchart TB
|
||||
WizardVue["WizardView.vue"]
|
||||
end
|
||||
|
||||
subgraph SimpleLite["SimpleLite 相邻仓库"]
|
||||
SLExe["SimpleLite.exe"]
|
||||
subgraph Simple3["Simple3 相邻仓库"]
|
||||
SLExe["Simple3.exe"]
|
||||
Projection["Projection API :8222"]
|
||||
VRender["webVRender :8223"]
|
||||
LogsFile["log/**/*.log"]
|
||||
@@ -591,14 +591,14 @@ flowchart TB
|
||||
|
||||
## 11. 典型业务链路
|
||||
|
||||
### 11.1 登录并启动 SimpleLite
|
||||
### 11.1 登录并启动 Simple3
|
||||
|
||||
1. 用户打开 `/login`。
|
||||
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`。
|
||||
3. `AuthController` 调 `RbacStore.VerifyCredentials()` 校验密码。
|
||||
4. `AuthController` 检查用户是否可使用请求的 scope。
|
||||
5. `AuthController` 调 `SimpleLiteLauncher.MaybeStart()`。
|
||||
6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`。
|
||||
5. `AuthController` 调 `Simple3Launcher.MaybeStart()`。
|
||||
6. `Simple3Launcher` 复用或拉起 `Simple3.exe`。
|
||||
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
|
||||
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
|
||||
|
||||
@@ -608,7 +608,7 @@ flowchart TB
|
||||
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
|
||||
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
|
||||
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
|
||||
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi` 或 `reflectionApi` 调用 SimpleLite。
|
||||
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi` 或 `reflectionApi` 调用 Simple3。
|
||||
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
|
||||
|
||||
### 11.3 运营端执行运维动作
|
||||
@@ -618,7 +618,7 @@ flowchart TB
|
||||
3. 用户确认后调用 `/api/sl/ops/execute`。
|
||||
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
|
||||
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
|
||||
6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。
|
||||
6. 已配置映射时调用 Simple3 反射 execute;未配置映射时返回未下发。
|
||||
7. 审计落盘,前端显示执行结果。
|
||||
|
||||
### 11.4 首次部署配置向导
|
||||
@@ -628,7 +628,7 @@ flowchart TB
|
||||
3. 前端加载 `/api/wizard/options` 和 `/api/wizard/profile`。
|
||||
4. 用户选择导航方式、模块、场景。
|
||||
5. 保存后端写 `deployment` section。
|
||||
6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`。
|
||||
6. 后端将导航方式转换为场景插件 ID,并写 Simple3 `plugins/active-scenes.json`。
|
||||
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
|
||||
|
||||
## 12. 构建与运行
|
||||
@@ -645,7 +645,7 @@ dotnet run
|
||||
|
||||
- 平台:`http://localhost:8080/login`
|
||||
- 健康检查:`http://localhost:8080/api/health`
|
||||
- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite`
|
||||
- Simple3 诊断:`http://localhost:8080/api/health/simple3`
|
||||
- Swagger:开发环境 `/swagger`
|
||||
|
||||
### 前端开发
|
||||
@@ -679,7 +679,7 @@ pnpm dev
|
||||
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
|
||||
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
|
||||
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
|
||||
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health` 与 `/api/health/simplelite` 的真实数据。
|
||||
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health` 与 `/api/health/simple3` 的真实数据。
|
||||
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`。
|
||||
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
|
||||
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
|
||||
@@ -687,8 +687,8 @@ pnpm dev
|
||||
## 14. 快速索引
|
||||
|
||||
- 后端启动:`MiGu.Server/Program.cs`
|
||||
- 登录和启动 SimpleLite:`MiGu.Server/Controllers/AuthController.cs`
|
||||
- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs`
|
||||
- 登录和启动 Simple3:`MiGu.Server/Controllers/AuthController.cs`
|
||||
- Simple3 拉起器:`MiGu.Server/Launcher/Simple3Launcher.cs`
|
||||
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
|
||||
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
|
||||
- 权限 API:`MiGu.Server/Controllers/RbacController.cs`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MiGu.DB.Abstractions.Entities;
|
||||
|
||||
public interface IEntity<TKey>
|
||||
{
|
||||
TKey Id { get; set; }
|
||||
}
|
||||
|
||||
public interface IAuditable
|
||||
{
|
||||
DateTimeOffset CreatedAt { get; set; }
|
||||
DateTimeOffset UpdatedAt { get; set; }
|
||||
string CreatedBy { get; set; }
|
||||
string UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
public interface ISoftDeletable
|
||||
{
|
||||
bool IsDeleted { get; set; }
|
||||
DateTimeOffset? DeletedAt { get; set; }
|
||||
string DeletedBy { get; set; }
|
||||
}
|
||||
|
||||
public interface IVersioned
|
||||
{
|
||||
long Version { get; set; }
|
||||
}
|
||||
|
||||
public interface ILockable
|
||||
{
|
||||
bool IsLock { get; set; }
|
||||
}
|
||||
|
||||
public interface IRemarkable
|
||||
{
|
||||
string Remark { get; set; }
|
||||
}
|
||||
|
||||
public interface IExtendable
|
||||
{
|
||||
string Extend { get; set; }
|
||||
}
|
||||
|
||||
public interface IHistoryEntry
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace MiGu.DB.Abstractions.Exceptions;
|
||||
|
||||
public sealed class ConcurrencyConflictException : Exception
|
||||
{
|
||||
public string EntityType { get; }
|
||||
public object? EntityId { get; }
|
||||
public long? ExpectedVersion { get; }
|
||||
|
||||
public ConcurrencyConflictException(string entityType, object? entityId, long? expectedVersion = null)
|
||||
: base("数据已被其他用户修改,请刷新后重试")
|
||||
{
|
||||
EntityType = entityType;
|
||||
EntityId = entityId;
|
||||
ExpectedVersion = expectedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EntityLockedException : Exception
|
||||
{
|
||||
public string EntityType { get; }
|
||||
public object? EntityId { get; }
|
||||
|
||||
public EntityLockedException(string entityType, object? entityId)
|
||||
: base("数据已锁定,不能修改")
|
||||
{
|
||||
EntityType = entityType;
|
||||
EntityId = entityId;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EntityNotFoundException : Exception
|
||||
{
|
||||
public string EntityType { get; }
|
||||
public object? EntityId { get; }
|
||||
|
||||
public EntityNotFoundException(string entityType, object? entityId)
|
||||
: base("数据不存在")
|
||||
{
|
||||
EntityType = entityType;
|
||||
EntityId = entityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MiGu.DB.Abstractions.Modules;
|
||||
|
||||
public interface IEntityModule
|
||||
{
|
||||
void ConfigureModel(ModelBuilder modelBuilder);
|
||||
void RegisterServices(IServiceCollection services);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Linq.Expressions;
|
||||
using MiGu.DB.Abstractions.Entities;
|
||||
|
||||
namespace MiGu.DB.Abstractions.Persistence;
|
||||
|
||||
public interface IRepository<TEntity, TKey> where TEntity : class, IEntity<TKey>
|
||||
{
|
||||
IQueryable<TEntity> Query(bool asNoTracking = true);
|
||||
Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default);
|
||||
Task AddAsync(TEntity entity, CancellationToken ct = default);
|
||||
void Update(TEntity entity);
|
||||
}
|
||||
|
||||
public interface IEditableRepository<TEntity> : IRepository<TEntity, Guid>
|
||||
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
|
||||
{
|
||||
Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
|
||||
Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
|
||||
Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IHistoryRepository<TEntity> : IRepository<TEntity, Guid>
|
||||
where TEntity : class, IEntity<Guid>, IHistoryEntry
|
||||
{
|
||||
Task AppendAsync(TEntity entry, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiGu.DB.Abstractions.Providers;
|
||||
|
||||
public interface IDbProviderSetup
|
||||
{
|
||||
string Name { get; }
|
||||
void Configure(DbContextOptionsBuilder builder, string connectionString);
|
||||
string MigrationsAssemblyName { get; }
|
||||
string MigrationsNamespace { get; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiGu.DB.Abstractions.Runtime;
|
||||
|
||||
public interface IActorContext
|
||||
{
|
||||
string Name { get; }
|
||||
}
|
||||
|
||||
public interface IActorContextAccessor
|
||||
{
|
||||
IActorContext Current { get; set; }
|
||||
}
|
||||
|
||||
public interface IDataMigrator
|
||||
{
|
||||
int Order { get; }
|
||||
string Name { get; }
|
||||
Task MigrateAsync(DbContext db, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动期 Schema 初始化策略。
|
||||
/// <see cref="Migrate"/>:版本化迁移(发版/现场);
|
||||
/// <see cref="EnsureCreated"/>:按当前模型建库(开发期,改模型需删库重建)。
|
||||
/// </summary>
|
||||
public enum MiGuSchemaMode
|
||||
{
|
||||
Migrate = 0,
|
||||
EnsureCreated = 1
|
||||
}
|
||||
|
||||
public sealed class MiGuDbOptions
|
||||
{
|
||||
public string Provider { get; set; } = "sqlite";
|
||||
public string ConnectionString { get; set; } = "";
|
||||
public string ContentRootPath { get; set; } = "";
|
||||
|
||||
/// <summary>Schema 初始化模式,对应配置 Database:SchemaMode。</summary>
|
||||
public MiGuSchemaMode SchemaMode { get; set; } = MiGuSchemaMode.Migrate;
|
||||
|
||||
/// <summary>为 true 时,Schema 初始化后按 Order 执行全部 IDataMigrator(默认开启)。</summary>
|
||||
public bool ApplyDataMigratorsOnStartup { get; set; } = true;
|
||||
}
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
namespace MiGu.Server.Dashboard;
|
||||
namespace MiGu.DB.Domains.Dashboard;
|
||||
|
||||
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
|
||||
public sealed class UserDashboardShortcut
|
||||
{
|
||||
public string UserId { get; set; } = "";
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace MiGu.DB.Domains.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
|
||||
/// 以任务 Id 为主键;Simple3/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskRecord
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? TaskId { get; set; }
|
||||
public int MissionId { get; set; }
|
||||
public string MissionName { get; set; } = "";
|
||||
public string MissionTypeName { get; set; } = "";
|
||||
public int SrcSiteId { get; set; }
|
||||
public string SrcLabel { get; set; } = "";
|
||||
public int DstSiteId { get; set; }
|
||||
public string DstLabel { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string StatusCode { get; set; } = "";
|
||||
public int? CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public int Priority { get; set; }
|
||||
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
|
||||
public string? CreateTime { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? FinishTime { get; set; }
|
||||
public string? StuckReason { get; set; }
|
||||
public bool Overdue { get; set; }
|
||||
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
|
||||
public DateTimeOffset FirstSeenAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace MiGu.DB.Domains.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
|
||||
/// 出现报警→开一条 active 记录;文案/级别变化→先 clear 再建新 active;消失→cleared。
|
||||
/// </summary>
|
||||
public sealed class VehicleAlarmRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("D");
|
||||
public int CarId { get; set; }
|
||||
public string CarName { get; set; } = "";
|
||||
/// <summary>报警文案(车体_AlarmInfo)。</summary>
|
||||
public string Info { get; set; } = "";
|
||||
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
|
||||
public int Level { get; set; }
|
||||
/// <summary>active | cleared</summary>
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset FirstAt { get; set; }
|
||||
public DateTimeOffset LastAt { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
/// <summary>持续时长(秒),恢复后写入。</summary>
|
||||
public long? DurationSecs { get; set; }
|
||||
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
|
||||
public bool Acknowledged { get; set; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MiGu.DB.Abstractions.Runtime;
|
||||
using MiGu.DB.Domains.Wms;
|
||||
using MiGu.DB.Kernel.Context;
|
||||
|
||||
namespace MiGu.DB.Domains.Migrators;
|
||||
|
||||
// 启动期数据修补:Schema 初始化之后按 Order 执行,须幂等。
|
||||
// 优先 LINQ / ExecuteUpdateAsync;枚举 converter 无法匹配旧字符串时允许定点 raw UPDATE(Sqlite)。
|
||||
|
||||
/// <summary>将 simple_fields.other 回填到 car_type(替代原 PlatformPersistence raw UPDATE)。</summary>
|
||||
public sealed class SimpleFieldsCarTypeBackfillMigrator : IDataMigrator
|
||||
{
|
||||
public int Order => 10;
|
||||
public string Name => "SimpleFields.CarTypeBackfill";
|
||||
|
||||
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
|
||||
{
|
||||
if (db is not MiGuDbContext ctx) return;
|
||||
|
||||
await ctx.SimpleFields
|
||||
.Where(x => (x.CarType == null || x.CarType == "") && x.Other != "")
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CarType, x => x.Other), ct);
|
||||
|
||||
await ctx.SimpleFields
|
||||
.Where(x => x.Other != "" && x.Other == x.CarType)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Other, ""), ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>库区 LayoutMode 空值补 Flat(raw UPDATE,避开枚举 converter)。</summary>
|
||||
public sealed class AreaLayoutModeDefaultMigrator : IDataMigrator
|
||||
{
|
||||
public int Order => 15;
|
||||
public string Name => "Wms.AreaLayoutModeDefault";
|
||||
|
||||
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
|
||||
{
|
||||
if (db is not MiGuDbContext) return;
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE wms_areas
|
||||
SET LayoutMode = 'Flat'
|
||||
WHERE LayoutMode IS NULL OR LayoutMode = ''
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LocationType=Storage 时回填 StorageId,供库存 join;写路径 BindOrTransferLocation 也会维护该字段。
|
||||
/// </summary>
|
||||
public sealed class ContainerLocationStorageIdBackfillMigrator : IDataMigrator
|
||||
{
|
||||
public int Order => 20;
|
||||
public string Name => "Wms.ContainerLocation.StorageId";
|
||||
|
||||
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
|
||||
{
|
||||
if (db is not MiGuDbContext ctx) return;
|
||||
|
||||
var rows = await ctx.ContainerLocations
|
||||
.Where(x => x.LocationType == ContainerLocationType.Storage && x.StorageId == null)
|
||||
.ToListAsync(ct);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (Guid.TryParse(row.LocationId, out var sid))
|
||||
row.StorageId = sid;
|
||||
}
|
||||
if (rows.Count > 0)
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DataMigratorRegistration
|
||||
{
|
||||
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
|
||||
services.AddSingleton<IDataMigrator, AreaLayoutModeDefaultMigrator>();
|
||||
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MiGu.DB.Kernel.Entities;
|
||||
|
||||
namespace MiGu.DB.Domains.SimpleFields;
|
||||
|
||||
public sealed class SimpleField : Entity<Guid>
|
||||
{
|
||||
[MaxLength(64)] public string CarType { get; set; } = "";
|
||||
[MaxLength(64)] public string FieldType { get; set; } = "";
|
||||
[MaxLength(128)] public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
[MaxLength(128)] public string DataType { get; set; } = "";
|
||||
[MaxLength(256)] public string? Chinese { get; set; }
|
||||
[MaxLength(256)] public string? English { get; set; }
|
||||
[MaxLength(512)] public string Other { get; set; } = "";
|
||||
public bool IsDefault { get; set; }
|
||||
public DateTimeOffset CreateTime { get; set; }
|
||||
public DateTimeOffset UpdateTime { get; set; }
|
||||
}
|
||||
|
||||
public static class SimpleFieldDateTime
|
||||
{
|
||||
public const string Format = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
public static DateTimeOffset Now => DateTimeOffset.UtcNow;
|
||||
|
||||
public static string ToStorage(DateTimeOffset value)
|
||||
=> value.UtcDateTime.ToString(Format);
|
||||
|
||||
public static DateTimeOffset FromStorage(string value)
|
||||
=> DateTimeOffset.Parse(value);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MiGu.DB.Kernel.Entities;
|
||||
|
||||
namespace MiGu.DB.Domains.Transport;
|
||||
|
||||
// 运输规则/任务/预占实体。状态字段为枚举;复数 *Statuses 辅助类供 Server 解析 DTO 字符串。
|
||||
|
||||
public sealed class WmsTransportRule : AggregateRoot
|
||||
{
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
public WmsTransportTriggerType TriggerType { get; set; } = WmsTransportTriggerType.MaterialCall;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int Priority { get; set; }
|
||||
public string SourceSelectorJson { get; set; } = "{}";
|
||||
public string TargetSelectorJson { get; set; } = "{}";
|
||||
public string TaskOptionsJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public sealed class WmsTransportTask : AggregateRoot
|
||||
{
|
||||
[MaxLength(64)] public string BusinessType { get; set; } = "";
|
||||
public Guid? RuleId { get; set; }
|
||||
public Guid SourceStorageId { get; set; }
|
||||
public Guid TargetStorageId { get; set; }
|
||||
public Guid ContainerId { get; set; }
|
||||
public Guid? MaterialId { get; set; }
|
||||
public decimal? Quantity { get; set; }
|
||||
public WmsTransportTaskStatus Status { get; set; } = WmsTransportTaskStatus.Pending;
|
||||
[MaxLength(64)] public string DispatchMissionId { get; set; } = "";
|
||||
[MaxLength(64)] public string DeliveryId { get; set; } = "";
|
||||
[MaxLength(32)] public string DispatchStatus { get; set; } = "";
|
||||
[MaxLength(500)] public string Reason { get; set; } = "";
|
||||
public string SnapshotJson { get; set; } = "{}";
|
||||
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
|
||||
public int TaskPriority { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WmsTransportReservation : AggregateRoot
|
||||
{
|
||||
public Guid TaskId { get; set; }
|
||||
public Guid ContainerId { get; set; }
|
||||
public Guid SourceStorageId { get; set; }
|
||||
public Guid TargetStorageId { get; set; }
|
||||
public WmsReservationStatus Status { get; set; } = WmsReservationStatus.Active;
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WmsTransportTaskHistory : Entity<Guid>
|
||||
{
|
||||
public Guid TaskId { get; set; }
|
||||
[MaxLength(32)] public string FromStatus { get; set; } = "";
|
||||
[MaxLength(32)] public string ToStatus { get; set; } = "";
|
||||
[MaxLength(128)] public string Operator { get; set; } = "";
|
||||
public DateTimeOffset OperatedAt { get; set; }
|
||||
[MaxLength(500)] public string Reason { get; set; } = "";
|
||||
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
|
||||
public string SnapshotJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public static class WmsTransportTriggerTypes
|
||||
{
|
||||
// 复数辅助类:与枚举分离,避免与属性/类型同名冲突,并提供 ParseOr / All
|
||||
public const WmsTransportTriggerType MaterialCall = WmsTransportTriggerType.MaterialCall;
|
||||
public const WmsTransportTriggerType FinishedGoodsOffline = WmsTransportTriggerType.FinishedGoodsOffline;
|
||||
public const WmsTransportTriggerType AutoTransfer = WmsTransportTriggerType.AutoTransfer;
|
||||
public static readonly HashSet<WmsTransportTriggerType> All = new()
|
||||
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
|
||||
|
||||
public static bool IsDefined(string? value) =>
|
||||
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e);
|
||||
|
||||
public static WmsTransportTriggerType ParseOr(string? value, WmsTransportTriggerType fallback = WmsTransportTriggerType.MaterialCall) =>
|
||||
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class WmsTransportTaskStatuses
|
||||
{
|
||||
public const WmsTransportTaskStatus Pending = WmsTransportTaskStatus.Pending;
|
||||
public const WmsTransportTaskStatus Reserved = WmsTransportTaskStatus.Reserved;
|
||||
public const WmsTransportTaskStatus Dispatched = WmsTransportTaskStatus.Dispatched;
|
||||
public const WmsTransportTaskStatus InTransit = WmsTransportTaskStatus.InTransit;
|
||||
public const WmsTransportTaskStatus Completed = WmsTransportTaskStatus.Completed;
|
||||
public const WmsTransportTaskStatus Failed = WmsTransportTaskStatus.Failed;
|
||||
public const WmsTransportTaskStatus Cancelled = WmsTransportTaskStatus.Cancelled;
|
||||
|
||||
public static readonly HashSet<WmsTransportTaskStatus> Active = new()
|
||||
{ Pending, Reserved, Dispatched, InTransit };
|
||||
public static readonly HashSet<WmsTransportTaskStatus> All = new()
|
||||
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
|
||||
}
|
||||
|
||||
public static class WmsReservationStatuses
|
||||
{
|
||||
public const WmsReservationStatus Active = WmsReservationStatus.Active;
|
||||
public const WmsReservationStatus Released = WmsReservationStatus.Released;
|
||||
public const WmsReservationStatus Expired = WmsReservationStatus.Expired;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MiGu.DB.Domains.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// 运输域状态/触发类型枚举。存储规则同 Wms:HasConversion<string>,成员名 = 列值。
|
||||
/// </summary>
|
||||
public enum WmsTransportTriggerType
|
||||
{
|
||||
MaterialCall,
|
||||
FinishedGoodsOffline,
|
||||
AutoTransfer
|
||||
}
|
||||
|
||||
public enum WmsTransportTaskStatus
|
||||
{
|
||||
Pending,
|
||||
Reserved,
|
||||
Dispatched,
|
||||
InTransit,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public enum WmsReservationStatus
|
||||
{
|
||||
Active,
|
||||
Released,
|
||||
Expired
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.DB.Domains.Dashboard;
|
||||
using MiGu.DB.Domains.SimpleFields;
|
||||
using MiGu.DB.Domains.Transport;
|
||||
|
||||
namespace MiGu.DB.Domains.Transport
|
||||
{
|
||||
public sealed class WmsTransportRuleConfiguration : IEntityTypeConfiguration<WmsTransportRule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WmsTransportRule> b)
|
||||
{
|
||||
b.ToTable("wms_transport_rules");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
b.HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
|
||||
b.Property(x => x.SourceSelectorJson).HasColumnType("text");
|
||||
b.Property(x => x.TargetSelectorJson).HasColumnType("text");
|
||||
b.Property(x => x.TaskOptionsJson).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WmsTransportTaskConfiguration : IEntityTypeConfiguration<WmsTransportTask>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WmsTransportTask> b)
|
||||
{
|
||||
b.ToTable("wms_transport_tasks");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Status);
|
||||
b.HasIndex(x => x.ContainerId);
|
||||
b.HasIndex(x => x.TargetStorageId);
|
||||
b.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
b.Property(x => x.SnapshotJson).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WmsTransportReservationConfiguration : IEntityTypeConfiguration<WmsTransportReservation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WmsTransportReservation> b)
|
||||
{
|
||||
b.ToTable("wms_transport_reservations");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => new { x.ContainerId, x.Status });
|
||||
b.HasIndex(x => new { x.TargetStorageId, x.Status });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WmsTransportTaskHistoryConfiguration : IEntityTypeConfiguration<WmsTransportTaskHistory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WmsTransportTaskHistory> b)
|
||||
{
|
||||
b.ToTable("wms_transport_task_history");
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.FromStatus).HasMaxLength(32);
|
||||
b.Property(x => x.ToStatus).HasMaxLength(32);
|
||||
b.Property(x => x.Operator).HasMaxLength(128);
|
||||
b.Property(x => x.Reason).HasMaxLength(500);
|
||||
b.Property(x => x.ErrorMessage).HasMaxLength(1000);
|
||||
b.Property(x => x.SnapshotJson).HasColumnType("text");
|
||||
b.HasIndex(x => x.TaskId);
|
||||
b.HasIndex(x => x.OperatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace MiGu.DB.Domains.SimpleFields
|
||||
{
|
||||
public sealed class SimpleFieldConfiguration : IEntityTypeConfiguration<SimpleField>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SimpleField> b)
|
||||
{
|
||||
b.ToTable("simple_fields");
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.Id).HasColumnName("id");
|
||||
b.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
|
||||
b.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
|
||||
b.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
|
||||
b.Property(x => x.Value).HasColumnName("value");
|
||||
b.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
|
||||
b.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
|
||||
b.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
|
||||
b.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
|
||||
b.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => SimpleFieldDateTime.ToStorage(v),
|
||||
v => SimpleFieldDateTime.FromStorage(v));
|
||||
b.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
b.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
b.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace MiGu.DB.Domains.Dashboard
|
||||
{
|
||||
public sealed class UserDashboardShortcutConfiguration : IEntityTypeConfiguration<UserDashboardShortcut>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserDashboardShortcut> b)
|
||||
{
|
||||
b.ToTable("user_dashboard_shortcuts");
|
||||
b.HasKey(x => new { x.UserId, x.Scope });
|
||||
b.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
|
||||
b.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
|
||||
b.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
b.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.DB.Domains.Dashboard;
|
||||
using MiGu.DB.Domains.SimpleFields;
|
||||
using MiGu.DB.Domains.Transport;
|
||||
using MiGu.DB.Domains.Wms;
|
||||
using MiGu.DB.Kernel.Entities;
|
||||
|
||||
namespace MiGu.DB.Domains.Wms;
|
||||
|
||||
public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Warehouse> b)
|
||||
{
|
||||
b.ToTable("wms_warehouses");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WarehouseAreaConfiguration : IEntityTypeConfiguration<WarehouseArea>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WarehouseArea> b)
|
||||
{
|
||||
b.ToTable("wms_areas");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
b.HasIndex(x => x.WarehouseId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StorageConfiguration : IEntityTypeConfiguration<Storage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Storage> b)
|
||||
{
|
||||
b.ToTable("wms_storages");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
b.HasIndex(x => x.AreaId);
|
||||
b.HasIndex(x => x.Barcode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ContainerConfiguration : IEntityTypeConfiguration<Container>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Container> b)
|
||||
{
|
||||
b.ToTable("wms_containers");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MaterialTypeConfiguration : IEntityTypeConfiguration<MaterialType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MaterialType> b)
|
||||
{
|
||||
b.ToTable("wms_material_types");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MaterialConfiguration : IEntityTypeConfiguration<Material>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Material> b)
|
||||
{
|
||||
b.ToTable("wms_materials");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.Code).IsUnique();
|
||||
b.HasIndex(x => x.Barcode);
|
||||
b.HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
|
||||
b.HasIndex(x => x.TypeCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ContainerLocationConfiguration : IEntityTypeConfiguration<ContainerLocation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContainerLocation> b)
|
||||
{
|
||||
b.ToTable("wms_container_locations");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.ContainerId).IsUnique();
|
||||
b.HasIndex(x => new { x.LocationType, x.LocationId });
|
||||
b.HasIndex(x => x.StorageId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ContainerMaterialConfiguration : IEntityTypeConfiguration<ContainerMaterial>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContainerMaterial> b)
|
||||
{
|
||||
b.ToTable("wms_container_materials");
|
||||
b.HasKey(x => x.Id);
|
||||
b.HasIndex(x => x.MaterialId).IsUnique();
|
||||
b.HasIndex(x => x.ContainerId);
|
||||
b.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StockEventConfiguration : IEntityTypeConfiguration<StockEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockEvent> b)
|
||||
{
|
||||
b.ToTable("wms_stock_events");
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.EventType).HasMaxLength(32);
|
||||
b.Property(x => x.MaterialCode).HasMaxLength(64);
|
||||
b.Property(x => x.MaterialName).HasMaxLength(128);
|
||||
b.Property(x => x.MaterialBarcode).HasMaxLength(128);
|
||||
b.Property(x => x.MaterialTypeCode).HasMaxLength(64);
|
||||
b.Property(x => x.ContainerCode).HasMaxLength(64);
|
||||
b.Property(x => x.ContainerName).HasMaxLength(128);
|
||||
b.Property(x => x.StorageCode).HasMaxLength(64);
|
||||
b.Property(x => x.StorageName).HasMaxLength(128);
|
||||
b.Property(x => x.AreaCode).HasMaxLength(64);
|
||||
b.Property(x => x.FromStorageCode).HasMaxLength(64);
|
||||
b.Property(x => x.FromStorageName).HasMaxLength(128);
|
||||
b.Property(x => x.ToStorageCode).HasMaxLength(64);
|
||||
b.Property(x => x.ToStorageName).HasMaxLength(128);
|
||||
b.Property(x => x.RefType).HasMaxLength(64);
|
||||
b.Property(x => x.RefCode).HasMaxLength(64);
|
||||
b.Property(x => x.Operator).HasMaxLength(128);
|
||||
b.Property(x => x.Reason).HasMaxLength(500);
|
||||
b.HasIndex(x => x.OperatedAt);
|
||||
b.HasIndex(x => x.EventType);
|
||||
b.HasIndex(x => x.MaterialId);
|
||||
b.HasIndex(x => x.ContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class HistoryConfigurationBase<T> : IEntityTypeConfiguration<T> where T : HistoryEntity
|
||||
{
|
||||
private readonly string _table;
|
||||
protected HistoryConfigurationBase(string table) => _table = table;
|
||||
|
||||
public virtual void Configure(EntityTypeBuilder<T> b)
|
||||
{
|
||||
b.ToTable(_table);
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.EventType).HasMaxLength(64);
|
||||
b.Property(x => x.BeforeJson).HasColumnType("text");
|
||||
b.Property(x => x.AfterJson).HasColumnType("text");
|
||||
b.Property(x => x.Operator).HasMaxLength(128);
|
||||
b.Property(x => x.Source).HasMaxLength(32);
|
||||
b.Property(x => x.Reason).HasMaxLength(500);
|
||||
b.Property(x => x.Remark).HasMaxLength(1000);
|
||||
b.Property(x => x.Extend).HasColumnType("text");
|
||||
b.HasIndex(x => x.ContainerId);
|
||||
b.HasIndex(x => x.OperatedAt);
|
||||
b.HasIndex(x => x.EventType);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ContainerLocationHistoryConfiguration : HistoryConfigurationBase<ContainerLocationHistory>
|
||||
{
|
||||
public ContainerLocationHistoryConfiguration() : base("wms_container_location_history") { }
|
||||
}
|
||||
|
||||
public sealed class ContainerMaterialHistoryConfiguration : HistoryConfigurationBase<ContainerMaterialHistory>
|
||||
{
|
||||
public ContainerMaterialHistoryConfiguration() : base("wms_container_material_history") { }
|
||||
|
||||
public override void Configure(EntityTypeBuilder<ContainerMaterialHistory> b)
|
||||
{
|
||||
base.Configure(b);
|
||||
b.Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MiGu.DB.Kernel.Entities;
|
||||
|
||||
namespace MiGu.DB.Domains.Wms;
|
||||
|
||||
// WMS 聚合实体。状态/类型字段为枚举(库内 TEXT,见全局 HasConversion);DTO 仍用字符串,由 *Statuses 辅助类解析。
|
||||
|
||||
public sealed class Warehouse : AggregateRoot
|
||||
{
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(64)] public string Type { get; set; } = "Default";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WarehouseArea : AggregateRoot
|
||||
{
|
||||
public Guid WarehouseId { get; set; }
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(64)] public string Type { get; set; } = "Storage";
|
||||
public AreaLayoutMode LayoutMode { get; set; } = AreaLayoutMode.Flat;
|
||||
[MaxLength(32)] public string State { get; set; } = "Default";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Storage : AggregateRoot
|
||||
{
|
||||
public Guid AreaId { get; set; }
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(64)] public string StorageType { get; set; } = StorageTypeCodes.Storage;
|
||||
// 属性名与枚举类型同名合法;默认值引用枚举成员 LocationKind.Station(勿再引入同名静态类)
|
||||
public LocationKind LocationKind { get; set; } = LocationKind.Station;
|
||||
public int ColumnNo { get; set; }
|
||||
public int LevelNo { get; set; } = 1;
|
||||
public int DepthNo { get; set; } = 1;
|
||||
[MaxLength(64)] public string SiteId { get; set; } = "";
|
||||
[MaxLength(64)] public string SiteCode { get; set; } = "";
|
||||
[MaxLength(128)] public string Barcode { get; set; } = "";
|
||||
public int Capacity { get; set; }
|
||||
public StorageStatus Status { get; set; } = StorageStatus.Empty;
|
||||
[MaxLength(64)] public string Usage { get; set; } = "";
|
||||
public int Priority { get; set; }
|
||||
[MaxLength(64)] public string ZoneCode { get; set; } = "";
|
||||
public bool AllowInbound { get; set; } = true;
|
||||
public bool AllowOutbound { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class Container : AggregateRoot
|
||||
{
|
||||
public Guid? AreaId { get; set; }
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(64)] public string ContainerType { get; set; } = "Box";
|
||||
public ContainerStatus Status { get; set; } = ContainerStatus.EmptyMaterial;
|
||||
[MaxLength(128)] public string Barcode { get; set; } = "";
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class MaterialType : AggregateRoot
|
||||
{
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(128)] public string Spec { get; set; } = "";
|
||||
[MaxLength(32)] public string Unit { get; set; } = "pcs";
|
||||
[MaxLength(64)] public string Category { get; set; } = "";
|
||||
[MaxLength(64)] public string BarcodePrefix { get; set; } = "";
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class Material : AggregateRoot
|
||||
{
|
||||
[MaxLength(64)] public string Code { get; set; } = "";
|
||||
[MaxLength(128)] public string Name { get; set; } = "";
|
||||
[MaxLength(64)] public string TypeCode { get; set; } = "";
|
||||
[MaxLength(128)] public string Barcode { get; set; } = "";
|
||||
[MaxLength(128)] public string Spec { get; set; } = "";
|
||||
[MaxLength(32)] public string Unit { get; set; } = "pcs";
|
||||
[MaxLength(64)] public string Category { get; set; } = "";
|
||||
public MaterialLifecycle LifecycleStatus { get; set; } = MaterialLifecycle.Active;
|
||||
public DateTimeOffset? UnboundAt { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ContainerLocation : AggregateRoot
|
||||
{
|
||||
public Guid ContainerId { get; set; }
|
||||
public ContainerLocationType LocationType { get; set; } = ContainerLocationType.Storage;
|
||||
[MaxLength(64)] public string LocationId { get; set; } = "";
|
||||
/// <summary>LocationType=Storage 时的库位 Id;由 LocationId 回填,供库存 join 下推。</summary>
|
||||
public Guid? StorageId { get; set; }
|
||||
[MaxLength(64)] public string LocationCode { get; set; } = "";
|
||||
[MaxLength(128)] public string LocationName { get; set; } = "";
|
||||
public ContainerLocationStatus Status { get; set; } = ContainerLocationStatus.Active;
|
||||
public DateTimeOffset EnteredAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ContainerMaterial : AggregateRoot
|
||||
{
|
||||
public Guid ContainerId { get; set; }
|
||||
public Guid MaterialId { get; set; }
|
||||
public decimal Quantity { get; set; } = 1;
|
||||
[MaxLength(64)] public string BatchNo { get; set; } = "";
|
||||
[MaxLength(64)] public string SerialNo { get; set; } = "";
|
||||
public ContainerMaterialStatus Status { get; set; } = ContainerMaterialStatus.Bound;
|
||||
public DateTimeOffset BoundAt { get; set; }
|
||||
public DateTimeOffset LoadedAt { get; set; }
|
||||
public DateTimeOffset? UnloadedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StockEvent : Kernel.Entities.Entity<Guid>
|
||||
{
|
||||
public StockEventType EventType { get; set; }
|
||||
public Guid? MaterialId { get; set; }
|
||||
[MaxLength(64)] public string MaterialCode { get; set; } = "";
|
||||
[MaxLength(128)] public string MaterialName { get; set; } = "";
|
||||
[MaxLength(128)] public string MaterialBarcode { get; set; } = "";
|
||||
[MaxLength(64)] public string MaterialTypeCode { get; set; } = "";
|
||||
public Guid? ContainerId { get; set; }
|
||||
[MaxLength(64)] public string ContainerCode { get; set; } = "";
|
||||
[MaxLength(128)] public string ContainerName { get; set; } = "";
|
||||
public Guid? StorageId { get; set; }
|
||||
[MaxLength(64)] public string StorageCode { get; set; } = "";
|
||||
[MaxLength(128)] public string StorageName { get; set; } = "";
|
||||
[MaxLength(64)] public string AreaCode { get; set; } = "";
|
||||
public Guid? FromStorageId { get; set; }
|
||||
[MaxLength(64)] public string FromStorageCode { get; set; } = "";
|
||||
[MaxLength(128)] public string FromStorageName { get; set; } = "";
|
||||
public Guid? ToStorageId { get; set; }
|
||||
[MaxLength(64)] public string ToStorageCode { get; set; } = "";
|
||||
[MaxLength(128)] public string ToStorageName { get; set; } = "";
|
||||
[MaxLength(64)] public string RefType { get; set; } = "Manual";
|
||||
public Guid? RefId { get; set; }
|
||||
[MaxLength(64)] public string RefCode { get; set; } = "";
|
||||
[MaxLength(128)] public string Operator { get; set; } = "";
|
||||
public DateTimeOffset OperatedAt { get; set; }
|
||||
[MaxLength(500)] public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ContainerLocationHistory : HistoryEntity
|
||||
{
|
||||
[MaxLength(32)] public string FromLocationType { get; set; } = "";
|
||||
[MaxLength(64)] public string FromLocationId { get; set; } = "";
|
||||
[MaxLength(32)] public string ToLocationType { get; set; } = "";
|
||||
[MaxLength(64)] public string ToLocationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ContainerMaterialHistory : HistoryEntity
|
||||
{
|
||||
public Guid? MaterialId { get; set; }
|
||||
public decimal QuantityDelta { get; set; }
|
||||
}
|
||||
|
||||
public static class StorageTypeCodes
|
||||
{
|
||||
public const string Storage = "Storage";
|
||||
public const string LineSide = "LineSide";
|
||||
public const string OfflinePoint = "OfflinePoint";
|
||||
public const string Buffer = "Buffer";
|
||||
public const string FinishedGoods = "FinishedGoods";
|
||||
}
|
||||
|
||||
public static class WmsDefaults
|
||||
{
|
||||
public const string DefaultWarehouseCode = "DEFAULT";
|
||||
public const string DefaultWarehouseName = "默认仓库";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace MiGu.DB.Domains.Wms;
|
||||
|
||||
/// <summary>
|
||||
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion<string>),成员名即库内合法值。
|
||||
/// </summary>
|
||||
public enum AreaLayoutMode
|
||||
{
|
||||
Flat,
|
||||
Grid
|
||||
}
|
||||
|
||||
public enum LocationKind
|
||||
{
|
||||
Grid,
|
||||
Station
|
||||
}
|
||||
|
||||
public enum StorageStatus
|
||||
{
|
||||
Empty,
|
||||
EmptyContainer,
|
||||
FullContainer,
|
||||
Disabled
|
||||
}
|
||||
|
||||
public enum ContainerStatus
|
||||
{
|
||||
EmptyMaterial,
|
||||
FullMaterial
|
||||
}
|
||||
|
||||
public enum ContainerLocationType
|
||||
{
|
||||
Storage,
|
||||
Car
|
||||
}
|
||||
|
||||
public enum ContainerLocationStatus
|
||||
{
|
||||
Active,
|
||||
Locked,
|
||||
Exception
|
||||
}
|
||||
|
||||
public enum ContainerMaterialStatus
|
||||
{
|
||||
Bound,
|
||||
Loaded,
|
||||
Unloaded,
|
||||
Adjusted,
|
||||
Frozen
|
||||
}
|
||||
|
||||
public enum MaterialLifecycle
|
||||
{
|
||||
Active,
|
||||
Archived
|
||||
}
|
||||
|
||||
public enum StockEventType
|
||||
{
|
||||
Bind,
|
||||
Unbind,
|
||||
ContainerMove
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace MiGu.DB.Domains.Wms;
|
||||
|
||||
/// <summary>
|
||||
/// 枚举辅助类(复数命名):承接 API/DTO 的字符串入参,解析为实体上的枚举。
|
||||
/// 与同名枚举分离,避免「属性 LocationKind 初始值引用实例属性」一类编译冲突。
|
||||
/// Server 侧通过 global using 别名(如 StorageStatuses)引用本文件类型。
|
||||
/// </summary>
|
||||
public static class AreaLayoutModes
|
||||
{
|
||||
public static readonly HashSet<AreaLayoutMode> All = new() { AreaLayoutMode.Flat, AreaLayoutMode.Grid };
|
||||
|
||||
public static AreaLayoutMode ParseOr(string? value, AreaLayoutMode fallback = AreaLayoutMode.Flat) =>
|
||||
Enum.TryParse<AreaLayoutMode>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class LocationKinds
|
||||
{
|
||||
public const LocationKind Grid = LocationKind.Grid;
|
||||
public const LocationKind Station = LocationKind.Station;
|
||||
public static readonly HashSet<LocationKind> All = new() { Grid, Station };
|
||||
|
||||
public static LocationKind ParseOr(string? value, LocationKind fallback = LocationKind.Station) =>
|
||||
Enum.TryParse<LocationKind>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class StorageStatuses
|
||||
{
|
||||
public const StorageStatus Empty = StorageStatus.Empty;
|
||||
public const StorageStatus EmptyContainer = StorageStatus.EmptyContainer;
|
||||
public const StorageStatus FullContainer = StorageStatus.FullContainer;
|
||||
public const StorageStatus Disabled = StorageStatus.Disabled;
|
||||
|
||||
public static readonly HashSet<StorageStatus> All = new()
|
||||
{ Empty, EmptyContainer, FullContainer, Disabled };
|
||||
|
||||
public static StorageStatus ParseOr(string? value, StorageStatus fallback = StorageStatus.Empty) =>
|
||||
Enum.TryParse<StorageStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class ContainerStatuses
|
||||
{
|
||||
public const ContainerStatus EmptyMaterial = ContainerStatus.EmptyMaterial;
|
||||
public const ContainerStatus FullMaterial = ContainerStatus.FullMaterial;
|
||||
public static readonly HashSet<ContainerStatus> All = new() { EmptyMaterial, FullMaterial };
|
||||
|
||||
public static ContainerStatus ParseOr(string? value, ContainerStatus fallback = ContainerStatus.EmptyMaterial) =>
|
||||
Enum.TryParse<ContainerStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class ContainerLocationTypes
|
||||
{
|
||||
public const ContainerLocationType Storage = ContainerLocationType.Storage;
|
||||
public const ContainerLocationType Car = ContainerLocationType.Car;
|
||||
public static readonly HashSet<ContainerLocationType> All = new() { Storage, Car };
|
||||
|
||||
public static bool IsDefined(string? value) =>
|
||||
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e);
|
||||
|
||||
public static ContainerLocationType ParseOr(string? value, ContainerLocationType fallback = ContainerLocationType.Storage) =>
|
||||
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
|
||||
public static bool EqualsString(ContainerLocationType value, string? other) =>
|
||||
Enum.TryParse<ContainerLocationType>(other, true, out var e) && e == value;
|
||||
}
|
||||
|
||||
public static class ContainerLocationStatuses
|
||||
{
|
||||
public const ContainerLocationStatus Active = ContainerLocationStatus.Active;
|
||||
public const ContainerLocationStatus Locked = ContainerLocationStatus.Locked;
|
||||
public const ContainerLocationStatus Exception = ContainerLocationStatus.Exception;
|
||||
public static readonly HashSet<ContainerLocationStatus> All = new() { Active, Locked, Exception };
|
||||
|
||||
public static bool IsDefined(string? value) =>
|
||||
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e);
|
||||
|
||||
public static ContainerLocationStatus ParseOr(string? value, ContainerLocationStatus fallback = ContainerLocationStatus.Active) =>
|
||||
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class ContainerMaterialStatuses
|
||||
{
|
||||
public const ContainerMaterialStatus Bound = ContainerMaterialStatus.Bound;
|
||||
public const ContainerMaterialStatus Loaded = ContainerMaterialStatus.Loaded;
|
||||
public const ContainerMaterialStatus Unloaded = ContainerMaterialStatus.Unloaded;
|
||||
public const ContainerMaterialStatus Adjusted = ContainerMaterialStatus.Adjusted;
|
||||
public const ContainerMaterialStatus Frozen = ContainerMaterialStatus.Frozen;
|
||||
|
||||
public static readonly HashSet<ContainerMaterialStatus> ActiveBind = new()
|
||||
{ Bound, Loaded, Adjusted, Frozen };
|
||||
}
|
||||
|
||||
public static class MaterialLifecycles
|
||||
{
|
||||
public const MaterialLifecycle Active = MaterialLifecycle.Active;
|
||||
public const MaterialLifecycle Archived = MaterialLifecycle.Archived;
|
||||
public static readonly HashSet<MaterialLifecycle> All = new() { Active, Archived };
|
||||
|
||||
public static MaterialLifecycle ParseOr(string? value, MaterialLifecycle fallback = MaterialLifecycle.Active) =>
|
||||
Enum.TryParse<MaterialLifecycle>(value, true, out var e) && All.Contains(e) ? e : fallback;
|
||||
}
|
||||
|
||||
public static class StockEventTypes
|
||||
{
|
||||
public const StockEventType Bind = StockEventType.Bind;
|
||||
public const StockEventType Unbind = StockEventType.Unbind;
|
||||
public const StockEventType ContainerMove = StockEventType.ContainerMove;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.DB.Domains.Dashboard;
|
||||
using MiGu.DB.Domains.Fleet;
|
||||
using MiGu.DB.Domains.SimpleFields;
|
||||
using MiGu.DB.Domains.Transport;
|
||||
using MiGu.DB.Domains.Wms;
|
||||
|
||||
namespace MiGu.DB.Kernel.Context;
|
||||
|
||||
public partial class MiGuDbContext
|
||||
{
|
||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
|
||||
public DbSet<Storage> Storages => Set<Storage>();
|
||||
public DbSet<Container> Containers => Set<Container>();
|
||||
public DbSet<MaterialType> MaterialTypes => Set<MaterialType>();
|
||||
public DbSet<Material> Materials => Set<Material>();
|
||||
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
|
||||
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
||||
public DbSet<StockEvent> StockEvents => Set<StockEvent>();
|
||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||
public DbSet<WmsTransportRule> WmsTransportRules => Set<WmsTransportRule>();
|
||||
public DbSet<WmsTransportTask> WmsTransportTasks => Set<WmsTransportTask>();
|
||||
public DbSet<WmsTransportReservation> WmsTransportReservations => Set<WmsTransportReservation>();
|
||||
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||
public DbSet<CdmTaskRecord> CdmTasks => Set<CdmTaskRecord>();
|
||||
public DbSet<VehicleAlarmRecord> VehicleAlarms => Set<VehicleAlarmRecord>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.DB.Abstractions.Modules;
|
||||
using MiGu.DB.Kernel.Conventions;
|
||||
|
||||
namespace MiGu.DB.Kernel.Context;
|
||||
|
||||
public partial class MiGuDbContext : DbContext
|
||||
{
|
||||
private readonly IEnumerable<IEntityModule> _modules;
|
||||
|
||||
public MiGuDbContext(DbContextOptions<MiGuDbContext> options, IEnumerable<IEntityModule>? modules = null)
|
||||
: base(options)
|
||||
{
|
||||
_modules = modules ?? Array.Empty<IEntityModule>();
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// 先约定、后 Configuration,让实体级配置覆盖全局 Guid/时间转换(如 SimpleField)
|
||||
ModelConventionExtensions.ApplyMiGuConventions(modelBuilder);
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MiGuDbContext).Assembly);
|
||||
foreach (var module in _modules)
|
||||
module.ConfigureModel(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.DB.Abstractions.Entities;
|
||||
using MiGu.DB.Abstractions.Runtime;
|
||||
|
||||
namespace MiGu.DB.Kernel.Conventions;
|
||||
|
||||
/// <summary>
|
||||
/// 全局模型约定:Guid/DateTimeOffset 字符串化、枚举存字符串、软删过滤、Version 并发令牌等。
|
||||
/// </summary>
|
||||
public static class ModelConventionExtensions
|
||||
{
|
||||
public static void ApplyMiGuConventions(ModelBuilder modelBuilder)
|
||||
{
|
||||
// 与历史 Sqlite TEXT 列兼容:Guid / DateTimeOffset 均以字符串落库
|
||||
var guid = new ValueConverter<Guid, string>(v => v.ToString("D"), v => Guid.Parse(v));
|
||||
var nullableGuid = new ValueConverter<Guid?, string?>(
|
||||
v => v.HasValue ? v.Value.ToString("D") : null,
|
||||
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
|
||||
var dto = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
var nullableDto = new ValueConverter<DateTimeOffset?, string?>(
|
||||
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
|
||||
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
var clr = entityType.ClrType;
|
||||
|
||||
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
|
||||
modelBuilder.Entity(clr).Property(p.Name).HasConversion(guid).HasMaxLength(36);
|
||||
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
|
||||
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
|
||||
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
|
||||
modelBuilder.Entity(clr).Property(p.Name).HasConversion(dto).HasMaxLength(40);
|
||||
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
|
||||
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableDto).HasMaxLength(40);
|
||||
|
||||
// 严格 1:1 字符串转换;旧值兼容靠 IDataMigrator,禁止在 converter 读侧 Normalize
|
||||
foreach (var p in clr.GetProperties().Where(p => p.PropertyType.IsEnum))
|
||||
modelBuilder.Entity(clr).Property(p.Name).HasConversion<string>().HasMaxLength(32);
|
||||
|
||||
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
|
||||
{
|
||||
var method = typeof(ModelConventionExtensions)
|
||||
.GetMethod(nameof(SetSoftDeleteFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
|
||||
.MakeGenericMethod(clr);
|
||||
method.Invoke(null, new object[] { modelBuilder });
|
||||
}
|
||||
|
||||
// Version:业务乐观锁(非数据库 rowversion 字节数组)
|
||||
if (typeof(IVersioned).IsAssignableFrom(clr))
|
||||
modelBuilder.Entity(clr).Property(nameof(IVersioned.Version)).IsConcurrencyToken();
|
||||
|
||||
if (typeof(ILockable).IsAssignableFrom(clr))
|
||||
modelBuilder.Entity(clr).Property(nameof(ILockable.IsLock)).HasColumnName("IsLock");
|
||||
|
||||
if (typeof(IAuditable).IsAssignableFrom(clr))
|
||||
{
|
||||
modelBuilder.Entity(clr).Property(nameof(IAuditable.CreatedBy)).HasMaxLength(128);
|
||||
modelBuilder.Entity(clr).Property(nameof(IAuditable.UpdatedBy)).HasMaxLength(128);
|
||||
}
|
||||
|
||||
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
|
||||
modelBuilder.Entity(clr).Property(nameof(ISoftDeletable.DeletedBy)).HasMaxLength(128);
|
||||
|
||||
if (typeof(IRemarkable).IsAssignableFrom(clr))
|
||||
modelBuilder.Entity(clr).Property(nameof(IRemarkable.Remark)).HasMaxLength(1000);
|
||||
|
||||
if (typeof(IExtendable).IsAssignableFrom(clr))
|
||||
modelBuilder.Entity(clr).Property(nameof(IExtendable.Extend)).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, ISoftDeletable
|
||||
=> modelBuilder.Entity<TEntity>().HasQueryFilter(e => !e.IsDeleted);
|
||||
}
|
||||
|
||||
public sealed class ActorContextAccessor : IActorContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<IActorContext?> CurrentContext = new();
|
||||
|
||||
public IActorContext Current
|
||||
{
|
||||
get => CurrentContext.Value ?? SystemActorContext.Instance;
|
||||
set => CurrentContext.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SystemActorContext : IActorContext
|
||||
{
|
||||
public static readonly SystemActorContext Instance = new();
|
||||
public string Name => "system";
|
||||
}
|
||||
|
||||
public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
private readonly IActorContextAccessor _actors;
|
||||
|
||||
public AuditSaveChangesInterceptor(IActorContextAccessor actors) => _actors = actors;
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
||||
{
|
||||
Stamp(eventData.Context);
|
||||
return base.SavingChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Stamp(eventData.Context);
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void Stamp(DbContext? db)
|
||||
{
|
||||
if (db is null) return;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var actor = _actors.Current.Name;
|
||||
|
||||
foreach (var entry in db.ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.Entity is IAuditable auditable)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
if (entry.Entity is IEntity<Guid> { Id: var id } && id == Guid.Empty)
|
||||
((IEntity<Guid>)entry.Entity).Id = Guid.NewGuid();
|
||||
auditable.CreatedAt = now;
|
||||
auditable.UpdatedAt = now;
|
||||
if (string.IsNullOrWhiteSpace(auditable.CreatedBy)) auditable.CreatedBy = actor;
|
||||
if (string.IsNullOrWhiteSpace(auditable.UpdatedBy)) auditable.UpdatedBy = actor;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
auditable.UpdatedAt = now;
|
||||
auditable.UpdatedBy = actor;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Entity is IVersioned versioned)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
versioned.Version = Math.Max(1, versioned.Version);
|
||||
else if (entry.State == EntityState.Modified)
|
||||
versioned.Version += 1;
|
||||
}
|
||||
|
||||
if (entry.Entity is IExtendable extendable && string.IsNullOrWhiteSpace(extendable.Extend))
|
||||
extendable.Extend = "{}";
|
||||
|
||||
if (entry.Entity is ISoftDeletable soft
|
||||
&& entry.State == EntityState.Modified
|
||||
&& entry.Property(nameof(ISoftDeletable.IsDeleted)).IsModified
|
||||
&& soft.IsDeleted)
|
||||
{
|
||||
soft.DeletedAt ??= now;
|
||||
if (string.IsNullOrWhiteSpace(soft.DeletedBy)) soft.DeletedBy = actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using MiGu.DB.Kernel.Context;
|
||||
|
||||
namespace MiGu.DB.Kernel.Design;
|
||||
|
||||
/// <summary>
|
||||
/// design-time 工厂(dotnet ef migrations add)。指定 MigrationsAssembly,与运行时 SqliteProviderSetup 一致。
|
||||
/// </summary>
|
||||
public sealed class MiGuDbContextFactory : IDesignTimeDbContextFactory<MiGuDbContext>
|
||||
{
|
||||
public MiGuDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MiGuDbContext>()
|
||||
.UseSqlite("Data Source=platform.db", o =>
|
||||
o.MigrationsAssembly(typeof(MiGuDbContext).Assembly.GetName().Name))
|
||||
.Options;
|
||||
return new MiGuDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MiGu.DB.Abstractions.Entities;
|
||||
|
||||
namespace MiGu.DB.Kernel.Entities;
|
||||
|
||||
public abstract class Entity<TKey> : IEntity<TKey>
|
||||
{
|
||||
public TKey Id { get; set; } = default!;
|
||||
}
|
||||
|
||||
public abstract class AuditedEntity : Entity<Guid>, IAuditable
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = "";
|
||||
public string UpdatedBy { get; set; } = "";
|
||||
}
|
||||
|
||||
public abstract class SoftDeletableEntity : AuditedEntity, ISoftDeletable, IVersioned, ILockable
|
||||
{
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
public string DeletedBy { get; set; } = "";
|
||||
public long Version { get; set; }
|
||||
public bool IsLock { get; set; }
|
||||
}
|
||||
|
||||
public abstract class AggregateRoot : SoftDeletableEntity, IRemarkable, IExtendable
|
||||
{
|
||||
public string Remark { get; set; } = "";
|
||||
public string Extend { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public abstract class HistoryEntity : Entity<Guid>, IHistoryEntry
|
||||
{
|
||||
public Guid? RelationId { get; set; }
|
||||
public string EventType { get; set; } = "";
|
||||
public string BeforeJson { get; set; } = "{}";
|
||||
public string AfterJson { get; set; } = "{}";
|
||||
public Guid? ContainerId { get; set; }
|
||||
public string Operator { get; set; } = "";
|
||||
public DateTimeOffset OperatedAt { get; set; }
|
||||
public string Source { get; set; } = "Manual";
|
||||
public string Reason { get; set; } = "";
|
||||
public string Remark { get; set; } = "";
|
||||
public string Extend { get; set; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MiGu.DB.Abstractions.Modules;
|
||||
using MiGu.DB.Abstractions.Persistence;
|
||||
using MiGu.DB.Abstractions.Providers;
|
||||
using MiGu.DB.Abstractions.Runtime;
|
||||
using MiGu.DB.Kernel.Context;
|
||||
using MiGu.DB.Kernel.Conventions;
|
||||
using MiGu.DB.Kernel.Providers;
|
||||
using MiGu.DB.Kernel.Repositories;
|
||||
using MiGu.DB.Domains.Migrators;
|
||||
|
||||
namespace MiGu.DB.Kernel.Hosting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMiGuDb(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<MiGuDbOptions>? configure = null)
|
||||
{
|
||||
var options = new MiGuDbOptions
|
||||
{
|
||||
Provider = configuration["Database:Provider"] ?? "sqlite",
|
||||
ConnectionString = configuration.GetConnectionString("Platform")
|
||||
?? configuration.GetConnectionString(ConnectionKey(configuration["Database:Provider"] ?? "sqlite"))
|
||||
?? "",
|
||||
SchemaMode = ParseSchemaMode(configuration["Database:SchemaMode"]),
|
||||
ApplyDataMigratorsOnStartup = configuration.GetValue("Database:ApplyDataMigratorsOnStartup", true)
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
|
||||
services.TryAddSingleton<IActorContextAccessor, ActorContextAccessor>();
|
||||
services.TryAddScoped<IActorContext>(sp => sp.GetRequiredService<IActorContextAccessor>().Current);
|
||||
services.AddSingleton<AuditSaveChangesInterceptor>();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqliteProviderSetup>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, MySqlProviderSetup>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, NpgsqlProviderSetup>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqlServerProviderSetup>());
|
||||
|
||||
services.AddDbContext<MiGuDbContext>((sp, builder) =>
|
||||
{
|
||||
var opt = sp.GetRequiredService<MiGuDbOptions>();
|
||||
if (string.IsNullOrWhiteSpace(opt.ContentRootPath))
|
||||
{
|
||||
var env = sp.GetService<IHostEnvironment>();
|
||||
opt.ContentRootPath = env?.ContentRootPath ?? AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
var providerName = NormalizeProviderName(opt.Provider);
|
||||
var setup = sp.GetServices<IDbProviderSetup>()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"未知数据库 Provider: {opt.Provider}");
|
||||
|
||||
setup.Configure(builder, ResolveConnectionString(opt, providerName));
|
||||
builder.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
|
||||
});
|
||||
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
|
||||
services.AddScoped(typeof(IEditableRepository<>), typeof(EditableRepository<>));
|
||||
services.AddScoped(typeof(IHistoryRepository<>), typeof(HistoryRepository<>));
|
||||
|
||||
services.AddMiGuDataMigrators();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddMiGuEntityModule<TModule>(this IServiceCollection services)
|
||||
where TModule : class, IEntityModule, new()
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IEntityModule, TModule>());
|
||||
new TModule().RegisterServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static string NormalizeProviderName(string provider) => provider.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"postgres" or "postgresql" => "npgsql",
|
||||
"mssql" => "sqlserver",
|
||||
var x => x
|
||||
};
|
||||
|
||||
private static MiGuSchemaMode ParseSchemaMode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return MiGuSchemaMode.Migrate;
|
||||
if (Enum.TryParse<MiGuSchemaMode>(value.Trim(), ignoreCase: true, out var mode))
|
||||
return mode;
|
||||
// 兼容简写
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"ensure" or "created" or "ensure-created" => MiGuSchemaMode.EnsureCreated,
|
||||
"migrations" or "ef" => MiGuSchemaMode.Migrate,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"未知 Database:SchemaMode '{value}',允许值:Migrate、EnsureCreated")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConnectionKey(string provider) => NormalizeProviderName(provider) switch
|
||||
{
|
||||
"npgsql" => "PostgreSQL",
|
||||
"sqlserver" => "SqlServer",
|
||||
"mysql" => "MySql",
|
||||
_ => "Sqlite"
|
||||
};
|
||||
|
||||
private static string ResolveConnectionString(MiGuDbOptions options, string providerName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||
{
|
||||
return providerName == "sqlite"
|
||||
? NormalizeSqliteConnection(options.ConnectionString, options.ContentRootPath)
|
||||
: options.ConnectionString;
|
||||
}
|
||||
|
||||
var dataDir = Path.Combine(options.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
|
||||
}
|
||||
|
||||
private static string NormalizeSqliteConnection(string connection, string contentRoot)
|
||||
{
|
||||
var builder = new SqliteConnectionStringBuilder(connection);
|
||||
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource is ":memory:")
|
||||
return connection;
|
||||
if (!Path.IsPathRooted(builder.DataSource))
|
||||
builder.DataSource = Path.Combine(contentRoot, builder.DataSource);
|
||||
var dir = Path.GetDirectoryName(builder.DataSource);
|
||||
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public static class DatabaseInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动期数据库初始化:按 SchemaMode 建库/迁移,再可选执行 DataMigrator。
|
||||
/// </summary>
|
||||
public static async Task MigrateMiGuDbAsync(this IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<MiGuDbOptions>();
|
||||
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("MiGu.DB.DatabaseInitializer");
|
||||
|
||||
if (options.SchemaMode == MiGuSchemaMode.EnsureCreated)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Database:SchemaMode=EnsureCreated:仅按当前模型建库,不应用 Migrations。" +
|
||||
"改实体后若结构未更新,请删除平台库文件后重启。发版请改用 Migrate。");
|
||||
await db.Database.EnsureCreatedAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var applied = (await db.Database.GetAppliedMigrationsAsync(ct)).ToList();
|
||||
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
|
||||
|
||||
if (applied.Count == 0 && pending.Count == 0)
|
||||
{
|
||||
logger?.LogWarning("程序集内无 Migration,回退 EnsureCreated。");
|
||||
await db.Database.EnsureCreatedAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 旧库(EnsureCreated)无历史表行时写入基线,再 Migrate
|
||||
await BaselineExistingDatabaseAsync(db, logger, ct);
|
||||
await db.Database.MigrateAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.ApplyDataMigratorsOnStartup) return;
|
||||
|
||||
foreach (var migrator in scope.ServiceProvider.GetServices<IDataMigrator>().OrderBy(m => m.Order))
|
||||
await migrator.MigrateAsync(db, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 已有表结构但无 __EFMigrationsHistory 时:将<strong>全部</strong> pending Migration 记为已应用。
|
||||
/// 前提:库由 EnsureCreated 按「当前模型 tip」建成,与最新 Snapshot 一致;否则应删库后走 Migrate,或手工对齐。
|
||||
/// </summary>
|
||||
private static async Task BaselineExistingDatabaseAsync(
|
||||
MiGuDbContext db, ILogger? logger, CancellationToken ct)
|
||||
{
|
||||
var applied = await db.Database.GetAppliedMigrationsAsync(ct);
|
||||
if (applied.Any()) return;
|
||||
|
||||
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
var creator = db.GetService<IRelationalDatabaseCreator>();
|
||||
if (!await creator.ExistsAsync(ct) || !await creator.HasTablesAsync(ct))
|
||||
return;
|
||||
|
||||
var history = db.GetService<IHistoryRepository>();
|
||||
var productVersion = ProductInfo.GetEFCoreVersion();
|
||||
logger?.LogWarning(
|
||||
"检测到已有表且无迁移历史,将 {Count} 个 pending Migration 全部写入基线(假定库结构已对齐模型 tip):{Ids}",
|
||||
pending.Count, string.Join(", ", pending));
|
||||
|
||||
foreach (var migrationId in pending)
|
||||
{
|
||||
var sql = history.GetInsertScript(new HistoryRow(migrationId, productVersion));
|
||||
await db.Database.ExecuteSqlRawAsync(sql, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ProductInfo
|
||||
{
|
||||
public static string GetEFCoreVersion()
|
||||
{
|
||||
var asm = typeof(DbContext).Assembly.GetName().Version;
|
||||
return asm?.ToString(3) ?? "8.0.10";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.DB.Abstractions.Providers;
|
||||
using MiGu.DB.Kernel.Context;
|
||||
|
||||
namespace MiGu.DB.Kernel.Providers;
|
||||
|
||||
public sealed class SqliteProviderSetup : IDbProviderSetup
|
||||
{
|
||||
public string Name => "sqlite";
|
||||
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
|
||||
public string MigrationsNamespace => "MiGu.DB.Migrations.Sqlite";
|
||||
|
||||
public void Configure(DbContextOptionsBuilder builder, string connectionString)
|
||||
=> builder.UseSqlite(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName)
|
||||
.MigrationsHistoryTable("__EFMigrationsHistory"));
|
||||
}
|
||||
|
||||
public sealed class MySqlProviderSetup : IDbProviderSetup
|
||||
{
|
||||
public string Name => "mysql";
|
||||
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
|
||||
public string MigrationsNamespace => "MiGu.DB.Migrations.MySql";
|
||||
|
||||
public void Configure(DbContextOptionsBuilder builder, string connectionString)
|
||||
=> builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
|
||||
o => o.MigrationsAssembly(MigrationsAssemblyName));
|
||||
}
|
||||
|
||||
public sealed class NpgsqlProviderSetup : IDbProviderSetup
|
||||
{
|
||||
public string Name => "npgsql";
|
||||
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
|
||||
public string MigrationsNamespace => "MiGu.DB.Migrations.Npgsql";
|
||||
|
||||
public void Configure(DbContextOptionsBuilder builder, string connectionString)
|
||||
=> builder.UseNpgsql(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
|
||||
}
|
||||
|
||||
public sealed class SqlServerProviderSetup : IDbProviderSetup
|
||||
{
|
||||
public string Name => "sqlserver";
|
||||
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
|
||||
public string MigrationsNamespace => "MiGu.DB.Migrations.SqlServer";
|
||||
|
||||
public void Configure(DbContextOptionsBuilder builder, string connectionString)
|
||||
=> builder.UseSqlServer(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.DB.Abstractions.Entities;
|
||||
using MiGu.DB.Abstractions.Exceptions;
|
||||
using MiGu.DB.Abstractions.Persistence;
|
||||
using MiGu.DB.Abstractions.Runtime;
|
||||
using MiGu.DB.Kernel.Context;
|
||||
|
||||
namespace MiGu.DB.Kernel.Repositories;
|
||||
|
||||
public class Repository<TEntity, TKey> : IRepository<TEntity, TKey>
|
||||
where TEntity : class, IEntity<TKey>
|
||||
{
|
||||
protected readonly MiGuDbContext Db;
|
||||
protected DbSet<TEntity> Set => Db.Set<TEntity>();
|
||||
|
||||
public Repository(MiGuDbContext db) => Db = db;
|
||||
|
||||
public virtual IQueryable<TEntity> Query(bool asNoTracking = true)
|
||||
=> asNoTracking ? Set.AsNoTracking() : Set.AsQueryable();
|
||||
|
||||
public virtual Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default)
|
||||
=> Set.FindAsync([id], ct).AsTask();
|
||||
|
||||
public virtual Task AddAsync(TEntity entity, CancellationToken ct = default)
|
||||
=> Set.AddAsync(entity, ct).AsTask();
|
||||
|
||||
public virtual void Update(TEntity entity) => Set.Update(entity);
|
||||
}
|
||||
|
||||
public sealed class EditableRepository<TEntity> : Repository<TEntity, Guid>, IEditableRepository<TEntity>
|
||||
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
|
||||
{
|
||||
private readonly IActorContextAccessor _actors;
|
||||
|
||||
public EditableRepository(MiGuDbContext db, IActorContextAccessor actors) : base(db)
|
||||
=> _actors = actors;
|
||||
|
||||
public async Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await Set.FirstOrDefaultAsync(x => x.Id.Equals(id), ct)
|
||||
?? throw new EntityNotFoundException(typeof(TEntity).Name, id);
|
||||
if (entity.IsLock)
|
||||
throw new EntityLockedException(typeof(TEntity).Name, id);
|
||||
if (expectedVersion.HasValue && entity.Version != expectedVersion.Value)
|
||||
throw new ConcurrencyConflictException(typeof(TEntity).Name, id, expectedVersion);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await GetEditableAsync(id, expectedVersion, ct);
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletedAt = DateTimeOffset.UtcNow;
|
||||
entity.DeletedBy = _actors.Current.Name;
|
||||
if (entity is IAuditable auditable)
|
||||
auditable.UpdatedBy = _actors.Current.Name;
|
||||
}
|
||||
|
||||
public async Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default)
|
||||
{
|
||||
if (await Set.AnyAsync(predicate, ct))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HistoryRepository<TEntity> : Repository<TEntity, Guid>, IHistoryRepository<TEntity>
|
||||
where TEntity : class, IEntity<Guid>, IHistoryEntry
|
||||
{
|
||||
public HistoryRepository(MiGuDbContext db) : base(db) { }
|
||||
|
||||
public Task AppendAsync(TEntity entry, CancellationToken ct = default)
|
||||
=> AddAsync(entry, ct);
|
||||
}
|
||||
|
||||
public sealed class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly MiGuDbContext _db;
|
||||
|
||||
public UnitOfWork(MiGuDbContext db) => _db = db;
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
var entry = ex.Entries.FirstOrDefault();
|
||||
throw new ConcurrencyConflictException(
|
||||
entry?.Entity.GetType().Name ?? "Unknown",
|
||||
entry?.Property("Id")?.CurrentValue);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
|
||||
{
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
await action(ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# MiGu.DB Migrations 使用手册
|
||||
|
||||
面向日常改表、发版与排错。当前默认 Provider 为 **Sqlite**,迁移目录为 `Migrations/Sqlite/`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 概念速览
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| Migration | 一次 Schema 变更(建表/加列/索引等),对应一对 `*_Name.cs` + `*_Name.Designer.cs` |
|
||||
| ModelSnapshot | `MiGuDbContextModelSnapshot.cs`,当前模型总快照;下次 `add` 时与代码模型做 diff |
|
||||
| `__EFMigrationsHistory` | 数据库内表,记录已应用的 Migration Id |
|
||||
| DataMigrator | **数据**修补(刷旧状态、回填列),不是 Schema;启动时在 Migrate 之后执行 |
|
||||
|
||||
**原则:Schema 只走 Migrations;业务代码禁止手写 CREATE/ALTER。**
|
||||
|
||||
三个文件职责:
|
||||
|
||||
- `YYYYMMDDHHMMSS_Name.cs` → `Up()`/`Down()`,真正改库
|
||||
- `YYYYMMDDHHMMSS_Name.Designer.cs` → 该次迁移的目标模型元数据(勿手改)
|
||||
- `MiGuDbContextModelSnapshot.cs` → 全库最新快照(勿手改,除非处理合并冲突)
|
||||
|
||||
---
|
||||
|
||||
## 2. 环境准备
|
||||
|
||||
### 2.1 工具
|
||||
|
||||
仓库根目录(`Migu2.0`)执行:
|
||||
|
||||
```powershell
|
||||
# 全局工具(任选)
|
||||
dotnet tool install --global dotnet-ef --version 8.0.10
|
||||
|
||||
# 或本地工具目录(本仓库曾用此方式)
|
||||
dotnet tool install dotnet-ef --version 8.0.10 --tool-path .\.tools
|
||||
.\.tools\dotnet-ef --version
|
||||
```
|
||||
|
||||
版本需与项目 EF Core **8.0.x** 对齐。
|
||||
|
||||
### 2.2 工程关系
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| 迁移所在工程 | `MiGu.DB` |
|
||||
| 启动工程 | `MiGu.Server`(提供配置与 Design 包) |
|
||||
| DbContext | `MiGu.DB.Kernel.Context.MiGuDbContext` |
|
||||
| Design-time 工厂 | `MiGu.DB.Kernel.Design.MiGuDbContextFactory` |
|
||||
|
||||
`MiGu.Server.csproj` 已引用 `Microsoft.EntityFrameworkCore.Design`;`MiGu.DB` 含 Sqlite 等 Provider 包。
|
||||
|
||||
---
|
||||
|
||||
## 3. 生成 Migration(日常流程)
|
||||
|
||||
### 3.1 改模型
|
||||
|
||||
在 `MiGu.DB/Domains` 改实体或 `IEntityTypeConfiguration`,保存后编译通过:
|
||||
|
||||
```powershell
|
||||
dotnet build .\MiGu.DB\MiGu.DB.csproj
|
||||
```
|
||||
|
||||
### 3.2 添加迁移
|
||||
|
||||
在**解决方案根目录**执行(PowerShell):
|
||||
|
||||
```powershell
|
||||
dotnet ef migrations add <迁移名称> `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--output-dir Migrations/Sqlite `
|
||||
--namespace MiGu.DB.Migrations.Sqlite `
|
||||
--context MiGuDbContext
|
||||
```
|
||||
|
||||
命名建议(PascalCase,无空格):
|
||||
|
||||
| 场景 | 示例名 |
|
||||
|------|--------|
|
||||
| 首库 | `InitialPlatform`(已存在,勿重复) |
|
||||
| 加表 | `AddWmsXxxTable` |
|
||||
| 加列 | `AddStoragePriorityColumn` |
|
||||
| 加索引 | `AddStockEventOperatedAtIndex` |
|
||||
|
||||
### 3.3 生成后检查(必做)
|
||||
|
||||
1. 新文件应在:`MiGu.DB/Migrations/Sqlite/`
|
||||
2. 打开 `*_Name.cs`,确认 `Up()` 只包含**本次预期**变更(无误删表、无多余重建)
|
||||
3. 确认 `MiGuDbContextModelSnapshot.cs` 仍在 `Migrations/Sqlite/`
|
||||
- 若出现在 `MiGu.DB/MiGu/DB/Migrations/Sqlite/` 等错误路径:把 Snapshot **移回**正确目录并删掉空目录(`--namespace` 偶发路径问题)
|
||||
|
||||
### 3.4 应用到本地库
|
||||
|
||||
启动 `MiGu.Server` 即可(`Program.cs` 直接调用 `MigrateMiGuDbAsync`),或:
|
||||
|
||||
```powershell
|
||||
dotnet ef database update `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 常用命令
|
||||
|
||||
```powershell
|
||||
# 列出迁移
|
||||
dotnet ef migrations list `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext
|
||||
|
||||
# 生成 SQL 脚本(发版/DBA 审阅,不直接连库执行也可)
|
||||
dotnet ef migrations script `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext `
|
||||
--output .\MiGu.DB\Migrations\Sqlite\script.sql
|
||||
|
||||
# 从某迁移到最新(含幂等脚本时加 --idempotent)
|
||||
dotnet ef migrations script FromMigration ToMigration `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext `
|
||||
--idempotent
|
||||
|
||||
# 删除「尚未应用到任何重要库」的最后一次迁移(仅开发)
|
||||
dotnet ef migrations remove `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext
|
||||
|
||||
# 回滚到指定迁移(会执行 Down,生产慎用)
|
||||
dotnet ef database update <目标迁移名> `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--context MiGuDbContext
|
||||
```
|
||||
|
||||
使用本地工具时,将 `dotnet ef` 换成 `.\.tools\dotnet-ef`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 启动时发生了什么
|
||||
|
||||
**完整说明(顺序、配置、SchemaMode、开发注意)见:**
|
||||
|
||||
→ **[MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)**
|
||||
|
||||
摘要:`MigrateMiGuDbAsync`;`Database:SchemaMode` 为 `EnsureCreated` 或 `Migrate`;之后按需跑 `IDataMigrator`。
|
||||
|
||||
**Migrate 基线:** 已有表、无 History 时,会把**全部** pending Migration 写入 `__EFMigrationsHistory`(假定 EnsureCreated 库已对齐模型 tip)。发版前若开发期改过模型,须先 `migrations add` 再切 `Migrate`。
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema 变更 vs 数据修补
|
||||
|
||||
| 需求 | 做法 |
|
||||
|------|------|
|
||||
| 新表/新列/索引/改列类型 | `migrations add` → 提交迁移文件 |
|
||||
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
|
||||
| 开发机整库清空重来 | 删 `data/platform.db*` 后启动(等同空库 Migrate);**不要**在生产用 EnsureDeleted |
|
||||
|
||||
注意:带 `HasConversion` 的枚举列,用 `ExecuteUpdate` + 原始字符串比较可能触发转换异常;空 LayoutMode 等特例用 raw UPDATE(参见 `AreaLayoutModeDefaultMigrator`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 协作与发版
|
||||
|
||||
1. **迁移文件必须入库**(含 Designer、Snapshot)
|
||||
2. 多人同时改模型易冲突 Snapshot:保留一方迁移,另一方 `remove` 后基于最新代码重新 `add`
|
||||
3. 已合并到主分支并可能已应用到共享库的迁移:**不要** `migrations remove` 或改写历史 `Up()`
|
||||
4. 发版包随程序集带上 Migration;现场首次升级靠启动 Migrate(或预执行 `migrations script`)
|
||||
|
||||
---
|
||||
|
||||
## 8. 其他 Provider(预留)
|
||||
|
||||
`IDbProviderSetup` 已预留 MySql / Npgsql / SqlServer。首轮只有 Sqlite 迁移套。
|
||||
|
||||
将来为企业库生成独立套时:
|
||||
|
||||
```powershell
|
||||
# 示例:输出到 Migrations/MySql,namespace 同步修改
|
||||
dotnet ef migrations add InitialPlatform `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--output-dir Migrations/MySql `
|
||||
--namespace MiGu.DB.Migrations.MySql `
|
||||
--context MiGuDbContext
|
||||
```
|
||||
|
||||
并确保对应 Provider 的 `MigrationsAssembly` / 运行时能发现该套迁移(按需拆程序集或过滤命名空间)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| `dotnet ef` 找不到 | 安装 8.0.10 工具,或用 `.\.tools\dotnet-ef` |
|
||||
| Design-time 连错库 | 检查 `MiGuDbContextFactory`(默认 `platform.db`);运行时以 Server 配置为准 |
|
||||
| Snapshot 生成到奇怪目录 | 移回 `Migrations/Sqlite/` |
|
||||
| 旧库启动重复建表失败 | 确认基线逻辑是否写入 History;备份后必要时手工插入 Initial 行 |
|
||||
| 改完实体 `add` 生成空迁移 | 模型无差异或未编译;先 `dotnet build` |
|
||||
| 想撤销未提交的迁移 | `migrations remove`(确认未被他人/现场应用) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 检查清单(每次提 PR)
|
||||
|
||||
- [ ] `dotnet build` 通过
|
||||
- [ ] 新迁移仅含预期 DDL
|
||||
- [ ] 文件在 `Migrations/Sqlite/`,Snapshot 路径正确
|
||||
- [ ] 本地启动一次,确认 Migrate 成功
|
||||
- [ ] 若有数据刷库,已加幂等 `IDataMigrator` 并注明 Order
|
||||
- [ ] 未改写已发布的历史 Migration
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MiGu.DB</RootNamespace>
|
||||
<AssemblyName>MiGu.DB</AssemblyName>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,754 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MiGu.DB.Migrations.Sqlite
|
||||
{
|
||||
/// <summary>
|
||||
/// Sqlite 初始 Schema(平台库全量表)。旧 EnsureCreated 库启动时会先基线本迁移名,再应用后续增量。
|
||||
/// </summary>
|
||||
public partial class InitialPlatform : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "simple_fields",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
car_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
field_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
key = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
value = table.Column<string>(type: "TEXT", nullable: false),
|
||||
data_type = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
chinese = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
english = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
other = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
|
||||
is_default = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
create_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false),
|
||||
update_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_simple_fields", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_dashboard_shortcuts",
|
||||
columns: table => new
|
||||
{
|
||||
user_id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
scope = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
keys_json = table.Column<string>(type: "text", nullable: false),
|
||||
updated_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_dashboard_shortcuts", x => new { x.user_id, x.scope });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_areas",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
WarehouseId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
LayoutMode = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
State = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_areas", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_container_location_history",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
FromLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
FromLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
ToLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
ToLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
BeforeJson = table.Column<string>(type: "text", nullable: false),
|
||||
AfterJson = table.Column<string>(type: "text", nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_container_location_history", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_container_locations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
LocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
LocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
LocationCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
LocationName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
EnteredAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_container_locations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_container_material_history",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
QuantityDelta = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
BeforeJson = table.Column<string>(type: "text", nullable: false),
|
||||
AfterJson = table.Column<string>(type: "text", nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_container_material_history", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_container_materials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
BatchNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
SerialNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
BoundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
LoadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UnloadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_container_materials", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_containers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
ContainerType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Length = table.Column<double>(type: "REAL", nullable: false),
|
||||
Width = table.Column<double>(type: "REAL", nullable: false),
|
||||
Height = table.Column<double>(type: "REAL", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_containers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_material_types",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
BarcodePrefix = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_material_types", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_materials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
TypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
LifecycleStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
UnboundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_materials", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_stock_events",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
EventType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
MaterialCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
MaterialName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
MaterialBarcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
MaterialTypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
ContainerCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
ContainerName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
StorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
StorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
AreaCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
FromStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
FromStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
FromStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
ToStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
ToStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
ToStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
RefType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
RefId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
RefCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_stock_events", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_storages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
StorageType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
LocationKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
ColumnNo = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LevelNo = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DepthNo = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
SiteId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
SiteCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Capacity = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Usage = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Priority = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ZoneCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
AllowInbound = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
AllowOutbound = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_storages", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_transport_reservations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
ExpiresAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_transport_reservations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_transport_rules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
TriggerType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Priority = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
SourceSelectorJson = table.Column<string>(type: "text", nullable: false),
|
||||
TargetSelectorJson = table.Column<string>(type: "text", nullable: false),
|
||||
TaskOptionsJson = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_transport_rules", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_transport_task_history",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
FromStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
ToStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
SnapshotJson = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_transport_task_history", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_transport_tasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
BusinessType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
RuleId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: true),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
DispatchMissionId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
DeliveryId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
DispatchStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
SnapshotJson = table.Column<string>(type: "text", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
TaskPriority = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_transport_tasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wms_warehouses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Version = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
|
||||
Extend = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_wms_warehouses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_simple_fields_car_type_field_type_key",
|
||||
table: "simple_fields",
|
||||
columns: new[] { "car_type", "field_type", "key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_areas_Code",
|
||||
table: "wms_areas",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_areas_WarehouseId",
|
||||
table: "wms_areas",
|
||||
column: "WarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_location_history_ContainerId",
|
||||
table: "wms_container_location_history",
|
||||
column: "ContainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_location_history_EventType",
|
||||
table: "wms_container_location_history",
|
||||
column: "EventType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_location_history_OperatedAt",
|
||||
table: "wms_container_location_history",
|
||||
column: "OperatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_locations_ContainerId",
|
||||
table: "wms_container_locations",
|
||||
column: "ContainerId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_locations_LocationType_LocationId",
|
||||
table: "wms_container_locations",
|
||||
columns: new[] { "LocationType", "LocationId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_locations_StorageId",
|
||||
table: "wms_container_locations",
|
||||
column: "StorageId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_material_history_ContainerId",
|
||||
table: "wms_container_material_history",
|
||||
column: "ContainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_material_history_EventType",
|
||||
table: "wms_container_material_history",
|
||||
column: "EventType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_material_history_OperatedAt",
|
||||
table: "wms_container_material_history",
|
||||
column: "OperatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_materials_ContainerId",
|
||||
table: "wms_container_materials",
|
||||
column: "ContainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_container_materials_MaterialId",
|
||||
table: "wms_container_materials",
|
||||
column: "MaterialId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_containers_Code",
|
||||
table: "wms_containers",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_material_types_Code",
|
||||
table: "wms_material_types",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_materials_Barcode",
|
||||
table: "wms_materials",
|
||||
column: "Barcode");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_materials_Code",
|
||||
table: "wms_materials",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_materials_LifecycleStatus_UpdatedAt",
|
||||
table: "wms_materials",
|
||||
columns: new[] { "LifecycleStatus", "UpdatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_materials_TypeCode",
|
||||
table: "wms_materials",
|
||||
column: "TypeCode");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_stock_events_ContainerId",
|
||||
table: "wms_stock_events",
|
||||
column: "ContainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_stock_events_EventType",
|
||||
table: "wms_stock_events",
|
||||
column: "EventType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_stock_events_MaterialId",
|
||||
table: "wms_stock_events",
|
||||
column: "MaterialId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_stock_events_OperatedAt",
|
||||
table: "wms_stock_events",
|
||||
column: "OperatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_storages_AreaId",
|
||||
table: "wms_storages",
|
||||
column: "AreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_storages_Barcode",
|
||||
table: "wms_storages",
|
||||
column: "Barcode");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_storages_Code",
|
||||
table: "wms_storages",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_reservations_ContainerId_Status",
|
||||
table: "wms_transport_reservations",
|
||||
columns: new[] { "ContainerId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_reservations_TargetStorageId_Status",
|
||||
table: "wms_transport_reservations",
|
||||
columns: new[] { "TargetStorageId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_rules_Code",
|
||||
table: "wms_transport_rules",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_rules_TriggerType_Enabled_Priority",
|
||||
table: "wms_transport_rules",
|
||||
columns: new[] { "TriggerType", "Enabled", "Priority" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_task_history_OperatedAt",
|
||||
table: "wms_transport_task_history",
|
||||
column: "OperatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_task_history_TaskId",
|
||||
table: "wms_transport_task_history",
|
||||
column: "TaskId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_tasks_ContainerId",
|
||||
table: "wms_transport_tasks",
|
||||
column: "ContainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_tasks_Status",
|
||||
table: "wms_transport_tasks",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_transport_tasks_TargetStorageId",
|
||||
table: "wms_transport_tasks",
|
||||
column: "TargetStorageId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_wms_warehouses_Code",
|
||||
table: "wms_warehouses",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "simple_fields");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_dashboard_shortcuts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_areas");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_container_location_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_container_locations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_container_material_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_container_materials");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_containers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_material_types");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_materials");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_stock_events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_storages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_transport_reservations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_transport_rules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_transport_task_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_transport_tasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wms_warehouses");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
# MiGu.DB
|
||||
|
||||
迷毂平台持久化框架(EF Core 8)。由 `MiGu.Server` 引用;本工程不依赖 ASP.NET。
|
||||
|
||||
## 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
|------|------|
|
||||
| `Abstractions/` | 对外契约:能力接口、仓储/UoW、Provider、Module、Actor、异常 |
|
||||
| `Kernel/` | 框架实现:实体基类族、MiGuDbContext、约定/Interceptor、仓储、Hosting |
|
||||
| `Domains/` | 业务实体、枚举、辅助类与 `IEntityTypeConfiguration` |
|
||||
| `Migrations/Sqlite/` | Schema 版本化来源(发版用;开发可用 EnsureCreated 不跑迁移) |
|
||||
|
||||
## 使用
|
||||
|
||||
由 `MiGu.Server` 调用:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
|
||||
await app.Services.MigrateMiGuDbAsync();
|
||||
```
|
||||
|
||||
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
|
||||
|
||||
配置键(`Database:*` / `ConnectionStrings:Platform`)由 Server 的 `appsettings*.json` 提供;本库通过 `AddMiGuDb(IConfiguration)` 读取。
|
||||
|
||||
## 实体与枚举
|
||||
|
||||
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
|
||||
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`。
|
||||
- 复数辅助类(`StorageStatuses`、`LocationKinds`…)负责 API 字符串解析与集合校验。
|
||||
|
||||
## 扩展
|
||||
|
||||
- **新 Provider**:实现 `IDbProviderSetup` + `Migrations/{Name}/`
|
||||
- **新领域**:`Domains/Xxx` 实体 + Configuration;可选 `IEntityModule`
|
||||
- **数据修补**:实现 `IDataMigrator`(优先 LINQ;枚举旧值刷库等特例可定点 raw UPDATE)
|
||||
|
||||
## 生成 Migration(Sqlite)
|
||||
|
||||
完整步骤、命令说明与排错见 **[MIGRATIONS.md](./MIGRATIONS.md)**。
|
||||
|
||||
快速命令:
|
||||
|
||||
```powershell
|
||||
dotnet ef migrations add <Name> `
|
||||
--project .\MiGu.DB\MiGu.DB.csproj `
|
||||
--startup-project .\MiGu.Server\MiGu.Server.csproj `
|
||||
--output-dir Migrations/Sqlite `
|
||||
--namespace MiGu.DB.Migrations.Sqlite `
|
||||
--context MiGuDbContext
|
||||
```
|
||||
|
||||
生成后请确认 `MiGuDbContextModelSnapshot.cs` 落在 `Migrations/Sqlite/`。
|
||||
|
||||
开发期使用 `EnsureCreated` 时仍可保留/继续提交 Migration 文件,启动不会应用它们,直到 `SchemaMode` 改回 `Migrate`(见 Server README)。
|
||||
Binary file not shown.
@@ -3,6 +3,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.Server", "MiGu.Server\MiGu.Server.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.DB", "MiGu.DB\MiGu.DB.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -13,5 +15,9 @@ Global
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
|
||||
///
|
||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||
/// 设计动机:Simple3 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
|
||||
/// (减少 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;
|
||||
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
|
||||
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 Simple3 8222 时,YARP transform
|
||||
/// 会自动追加该 header;
|
||||
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
||||
/// - Simple3 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||
/// <c>SIMPLE3__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
||||
///
|
||||
/// Token 来源优先级:
|
||||
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32)
|
||||
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
|
||||
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
|
||||
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
|
||||
/// 文件供本机 Simple3 读取(同机部署常见场景)
|
||||
/// </summary>
|
||||
public sealed class InternalTokenStore
|
||||
{
|
||||
@@ -39,7 +39,7 @@ public sealed class InternalTokenStore
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
|
||||
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 Simple3 读取
|
||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
var file = Path.Combine(dataDir, ".internal-token");
|
||||
@@ -71,12 +71,12 @@ public sealed class InternalTokenStore
|
||||
PersistedFilePath = file;
|
||||
logger.LogWarning(
|
||||
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
||||
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
|
||||
"若 MiGu.Server 与 Simple3 不在同一台机器,请把同一个值写入 Simple3 端 simple.json:platform.internalToken。",
|
||||
file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||
logger.LogError(ex, "internal token 落盘失败 —— 远程 Simple3 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
-18
@@ -29,7 +29,14 @@ public static class PageCatalog
|
||||
{
|
||||
// ── 管理端 / Platform:概览 ──
|
||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||
new("admin-setup", "初始配置", "概览", ScopePlatform),
|
||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||
new("admin-playback", "调度回放", "概览", ScopePlatform),
|
||||
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
|
||||
new("admin-notes", "运维备注", "概览", ScopePlatform),
|
||||
new("admin-logs", "日志管理", "概览", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:设计与编排 ──
|
||||
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
||||
@@ -40,23 +47,25 @@ public static class PageCatalog
|
||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
|
||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
|
||||
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
|
||||
|
||||
// ── 运营端 / RCSMonitor ──
|
||||
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
|
||||
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
|
||||
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
|
||||
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
|
||||
new("monitor-notes", "运营备注", "运营监控", ScopeMonitor),
|
||||
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
|
||||
new("monitor-notes", "运维备注", "运营监控", ScopeMonitor),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _keys =
|
||||
@@ -69,23 +78,79 @@ public static class PageCatalog
|
||||
["admin-config-vehicle"] = "admin-vehicle-hub",
|
||||
["admin-config-fleet"] = "admin-vehicle-hub",
|
||||
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
|
||||
["admin-config-routing"] = "admin-config-strategy",
|
||||
["admin-config-task"] = "admin-config-strategy",
|
||||
["admin-config-traffic"] = "admin-config-strategy",
|
||||
["admin-config-charge"] = "admin-config-strategy",
|
||||
["admin-config-device"] = "admin-config-facility",
|
||||
["admin-config-location"] = "admin-config-warehouse",
|
||||
["admin-config-integrations"] = "admin-config-business",
|
||||
["admin-config-scenario"] = "admin-config-business",
|
||||
["admin-config-widget"] = "admin-config-business",
|
||||
["admin-playback"] = "admin-config-ops-center",
|
||||
["admin-config-ops"] = "admin-config-ops-center",
|
||||
["admin-config-logs"] = "admin-config-ops-center",
|
||||
["admin-config-logs"] = "admin-logs",
|
||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||
["admin-config-system"] = "admin-config-system-center",
|
||||
["admin-config-auth"] = "admin-config-system-center",
|
||||
["admin-config-system-center"] = "admin-config-system",
|
||||
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
|
||||
["monitor-dashboard"] = "monitor-map",
|
||||
["admin-data-center-stations"] = "admin-data-center",
|
||||
["admin-data-center-docks"] = "admin-data-center",
|
||||
["admin-data-center-handshake"] = "admin-data-center",
|
||||
["admin-data-center-release"] = "admin-data-center",
|
||||
["admin-data-center-mag-control"] = "admin-data-center",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 管理端页面 → 运营端对应页面。给运营角色勾页时,只能从「当前管理员已有的管理端页」映射出来。
|
||||
/// 无对应运营页的管理端入口(如任务/报警)不出现在映射里。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> PlatformToMonitor =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["admin-map-monitor"] = "monitor-map",
|
||||
["admin-vehicle-hub"] = "monitor-vehicle-hub",
|
||||
["admin-ops-log"] = "monitor-ops",
|
||||
["admin-notes"] = "monitor-notes",
|
||||
};
|
||||
|
||||
/// <summary>仅运营端有的页面:不依赖管理端对应页,始终可授给运营角色。</summary>
|
||||
public static readonly IReadOnlyList<string> MonitorOnlyPages =
|
||||
new[] { "monitor-ops", "monitor-notes" };
|
||||
|
||||
/// <summary>
|
||||
/// 把角色里勾选的页面展开到指定登录域:勾了运营监控的「运维记录」时,
|
||||
/// 管理账号/通用角色进管理壳也能拿到对应的 admin-ops-log。
|
||||
/// </summary>
|
||||
public static HashSet<string> ExpandKeysForScope(IEnumerable<string> keys, string scope)
|
||||
{
|
||||
var scopeKeys = KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in keys ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.Equals(raw, Wildcard, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var k in scopeKeys) set.Add(k);
|
||||
return set;
|
||||
}
|
||||
var key = NormalizeKey(raw);
|
||||
if (scopeKeys.Contains(key)) set.Add(key);
|
||||
var counterpart = CounterpartForScope(key, scope);
|
||||
if (counterpart != null && scopeKeys.Contains(counterpart)) set.Add(counterpart);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>管理页 ↔ 运营页互认:monitor-ops → admin-ops-log(当前域是 Platform 时)。</summary>
|
||||
public static string? CounterpartForScope(string key, string targetScope)
|
||||
{
|
||||
if (string.Equals(targetScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var kv in PlatformToMonitor)
|
||||
{
|
||||
if (string.Equals(kv.Value, key, StringComparison.OrdinalIgnoreCase))
|
||||
return kv.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (string.Equals(targetScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase)
|
||||
&& PlatformToMonitor.TryGetValue(key, out var monitorKey))
|
||||
return monitorKey;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||
public static bool IsValidKey(string key) => _keys.Contains(key);
|
||||
|
||||
@@ -98,4 +163,57 @@ public static class PageCatalog
|
||||
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
|
||||
/// <summary>当前管理员在管理端拥有的页面 → 可授给运营端角色的页面集合。</summary>
|
||||
public static HashSet<string> GrantableMonitorPages(IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
var set = new HashSet<string>(MonitorOnlyPages, StringComparer.OrdinalIgnoreCase);
|
||||
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
|
||||
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase)
|
||||
|| HasAllKeys(list, KeysForScope(ScopePlatform)))
|
||||
{
|
||||
foreach (var k in KeysForScope(ScopeMonitor)) set.Add(k);
|
||||
return set;
|
||||
}
|
||||
foreach (var p in list)
|
||||
{
|
||||
var key = NormalizeKey(p);
|
||||
if (PlatformToMonitor.TryGetValue(key, out var monitorKey) && IsValidKey(monitorKey))
|
||||
set.Add(monitorKey);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前管理员可授给管理端角色的页面(不能超权:只能授自己已有的)。</summary>
|
||||
public static HashSet<string> GrantablePlatformPages(IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
|
||||
var all = KeysForScope(ScopePlatform);
|
||||
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || HasAllKeys(list, all))
|
||||
return all.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return list
|
||||
.Select(NormalizeKey)
|
||||
.Where(k => IsValidKey(k) && all.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>按角色归属域,算出当前管理员可勾选的页面 Key。</summary>
|
||||
public static HashSet<string> GrantablePagesForRoleScope(
|
||||
string roleScope, IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
if (string.Equals(roleScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase))
|
||||
return GrantableMonitorPages(actorPlatformPages);
|
||||
if (string.Equals(roleScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
|
||||
return GrantablePlatformPages(actorPlatformPages);
|
||||
// 通用域:管理端可授 + 运营端可授
|
||||
var set = GrantablePlatformPages(actorPlatformPages);
|
||||
set.UnionWith(GrantableMonitorPages(actorPlatformPages));
|
||||
return set;
|
||||
}
|
||||
|
||||
private static bool HasAllKeys(IEnumerable<string> have, IEnumerable<string> all)
|
||||
{
|
||||
var set = have.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return all.All(k => set.Contains(k));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
|
||||
///
|
||||
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
|
||||
/// admin(超级管理员,scope=*,全部页面 / 操作)
|
||||
/// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码)
|
||||
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
|
||||
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
|
||||
/// </summary>
|
||||
public sealed class RbacStore
|
||||
@@ -58,9 +58,14 @@ public sealed class RbacStore
|
||||
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
|
||||
if (snap is { Users.Count: > 0 })
|
||||
{
|
||||
_snapshot = Normalize(snap);
|
||||
_snapshot = Normalize(snap, out var migrated);
|
||||
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
|
||||
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
|
||||
if (migrated)
|
||||
{
|
||||
Persist();
|
||||
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
|
||||
@@ -82,21 +87,30 @@ public sealed class RbacStore
|
||||
}
|
||||
|
||||
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
|
||||
private static RbacSnapshot Normalize(RbacSnapshot snap)
|
||||
/// <param name="migrated">任一角色页面 Key 被别名替换 / 丢弃 / 补齐时为 true,调用方应落盘。</param>
|
||||
private static RbacSnapshot Normalize(RbacSnapshot snap, out bool migrated)
|
||||
{
|
||||
migrated = false;
|
||||
snap.Roles ??= new();
|
||||
snap.Users ??= new();
|
||||
foreach (var r in snap.Roles)
|
||||
{
|
||||
r.Pages = (r.Pages ?? new())
|
||||
var before = r.Pages ?? new();
|
||||
var hadSystemHub = before.Any(p =>
|
||||
string.Equals(p, "admin-config-system-center", StringComparison.OrdinalIgnoreCase));
|
||||
var after = before
|
||||
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
|
||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (!SamePages(before, after)) migrated = true;
|
||||
r.Pages = after;
|
||||
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
||||
r.WidgetGrants ??= new();
|
||||
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
|
||||
BackfillKnownPageMigrations(r);
|
||||
var countBeforeBackfill = r.Pages.Count;
|
||||
BackfillKnownPageMigrations(r, hadSystemHub);
|
||||
if (r.Pages.Count != countBeforeBackfill) migrated = true;
|
||||
}
|
||||
foreach (var u in snap.Users)
|
||||
{
|
||||
@@ -105,7 +119,12 @@ public sealed class RbacStore
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static void BackfillKnownPageMigrations(RbacRole r)
|
||||
private static bool SamePages(IReadOnlyList<string> a, IReadOnlyList<string> b) =>
|
||||
a.Count == b.Count
|
||||
&& !a.Except(b, StringComparer.OrdinalIgnoreCase).Any()
|
||||
&& !b.Except(a, StringComparer.OrdinalIgnoreCase).Any();
|
||||
|
||||
private static void BackfillKnownPageMigrations(RbacRole r, bool hadSystemHub)
|
||||
{
|
||||
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
|
||||
&& r.Scope != PageCatalog.Wildcard) return;
|
||||
@@ -126,6 +145,31 @@ public sealed class RbacStore
|
||||
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-simple-fields");
|
||||
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& !r.Pages.Contains("admin-setup", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-setup");
|
||||
|
||||
// 日志管理、调度回放从监控配置聚合页拆出:原先有聚合页权限的角色补上新入口。
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& r.Pages.Contains("admin-config-ops-center", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!r.Pages.Contains("admin-logs", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-logs");
|
||||
if (!r.Pages.Contains("admin-playback", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-playback");
|
||||
}
|
||||
|
||||
// 「系统与权限」拆成系统配置 / 权限与角色。
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard) && hadSystemHub)
|
||||
{
|
||||
if (!r.Pages.Contains("admin-config-system", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-config-system");
|
||||
if (!r.Pages.Contains("admin-config-auth", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-config-auth");
|
||||
}
|
||||
}
|
||||
|
||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||
@@ -153,7 +197,7 @@ public sealed class RbacStore
|
||||
{
|
||||
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
|
||||
Scope = PageCatalog.ScopeMonitor,
|
||||
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
|
||||
Pages = new() { "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
|
||||
Ops = new()
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
@@ -244,6 +288,20 @@ public sealed class RbacStore
|
||||
public bool CanUseScope(RbacUser user, string scope) =>
|
||||
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 登录按账号角色自动选域:能进管理端则进管理端,否则进运营权限页。
|
||||
/// 不再接受客户端挑选「运营端 / 管理端」。
|
||||
/// </summary>
|
||||
public string? ResolveLoginScope(RbacUser user)
|
||||
{
|
||||
var scopes = UsableScopes(user);
|
||||
if (scopes.Contains(PageCatalog.ScopePlatform, StringComparer.OrdinalIgnoreCase))
|
||||
return PageCatalog.ScopePlatform;
|
||||
if (scopes.Contains(PageCatalog.ScopeMonitor, StringComparer.OrdinalIgnoreCase))
|
||||
return PageCatalog.ScopeMonitor;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
|
||||
public List<string> RoleNamesOf(RbacUser user)
|
||||
{
|
||||
@@ -270,7 +328,7 @@ public sealed class RbacStore
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
|
||||
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
|
||||
else pages.UnionWith(PageCatalog.ExpandKeysForScope(r.Pages, scope));
|
||||
|
||||
foreach (var o in r.Ops)
|
||||
{
|
||||
@@ -480,7 +538,11 @@ public sealed class RbacStore
|
||||
{
|
||||
if (pages is null) return new();
|
||||
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
|
||||
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
|
||||
return pages
|
||||
.Select(PageCatalog.NormalizeKey)
|
||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeScope(string? scope) => scope switch
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
|
||||
/// <summary>
|
||||
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
@@ -99,6 +99,58 @@ public sealed class ConfigStore
|
||||
return Put("deployment", el);
|
||||
}
|
||||
|
||||
public LogCleanupOptions GetLogCleanup()
|
||||
{
|
||||
var env = Get("system");
|
||||
return env.Payload switch
|
||||
{
|
||||
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
|
||||
JsonElement el => ParseLogCleanup(el),
|
||||
_ => LogCleanupOptions.CreateDefaults()
|
||||
};
|
||||
}
|
||||
|
||||
public Envelope PutLogCleanup(LogCleanupOptions options)
|
||||
{
|
||||
var opt = (options ?? new LogCleanupOptions()).Clamp();
|
||||
Dictionary<string, JsonElement> dict;
|
||||
try
|
||||
{
|
||||
var current = Get("system").Payload;
|
||||
var el = current is JsonElement je
|
||||
? je
|
||||
: JsonSerializer.SerializeToElement(current, _jsonOpts);
|
||||
dict = el.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
|
||||
: new Dictionary<string, JsonElement>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
|
||||
dict = new Dictionary<string, JsonElement>();
|
||||
}
|
||||
|
||||
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
|
||||
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
|
||||
return Put("system", merged);
|
||||
}
|
||||
|
||||
private LogCleanupOptions ParseLogCleanup(JsonElement el)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
|
||||
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
|
||||
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
|
||||
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
|
||||
return LogCleanupOptions.CreateDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
|
||||
{
|
||||
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
|
||||
|
||||
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
|
||||
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||
/// <summary>
|
||||
/// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入
|
||||
/// <see cref="DeploymentProfile.SignalSceneId"/>,从而点亮「数据中心」。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</summary>
|
||||
public static IReadOnlyCollection<string> TailorablePages()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
||||
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
|
||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dp == null) return set;
|
||||
foreach (var m in dp.Modules ?? new List<string>())
|
||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
||||
|
||||
var activeScenes = dp.ToLauncherSceneIds();
|
||||
foreach (var (sceneId, pages) in SceneToPages)
|
||||
{
|
||||
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
foreach (var p in pages) set.Add(p);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
|
||||
/// <para>它是「按选型裁剪」的单一事实来源:</para>
|
||||
/// <list type="number">
|
||||
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
|
||||
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id,
|
||||
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 Simple3 场景 id,
|
||||
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
|
||||
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
|
||||
/// </list>
|
||||
@@ -35,7 +35,7 @@ public record DeploymentProfile(
|
||||
UpdatedBy: "");
|
||||
|
||||
/// <summary>
|
||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 导航方式 → Simple3 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>;
|
||||
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
|
||||
@@ -49,7 +49,7 @@ public record DeploymentProfile(
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
||||
/// 把已选 <see cref="NavigationKinds"/> 映射为 Simple3 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
|
||||
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ToActiveSceneIds()
|
||||
@@ -64,4 +64,36 @@ public record DeploymentProfile(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>信号交互场景 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>
|
||||
public IReadOnlyList<AuditEntry> Recent()
|
||||
/// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
|
||||
public IReadOnlyList<AuditEntry> Recent(string? user = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var copy = new List<AuditEntry>(_entries);
|
||||
IEnumerable<AuditEntry> src = _entries;
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase));
|
||||
var copy = src.ToList();
|
||||
copy.Reverse();
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
||||
|
||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
||||
public record SystemConfig
|
||||
{
|
||||
public static SystemConfig Default() => new(
|
||||
DispatchLoopHz: 50,
|
||||
Log: new LogPolicy("info", 7, 256),
|
||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||
public LogCleanupOptions LogCleanup { get; init; } = new();
|
||||
|
||||
public static SystemConfig Default() => new();
|
||||
}
|
||||
|
||||
@@ -12,19 +12,17 @@ namespace MiGu.Server.Controllers;
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 登录请求体。
|
||||
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb";
|
||||
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
|
||||
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
|
||||
/// 登录请求体。可选 <see cref="LaunchMode"/>;Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
|
||||
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
|
||||
/// </summary>
|
||||
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
|
||||
public record LoginRequest(string Username, string Password, string? Scope = null, string? LaunchMode = null);
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应。
|
||||
/// 会话 N+1 增量字段:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
|
||||
/// <item><c>LaunchStatus</c>:<see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
||||
/// <item><c>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
|
||||
/// <item><c>LaunchStatus</c>:<see cref="Simple3Launcher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
||||
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -51,17 +49,18 @@ public class AuthController : ControllerBase
|
||||
string Scope,
|
||||
string RunMode,
|
||||
EffectivePermissions EffectivePermissions,
|
||||
bool NeedsWizard = false);
|
||||
bool NeedsWizard = false,
|
||||
string? Token = null);
|
||||
|
||||
private const string CookieName = "simple.auth.token";
|
||||
|
||||
private readonly RbacStore _rbac;
|
||||
private readonly JwtIssuer _jwt;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly ConfigStore _config;
|
||||
private readonly ILogger<AuthController> _log;
|
||||
|
||||
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
|
||||
public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger<AuthController> log)
|
||||
{
|
||||
_rbac = rbac;
|
||||
_jwt = jwt;
|
||||
@@ -79,41 +78,40 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username))
|
||||
return BadRequest(new { message = "用户名不能为空" });
|
||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||
return BadRequest(new { message = "无效 scope" });
|
||||
|
||||
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
|
||||
var user = _rbac.VerifyCredentials(req.Username, req.Password);
|
||||
if (user == null)
|
||||
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
|
||||
|
||||
// scope 必须落在该账号「角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)。
|
||||
if (!_rbac.CanUseScope(user, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
|
||||
// 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」。
|
||||
var scope = _rbac.ResolveLoginScope(user);
|
||||
if (scope == null)
|
||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
|
||||
|
||||
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
|
||||
// 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。
|
||||
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
||||
SimpleLiteLauncher.LaunchResult? launchResult = null;
|
||||
Simple3Launcher.LaunchResult? launchResult = null;
|
||||
try
|
||||
{
|
||||
// M1:waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
|
||||
// M1:waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口
|
||||
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
|
||||
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
|
||||
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
|
||||
_log.LogInformation("Simple3 launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
|
||||
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
|
||||
_log.LogError(ex, "Simple3 launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
|
||||
}
|
||||
|
||||
var runMode = ResolveRunMode(launchResult, launchMode);
|
||||
|
||||
var (perm, roleNames, token) = BuildSession(user, req.Scope);
|
||||
var (perm, roleNames, token) = BuildSession(user, scope);
|
||||
SetAuthCookie(token);
|
||||
|
||||
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
|
||||
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
|
||||
return Ok(new LoginResponse(token, dto, scope, runMode, perm,
|
||||
LaunchStatus: launchResult?.Status,
|
||||
LaunchWarning: launchResult?.Warning,
|
||||
NeedsWizard: NeedsWizard()));
|
||||
@@ -144,13 +142,20 @@ public class AuthController : ControllerBase
|
||||
if (user == null || !user.Enabled)
|
||||
return Unauthorized(new { message = "账号已失效或被停用" });
|
||||
|
||||
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
|
||||
if (!_rbac.CanUseScope(user, scope))
|
||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
|
||||
var preferred = _rbac.ResolveLoginScope(user);
|
||||
if (preferred == null)
|
||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
|
||||
|
||||
var (perm, roleNames, token) = BuildSession(user, preferred);
|
||||
string? rotated = null;
|
||||
if (!string.Equals(preferred, scope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetAuthCookie(token);
|
||||
rotated = token;
|
||||
}
|
||||
|
||||
var (perm, roleNames, _) = BuildSession(user, scope);
|
||||
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
|
||||
return Ok(new MeResponse(dto, scope, InferRunMode(), perm, NeedsWizard()));
|
||||
return Ok(new MeResponse(dto, preferred, InferRunMode(), perm, NeedsWizard(), rotated));
|
||||
}
|
||||
|
||||
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
|
||||
@@ -172,6 +177,11 @@ public class AuthController : ControllerBase
|
||||
if (!_rbac.CanUseScope(user, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
|
||||
|
||||
// 有管理权限的账号固定走管理入口,不再切到运营壳;请用运营权限账号登录查看运营页。
|
||||
var preferred = _rbac.ResolveLoginScope(user);
|
||||
if (preferred == PageCatalog.ScopePlatform && req.Scope == PageCatalog.ScopeMonitor)
|
||||
return StatusCode(403, new { message = "请使用运营权限账号查看运营页面" });
|
||||
|
||||
var (perm, roleNames, token) = BuildSession(user, req.Scope);
|
||||
SetAuthCookie(token);
|
||||
|
||||
@@ -200,22 +210,23 @@ public class AuthController : ControllerBase
|
||||
return (perm, roleNames, token);
|
||||
}
|
||||
|
||||
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
|
||||
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
|
||||
private string InferRunMode()
|
||||
{
|
||||
var last = _launcher.LastLaunchMode;
|
||||
if (string.IsNullOrEmpty(last)) return "Detached";
|
||||
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
|
||||
if (last == Simple3Launcher.ExternalReuseLaunchMode) return "WebOnly";
|
||||
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||
}
|
||||
|
||||
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
|
||||
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
|
||||
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
|
||||
private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode)
|
||||
{
|
||||
if (result is not { Started: true })
|
||||
return "Detached";
|
||||
// Simple3 无本地端:复用既有实例也按 WebOnly 展示。
|
||||
if (result.Value.Status == "ReusingExisting")
|
||||
return "WebEnabled";
|
||||
return "WebOnly";
|
||||
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
||||
{
|
||||
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -230,8 +241,9 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
return raw?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web-only" or "web" => "WebOnly",
|
||||
_ => "DesktopAndWeb",
|
||||
"desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb",
|
||||
// Simple3 默认仅 Web;未传 / 未知也走 WebOnly
|
||||
_ => "WebOnly",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Logs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly LogCleanupService _cleanup;
|
||||
|
||||
public ConfigController(ConfigStore store)
|
||||
public ConfigController(ConfigStore store, LogCleanupService cleanup)
|
||||
{
|
||||
_store = store;
|
||||
_cleanup = cleanup;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
|
||||
}
|
||||
|
||||
var env = _store.Put(section, payload);
|
||||
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
|
||||
_cleanup.ApplySchedule();
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.Server.Fleet;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>车队运维健康探针(延迟走 WatchDog TCP)+ CDM 任务平台侧快照读取。</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/fleet")]
|
||||
public sealed class FleetController : ControllerBase
|
||||
{
|
||||
private readonly FleetHealthService _health;
|
||||
private readonly CdmTaskSyncer _cdmSyncer;
|
||||
private readonly AlarmCollector _alarmCollector;
|
||||
private readonly MiGuDbContext _db;
|
||||
|
||||
public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, MiGuDbContext db)
|
||||
{
|
||||
_health = health;
|
||||
_cdmSyncer = cdmSyncer;
|
||||
_alarmCollector = alarmCollector;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public async Task<ActionResult<List<FleetHealthRowDto>>> Health(CancellationToken ct)
|
||||
=> Ok(await _health.GetAsync(ct));
|
||||
|
||||
/// <summary>
|
||||
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
|
||||
/// Simple3 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
|
||||
/// </summary>
|
||||
[HttpGet("tasks")]
|
||||
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
|
||||
{
|
||||
await _cdmSyncer.SyncOnceAsync(ct);
|
||||
|
||||
var take = Math.Clamp(limit, 1, 5000);
|
||||
var tasks = await _db.CdmTasks.AsNoTracking()
|
||||
.OrderByDescending(t => t.CreateTime)
|
||||
.Take(take)
|
||||
.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
taskId = t.TaskId,
|
||||
missionId = t.MissionId,
|
||||
missionName = t.MissionName,
|
||||
missionTypeName = t.MissionTypeName,
|
||||
srcSiteId = t.SrcSiteId,
|
||||
srcLabel = t.SrcLabel,
|
||||
dstSiteId = t.DstSiteId,
|
||||
dstLabel = t.DstLabel,
|
||||
status = t.Status,
|
||||
statusCode = t.StatusCode,
|
||||
carId = t.CarId,
|
||||
carName = t.CarName,
|
||||
priority = t.Priority,
|
||||
createTime = t.CreateTime,
|
||||
startTime = t.StartTime,
|
||||
finishTime = t.FinishTime,
|
||||
stuckReason = t.StuckReason,
|
||||
overdue = t.Overdue
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
online = _cdmSyncer.Online,
|
||||
lastSyncAt = _cdmSyncer.LastSyncAt,
|
||||
count = tasks.Count,
|
||||
tasks
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;Simple3 离线时回退最近记录,
|
||||
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
|
||||
/// </summary>
|
||||
[HttpGet("alarms")]
|
||||
public async Task<ActionResult<object>> Alarms(
|
||||
[FromQuery] int limit = 2000,
|
||||
[FromQuery] bool activeOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 在线时先即时对帐一次,保证打开页面能拿到最新活跃报警。
|
||||
await _alarmCollector.SyncOnceAsync(ct);
|
||||
|
||||
var take = Math.Clamp(limit, 1, 10000);
|
||||
var query = _db.VehicleAlarms.AsNoTracking().AsQueryable();
|
||||
if (activeOnly) query = query.Where(a => a.Status == "active");
|
||||
|
||||
var alarms = await query
|
||||
.OrderByDescending(a => a.Status == "active")
|
||||
.ThenByDescending(a => a.LastAt)
|
||||
.Take(take)
|
||||
.Select(a => new
|
||||
{
|
||||
id = a.Id,
|
||||
carId = a.CarId,
|
||||
carName = a.CarName,
|
||||
info = a.Info,
|
||||
level = a.Level,
|
||||
status = a.Status,
|
||||
firstAt = a.FirstAt,
|
||||
lastAt = a.LastAt,
|
||||
resolvedAt = a.ResolvedAt,
|
||||
durationSecs = a.DurationSecs,
|
||||
acknowledged = a.Acknowledged
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
online = _alarmCollector.Online,
|
||||
lastSyncAt = _alarmCollector.LastSyncAt,
|
||||
count = alarms.Count,
|
||||
alarms
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ namespace MiGu.Server.Controllers;
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
|
||||
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||
public HealthController(Simple3Launcher launcher) => _launcher = launcher;
|
||||
|
||||
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
|
||||
[HttpGet]
|
||||
@@ -26,21 +26,41 @@ public class HealthController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||
/// Simple3 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>Simple3</c> 节点。
|
||||
/// 含服务器本地路径等敏感信息,要求登录。
|
||||
/// </summary>
|
||||
[HttpGet("simplelite")]
|
||||
[HttpGet("simple3")]
|
||||
[Authorize]
|
||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
public IActionResult GetSimple3Diagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
|
||||
/// <summary>关闭本机全部 Simple3 进程。仅 Platform 管理端可调。</summary>
|
||||
[HttpPost("simple3/stop")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult StopSimple3()
|
||||
{
|
||||
var killed = _launcher.StopAll();
|
||||
var diag = _launcher.GetDiagnostics();
|
||||
return Ok(new { killed, diagnostics = diag });
|
||||
}
|
||||
|
||||
/// <summary>关闭并重新拉起 Simple3(不同步 DLL)。仅 Platform 管理端可调。</summary>
|
||||
[HttpPost("simple3/restart")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult RestartSimple3([FromQuery] string launchMode = "webonly")
|
||||
{
|
||||
var result = _launcher.Restart(launchMode);
|
||||
var diag = _launcher.GetDiagnostics();
|
||||
return Ok(new { restart = result, diagnostics = diag });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
||||
/// 关闭 Simple3、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||
/// 会终止本机全部 Simple3 进程并重启,仅 Platform 管理端可调。
|
||||
/// </summary>
|
||||
[HttpPost("simplelite/restart-for-update")]
|
||||
[HttpPost("simple3/restart-for-update")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
|
||||
public IActionResult RestartSimple3ForUpdate([FromQuery] string launchMode = "webonly")
|
||||
{
|
||||
var result = _launcher.RestartForUpdate(launchMode);
|
||||
var diag = _launcher.GetDiagnostics();
|
||||
|
||||
@@ -2,18 +2,20 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Logs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||||
/// 平台「日志管理」后端:把 Simple3 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||||
/// (<c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
|
||||
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
|
||||
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
|
||||
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
|
||||
///
|
||||
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>,
|
||||
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>,
|
||||
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
||||
///
|
||||
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>,
|
||||
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
|
||||
private static readonly Regex NumericFieldRegex =
|
||||
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
|
||||
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LogsController> _log;
|
||||
private readonly LogCleanupService _cleanup;
|
||||
|
||||
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
|
||||
public LogsController(
|
||||
Simple3Launcher launcher,
|
||||
IConfiguration config,
|
||||
ILogger<LogsController> log,
|
||||
LogCleanupService cleanup)
|
||||
{
|
||||
_launcher = launcher;
|
||||
_config = config;
|
||||
_log = log;
|
||||
_cleanup = cleanup;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 概览 ──
|
||||
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
|
||||
{
|
||||
var (root, workdir, error) = ResolveLogRoot();
|
||||
if (root == null)
|
||||
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
|
||||
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
|
||||
|
||||
if (!Directory.Exists(root))
|
||||
return Ok(new
|
||||
{
|
||||
exists = false, root, workingDirectory = workdir,
|
||||
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
|
||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||||
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
|
||||
disk = DiskDto()
|
||||
});
|
||||
|
||||
var files = EnumerateLogFiles(root);
|
||||
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
|
||||
totalFiles = files.Count,
|
||||
totalBytes = files.Sum(f => f.Bytes),
|
||||
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
|
||||
days
|
||||
days,
|
||||
disk = DiskDto()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
|
||||
[HttpGet("cleanup-config")]
|
||||
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
|
||||
|
||||
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
|
||||
[HttpPut("cleanup-config")]
|
||||
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
|
||||
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
|
||||
|
||||
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
|
||||
[HttpPost("cleanup-now")]
|
||||
public IActionResult CleanupNow()
|
||||
{
|
||||
var r = _cleanup.CleanNow();
|
||||
if (r.Error != null)
|
||||
return Problem(r.Error, statusCode: 500);
|
||||
return Ok(new
|
||||
{
|
||||
deletedFiles = r.DeletedFiles,
|
||||
freedBytes = r.FreedBytes,
|
||||
freedMB = Math.Round(r.FreedMB, 1)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
|
||||
root = normRoot, exists = false, path = "", parent = (string?)null,
|
||||
dirCount = 0, fileCount = 0,
|
||||
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
|
||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||||
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
|
||||
});
|
||||
|
||||
var target = SafeResolveDir(normRoot, path);
|
||||
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
|
||||
return PhysicalFile(full, "application/octet-stream", downloadName);
|
||||
}
|
||||
|
||||
private object DiskDto()
|
||||
{
|
||||
var d = _cleanup.GetDiskStatus();
|
||||
return new
|
||||
{
|
||||
known = d.Known,
|
||||
drive = d.Drive,
|
||||
freeGB = Math.Round(d.FreeGB, 1),
|
||||
alertEnabled = d.AlertEnabled,
|
||||
alertGB = d.AlertGB,
|
||||
belowThreshold = d.BelowThreshold
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────── helpers ──
|
||||
|
||||
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
|
||||
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
|
||||
private (string? root, string? workdir, string? error) ResolveLogRoot()
|
||||
{
|
||||
var overrideRoot = _config["Logs:Root"];
|
||||
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
|
||||
var wd = _launcher.ResolveWorkingDirectory();
|
||||
if (string.IsNullOrWhiteSpace(wd))
|
||||
return (null, null,
|
||||
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory," +
|
||||
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory," +
|
||||
"或显式设置 Logs:Root 指向日志根目录。");
|
||||
|
||||
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
||||
|
||||
@@ -8,8 +8,8 @@ using MiGu.Server.Launcher;
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
|
||||
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
|
||||
/// 读取 Simple3 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
|
||||
/// 先向 Simple3 拉取 maps 列表拿到 directory,再读本机同路径文件(与 Simple3 同机部署)。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -18,13 +18,13 @@ public class MapsContentController : ControllerBase
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly InternalTokenStore _internalToken;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly ILogger<MapsContentController> _log;
|
||||
|
||||
public MapsContentController(
|
||||
IHttpClientFactory httpFactory,
|
||||
InternalTokenStore internalToken,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
IOptions<Simple3Options> sl,
|
||||
ILogger<MapsContentController> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
@@ -47,7 +47,7 @@ public class MapsContentController : ControllerBase
|
||||
{
|
||||
var directory = await FetchMapsDirectoryAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" });
|
||||
return StatusCode(503, new { message = "无法从 Simple3 获取地图目录" });
|
||||
|
||||
var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
|
||||
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
|
||||
@@ -86,7 +86,7 @@ public class MapsContentController : ControllerBase
|
||||
using var resp = await client.SendAsync(msg, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"SimpleLite maps 列表返回 {(int)resp.StatusCode}");
|
||||
throw new InvalidOperationException($"Simple3 maps 列表返回 {(int)resp.StatusCode}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
|
||||
/// M4 修复(运维操作真实下发 + 审计落库):
|
||||
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
|
||||
/// “暂停成功”但内核毫无反应,且重启审计全丢);
|
||||
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute,
|
||||
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute,
|
||||
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
|
||||
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
|
||||
///
|
||||
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
|
||||
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
|
||||
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
|
||||
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发。
|
||||
/// 关于映射:
|
||||
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
|
||||
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
|
||||
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/sl/ops")]
|
||||
public class OpsController : ControllerBase
|
||||
{
|
||||
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
||||
public const string CarExecuteOp = "ops.car.execute";
|
||||
|
||||
public record ExecuteRequest(
|
||||
string OpCode,
|
||||
string TargetId,
|
||||
string? Reason,
|
||||
string? IdempotencyKey,
|
||||
string? Method,
|
||||
Dictionary<string, string>? Params,
|
||||
int? SiteId);
|
||||
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||
|
||||
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.car.manualCharge", CarExecuteOp,
|
||||
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Goto", "GotoSite", "WebGotoSite"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> MapMonitorQuickMethods = new(StringComparer.Ordinal)
|
||||
{
|
||||
"OnlineCar", "OfflineCar", "DisableCar", "EnableCar",
|
||||
"ForceStopUI", "ForceStop", "UIIntercept"
|
||||
};
|
||||
|
||||
private readonly OpsAuditStore _audits;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly InternalTokenStore _internalToken;
|
||||
private readonly SimpleLiteOptions _sl;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly ILogger<OpsController> _log;
|
||||
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
|
||||
|
||||
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
|
||||
OpsAuditStore audits,
|
||||
IHttpClientFactory httpFactory,
|
||||
InternalTokenStore internalToken,
|
||||
IOptions<SimpleLiteOptions> sl,
|
||||
IOptions<Simple3Options> sl,
|
||||
IConfiguration config,
|
||||
ILogger<OpsController> log)
|
||||
{
|
||||
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
|
||||
// AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。
|
||||
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
||||
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
|
||||
if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value))
|
||||
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
|
||||
|
||||
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
||||
var user = CurrentUsername();
|
||||
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||
|
||||
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
|
||||
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
|
||||
if (req.OpCode == "monitor.note.write")
|
||||
return Ok(Done(user, scope, req, "ok", req.Reason));
|
||||
|
||||
if (req.OpCode == CarExecuteOp)
|
||||
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
|
||||
|
||||
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
|
||||
if (!_dispatch.TryGetValue(req.OpCode, out var map))
|
||||
return Ok(Done(user, scope, req, "unmapped",
|
||||
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
|
||||
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
|
||||
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
|
||||
ok: false));
|
||||
|
||||
@@ -120,37 +147,62 @@ public class OpsController : ControllerBase
|
||||
if (numericId is null)
|
||||
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
|
||||
|
||||
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
|
||||
string result;
|
||||
string? message;
|
||||
try
|
||||
{
|
||||
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
|
||||
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
|
||||
if (!string.IsNullOrEmpty(_internalToken.Token))
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
|
||||
using var resp = await client.SendAsync(msg);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
|
||||
result = success ? "ok" : "failed";
|
||||
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}:{ExtractMessage(body)}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = "failed";
|
||||
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
|
||||
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
|
||||
}
|
||||
|
||||
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
|
||||
var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user);
|
||||
return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
|
||||
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
|
||||
/// </summary>
|
||||
private async Task<ExecuteResponse> ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req)
|
||||
{
|
||||
var method = (req.Method ?? "").Trim();
|
||||
if (!IsSafeMethodName(method))
|
||||
return Done(user, scope, req, "failed", "方法名无效", ok: false);
|
||||
|
||||
var numericId = ExtractNumericId(req.TargetId);
|
||||
if (numericId is null)
|
||||
return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false);
|
||||
|
||||
var allowed = await LoadAllowedCarMethodsAsync(numericId.Value);
|
||||
if (allowed is null)
|
||||
return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config)", ok: false);
|
||||
allowed.UnionWith(MapMonitorQuickMethods);
|
||||
if (allowed.Count == 0)
|
||||
return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false);
|
||||
if (!allowed.Contains(method))
|
||||
return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false);
|
||||
|
||||
var siteId = req.SiteId;
|
||||
if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite)
|
||||
&& int.TryParse(rawSite, out var parsedSite))
|
||||
siteId = parsedSite;
|
||||
|
||||
if (siteId is int sid && GotoMethods.Contains(method))
|
||||
{
|
||||
var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user);
|
||||
if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase))
|
||||
return Done(user, scope, req, gotoResult.Result,
|
||||
CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok),
|
||||
ok: gotoResult.Ok);
|
||||
}
|
||||
|
||||
var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user);
|
||||
return Done(user, scope, req, forwarded.Result,
|
||||
CarAuditMessage(method, forwarded.Message, forwarded.Ok),
|
||||
ok: forwarded.Ok);
|
||||
}
|
||||
|
||||
private static string CarAuditMessage(string method, string? detail, bool ok)
|
||||
{
|
||||
if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail;
|
||||
return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}:{detail}";
|
||||
}
|
||||
|
||||
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
|
||||
[HttpGet("audits")]
|
||||
public IActionResult Audits200() => Ok(_audits.Recent());
|
||||
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
|
||||
|
||||
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
|
||||
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
|
||||
@@ -159,6 +211,14 @@ public class OpsController : ControllerBase
|
||||
return new ExecuteResponse(ok, entry.Id, message);
|
||||
}
|
||||
|
||||
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
|
||||
private string CurrentUsername() =>
|
||||
User.FindFirst("unique_name")?.Value
|
||||
?? User.Identity?.Name
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? "anonymous";
|
||||
|
||||
/// <summary>
|
||||
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
|
||||
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
|
||||
@@ -191,4 +251,206 @@ public class OpsController : ControllerBase
|
||||
catch { /* ignore,下面回退裁剪原文 */ }
|
||||
return body.Length <= 200 ? body : body[..200] + "…";
|
||||
}
|
||||
|
||||
private static bool CanRunOp(string[] userOps, string opCode, string? scope)
|
||||
{
|
||||
if (userOps.Contains("*") || userOps.Contains(opCode)) return true;
|
||||
if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal)))
|
||||
return true;
|
||||
// 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。
|
||||
return opCode == CarExecuteOp
|
||||
&& string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSafeMethodName(string method) =>
|
||||
method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');
|
||||
|
||||
private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message);
|
||||
|
||||
private async Task<HashSet<string>?> LoadAllowedCarMethodsAsync(int carId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}");
|
||||
var config = await GetLiteJsonAsync("/projection/reflection/monitor-config");
|
||||
if (bundle is null || config is null) return null;
|
||||
|
||||
var typeName = ReadString(bundle, "typeName") ?? "";
|
||||
var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName;
|
||||
var map = ReadCarActionByType(config.Value);
|
||||
return LookupCarMethods(map, fullTypeName, typeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ReadCarActionByType(JsonElement envelope)
|
||||
{
|
||||
var map = new Dictionary<string, string[]>(StringComparer.Ordinal);
|
||||
if (!TryData(envelope, out var data)) return map;
|
||||
if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object)
|
||||
return map;
|
||||
if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object)
|
||||
return map;
|
||||
foreach (var prop in cat.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
|
||||
map[prop.Name] = prop.Value.EnumerateArray()
|
||||
.Where(x => x.ValueKind == JsonValueKind.String)
|
||||
.Select(x => x.GetString() ?? "")
|
||||
.Where(x => x.Length > 0)
|
||||
.ToArray();
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static HashSet<string> LookupCarMethods(Dictionary<string, string[]> map, string fullType, string shortType)
|
||||
{
|
||||
var shortName = shortType;
|
||||
var dot = shortType.LastIndexOf('.');
|
||||
if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..];
|
||||
|
||||
if (map.TryGetValue(fullType, out var exact) && exact.Length > 0)
|
||||
return new HashSet<string>(exact, StringComparer.Ordinal);
|
||||
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
|
||||
return new HashSet<string>(byType, StringComparer.Ordinal);
|
||||
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
|
||||
return new HashSet<string>(byShort, StringComparer.Ordinal);
|
||||
|
||||
foreach (var kv in map)
|
||||
{
|
||||
if (kv.Value.Length == 0) continue;
|
||||
var k = kv.Key;
|
||||
if (k == fullType || k == shortType || k == shortName) return new HashSet<string>(kv.Value, StringComparer.Ordinal);
|
||||
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
|
||||
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
|
||||
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
|
||||
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
|
||||
}
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static bool TryData(JsonElement envelope, out JsonElement data)
|
||||
{
|
||||
if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object)
|
||||
return true;
|
||||
data = envelope;
|
||||
return envelope.ValueKind == JsonValueKind.Object;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement? envelope, string name)
|
||||
{
|
||||
if (envelope is null) return null;
|
||||
var root = envelope.Value;
|
||||
if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String)
|
||||
return direct.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> GetLiteJsonAsync(string path)
|
||||
{
|
||||
var call = await CallLiteAsync(HttpMethod.Get, path, actor: null);
|
||||
if (!call.Ok) return null;
|
||||
using var doc = JsonDocument.Parse(call.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private async Task<ForwardOutcome> ForwardGotoSiteAsync(int carId, int siteId, string actor)
|
||||
{
|
||||
var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}";
|
||||
return await SendLiteExecuteAsync(path, actor);
|
||||
}
|
||||
|
||||
private async Task<ForwardOutcome> ForwardExecuteAsync(
|
||||
string kind, int id, string method, Dictionary<string, string>? query, string actor)
|
||||
{
|
||||
var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}";
|
||||
if (query is { Count: > 0 })
|
||||
{
|
||||
var qs = string.Join("&", query
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Key))
|
||||
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}"));
|
||||
if (qs.Length > 0) path += "?" + qs;
|
||||
}
|
||||
return await SendLiteExecuteAsync(path, actor);
|
||||
}
|
||||
|
||||
private async Task<ForwardOutcome> SendLiteExecuteAsync(string path, string actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
|
||||
if (TryReadConfirmTicket(call.Body, out var token))
|
||||
{
|
||||
call = await CallLiteAsync(HttpMethod.Post, path, actor, new Dictionary<string, string>
|
||||
{
|
||||
[ConfirmTokenHeader] = token
|
||||
});
|
||||
}
|
||||
var success = call.Ok && ParseSuccess(call.Body);
|
||||
return success
|
||||
? new ForwardOutcome(true, "ok", null)
|
||||
: new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}:{ExtractMessage(call.Body)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path);
|
||||
return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private const string ConfirmTokenHeader = "X-Platform-Confirm-Token";
|
||||
|
||||
private static bool TryReadConfirmTicket(string body, out string token)
|
||||
{
|
||||
token = "";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var code = root.TryGetProperty("code", out var c) && c.TryGetInt32(out var n) ? n : 0;
|
||||
if (code != 428) return false;
|
||||
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
token = data.TryGetProperty("confirmToken", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString() ?? ""
|
||||
: "";
|
||||
return !string.IsNullOrWhiteSpace(token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(
|
||||
HttpMethod method, string path, string? actor, IReadOnlyDictionary<string, string>? extraHeaders = null)
|
||||
{
|
||||
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var msg = new HttpRequestMessage(method, url);
|
||||
if (!string.IsNullOrEmpty(_internalToken.Token))
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
|
||||
if (method == HttpMethod.Post)
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
|
||||
if (extraHeaders != null)
|
||||
{
|
||||
foreach (var kv in extraHeaders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null) continue;
|
||||
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
||||
}
|
||||
}
|
||||
using var resp = await client.SendAsync(msg);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Ota;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/ota")]
|
||||
public class OtaController : ControllerBase
|
||||
{
|
||||
private readonly OtaStore _store;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly OtaVehicleSource _vehicles;
|
||||
private readonly OtaJobRunner _jobs;
|
||||
private readonly OpsAuditStore _audits;
|
||||
private readonly OtaOptions _opt;
|
||||
|
||||
public OtaController(
|
||||
OtaStore store,
|
||||
WatchDogClient wd,
|
||||
OtaVehicleSource vehicles,
|
||||
OtaJobRunner jobs,
|
||||
OpsAuditStore audits,
|
||||
IOptions<OtaOptions> opt)
|
||||
{
|
||||
_store = store;
|
||||
_wd = wd;
|
||||
_vehicles = vehicles;
|
||||
_jobs = jobs;
|
||||
_audits = audits;
|
||||
_opt = opt.Value;
|
||||
}
|
||||
|
||||
private string UserName =>
|
||||
User.FindFirst("unique_name")?.Value
|
||||
?? User.Identity?.Name
|
||||
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? "unknown";
|
||||
|
||||
private string Scope => User.FindFirst("scope")?.Value ?? "";
|
||||
|
||||
private bool CanWrite()
|
||||
{
|
||||
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
var ops = User.FindFirst("ops")?.Value ?? "";
|
||||
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return set.Contains("*")
|
||||
|| set.Contains("ops.ota")
|
||||
|| set.Contains("ops.ota.write")
|
||||
|| set.Any(o => o.StartsWith("ops.ota.", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool DenyWrite(out ActionResult denied)
|
||||
{
|
||||
if (CanWrite()) { denied = null!; return false; }
|
||||
denied = StatusCode(StatusCodes.Status403Forbidden, new { message = "需要 Platform 或 ops.ota.* 权限" });
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Audit(string op, string target, string result, string? msg = null) =>
|
||||
_audits.Append(UserName, Scope, op, target, result, msg);
|
||||
|
||||
[HttpGet("settings")]
|
||||
public ActionResult<OtaSettings> GetSettings() => _store.GetSettings();
|
||||
|
||||
[HttpPut("settings")]
|
||||
public ActionResult<OtaSettings> PutSettings([FromBody] OtaSettings settings)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
var saved = _store.SaveSettings(settings);
|
||||
Audit("ops.ota.settings", "settings", "ok");
|
||||
return saved;
|
||||
}
|
||||
|
||||
[HttpGet("target")]
|
||||
public ActionResult<object> GetTarget()
|
||||
{
|
||||
var t = _store.GetTarget();
|
||||
if (t == null) return Ok(new { target = (OtaTarget?)null });
|
||||
return Ok(new
|
||||
{
|
||||
target = t,
|
||||
summary = t.Components.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => OtaHash.Short(kv.Value.Hash))
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("packages")]
|
||||
public ActionResult<List<OtaPackageInfo>> ListPackages() => _store.ListPackages();
|
||||
|
||||
[HttpGet("packages/{id}")]
|
||||
public ActionResult<OtaPackageInfo> GetPackage(string id)
|
||||
{
|
||||
try { return _store.ScanPackage(id); }
|
||||
catch (DirectoryNotFoundException) { return NotFound(); }
|
||||
catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("packages/{id}/activate")]
|
||||
public ActionResult<OtaTarget> Activate(string id, [FromQuery] string? name = null)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
try
|
||||
{
|
||||
var t = _store.ActivatePackage(id, name);
|
||||
Audit("ops.ota.activate", id, "ok");
|
||||
return t;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Audit("ops.ota.activate", id, "fail", ex.Message);
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("packages/{id}")]
|
||||
public IActionResult DeletePackage(string id)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
try
|
||||
{
|
||||
_store.DeletePackage(id);
|
||||
Audit("ops.ota.package.delete", id, "ok");
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("packages/pull")]
|
||||
public async Task<ActionResult<object>> Pull([FromBody] PullPackageRequest req, CancellationToken ct)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
var cars = await _vehicles.ListCarsAsync(ct);
|
||||
var car = cars.FirstOrDefault(c => string.Equals(c.Id, req.CarId, StringComparison.OrdinalIgnoreCase));
|
||||
if (car == null || string.IsNullOrEmpty(car.Ip))
|
||||
return BadRequest(new { message = "车辆不存在或无 IP" });
|
||||
|
||||
var pkgId = _store.BeginPullPackage(car.Ip);
|
||||
try
|
||||
{
|
||||
var baseUrl = ResolvePublicBase();
|
||||
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
|
||||
var time = DateTime.Now.ToString("yyyyMMddHHmmss");
|
||||
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
|
||||
// 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包
|
||||
await Task.Delay(1500, ct);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(90);
|
||||
var idleAfterReceive = TimeSpan.FromSeconds(8);
|
||||
var lastCount = 0;
|
||||
DateTime? lastProgressAt = null;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var info = _store.ScanPackage(pkgId);
|
||||
if (info.Components.Count > lastCount)
|
||||
{
|
||||
lastCount = info.Components.Count;
|
||||
lastProgressAt = DateTime.UtcNow;
|
||||
}
|
||||
else if (_store.TryGetLastReceiveAt(car.Ip, out var recvAt) &&
|
||||
(lastProgressAt == null || recvAt > lastProgressAt.Value))
|
||||
{
|
||||
lastProgressAt = recvAt;
|
||||
}
|
||||
|
||||
if (lastCount > 0 &&
|
||||
lastProgressAt != null &&
|
||||
DateTime.UtcNow - lastProgressAt.Value >= idleAfterReceive)
|
||||
break;
|
||||
|
||||
await Task.Delay(500, ct);
|
||||
}
|
||||
_store.ClearActivePull(car.Ip);
|
||||
var result = _store.ScanPackage(pkgId);
|
||||
if (result.Components.Count == 0)
|
||||
{
|
||||
Audit("ops.ota.package.pull", req.CarId, "empty", pkgId);
|
||||
return BadRequest(new
|
||||
{
|
||||
message =
|
||||
$"未收到任何组件文件(包 {pkgId} 为 0 B)。" +
|
||||
$"WatchDog 会固定 POST 到 http://{{serverIP}}:{_opt.ReceivePort}/upload-mdcs/{{组件}}," +
|
||||
"请把该车 watch_dog.json 的 serverIP 设为本机局域网 IP,并确认本机已监听该端口;" +
|
||||
"同时确认车上已配置 Medulla/Detour/Clumsy 路径。",
|
||||
packageId = pkgId,
|
||||
receivePort = _opt.ReceivePort
|
||||
});
|
||||
}
|
||||
Audit("ops.ota.package.pull", req.CarId, "ok", pkgId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_store.ClearActivePull(car.Ip);
|
||||
Audit("ops.ota.package.pull", req.CarId, "fail", ex.Message);
|
||||
return BadRequest(new { message = ex.Message, packageId = pkgId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("packages/upload")]
|
||||
[RequestSizeLimit(512_000_000)]
|
||||
public async Task<ActionResult<OtaPackageInfo>> Upload(IFormFile? file, CancellationToken ct)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { message = "请上传文件(zip 或单文件)" });
|
||||
|
||||
var pkgId = _store.BeginPullPackage("upload");
|
||||
var dir = _store.PackageDir(pkgId);
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName(file.FileName);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return BadRequest(new { message = "文件名无效" });
|
||||
if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var zipPath = Path.Combine(dir, name);
|
||||
await using (var fs = System.IO.File.Create(zipPath))
|
||||
await file.CopyToAsync(fs, ct);
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, dir, true);
|
||||
System.IO.File.Delete(zipPath);
|
||||
NormalizeUploadLayout(dir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 单文件:按扩展名猜放到 M/D/C
|
||||
var dest = GuessDest(dir, name);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
await using var fs = System.IO.File.Create(dest);
|
||||
await file.CopyToAsync(fs, ct);
|
||||
}
|
||||
_store.ClearActivePull("upload");
|
||||
var info = _store.ScanPackage(pkgId);
|
||||
Audit("ops.ota.package.upload", pkgId, "ok");
|
||||
return info;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_store.ClearActivePull("upload");
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("vehicles")]
|
||||
public async Task<ActionResult<List<OtaVehicleRow>>> Vehicles([FromQuery] bool? latency, CancellationToken ct)
|
||||
{
|
||||
var settings = _store.GetSettings();
|
||||
var doLatency = latency ?? settings.LatencyEnabled;
|
||||
var target = _store.GetTarget();
|
||||
var cars = await _vehicles.ListCarsAsync(ct);
|
||||
|
||||
await Parallel.ForEachAsync(cars, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (car, token) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(car.Ip))
|
||||
{
|
||||
car.Reachable = false;
|
||||
return;
|
||||
}
|
||||
if (doLatency)
|
||||
car.RttMs = await _wd.MeasureRttMsAsync(car.Ip, token);
|
||||
|
||||
var (ok, m, d, c, _) = await _wd.GetMdcInfoAsync(car.Ip, token);
|
||||
car.Reachable = ok;
|
||||
car.Medulla = m;
|
||||
car.Detour = d;
|
||||
car.Clumsy = c;
|
||||
if (target != null)
|
||||
car.Match = BuildMatch(target, m, d, c);
|
||||
});
|
||||
|
||||
return cars;
|
||||
}
|
||||
|
||||
[HttpGet("latency")]
|
||||
public async Task<ActionResult<object>> Latency(CancellationToken ct)
|
||||
{
|
||||
var settings = _store.GetSettings();
|
||||
if (!settings.LatencyEnabled)
|
||||
return Ok(new { enabled = false, items = Array.Empty<object>() });
|
||||
var cars = await _vehicles.ListCarsAsync(ct);
|
||||
var items = new List<object>();
|
||||
foreach (var car in cars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(car.Ip)) continue;
|
||||
var rtt = await _wd.MeasureRttMsAsync(car.Ip, ct);
|
||||
items.Add(new { car.Id, car.Ip, rttMs = rtt, over = rtt == null || rtt > settings.RttThresholdMs });
|
||||
}
|
||||
return Ok(new { enabled = true, thresholdMs = settings.RttThresholdMs, items });
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
public ActionResult<List<OtaJob>> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take);
|
||||
|
||||
[HttpGet("jobs/{id}")]
|
||||
public ActionResult<OtaJob> GetJob(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var j = _store.GetJob(id);
|
||||
return j == null ? NotFound() : j;
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("jobs")]
|
||||
public ActionResult<OtaJob> CreateJob([FromBody] CreateSyncJobRequest req)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
try
|
||||
{
|
||||
var job = _jobs.EnqueueSync(req, UserName);
|
||||
Audit("ops.ota.job.create", job.Id, "ok", $"cars={req.CarIds.Count}");
|
||||
return job;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{id}/cancel")]
|
||||
public IActionResult Cancel(string id)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
var ok = _jobs.Cancel(id);
|
||||
Audit("ops.ota.job.cancel", id, ok ? "ok" : "noop");
|
||||
return ok ? Ok(new { ok = true }) : BadRequest(new { message = "无法取消" });
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{id}/retry")]
|
||||
public ActionResult<OtaJob> Retry(string id)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
try
|
||||
{
|
||||
var job = _jobs.RetryFailed(id, UserName);
|
||||
if (job == null) return NotFound();
|
||||
Audit("ops.ota.job.retry", id, "ok", job.Id);
|
||||
return job;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("config/{carId}/{app}")]
|
||||
public async Task<ActionResult<object>> GetConfig(string carId, string app, CancellationToken ct)
|
||||
{
|
||||
var cars = await _vehicles.ListCarsAsync(ct);
|
||||
var car = cars.FirstOrDefault(c => string.Equals(c.Id, carId, StringComparison.OrdinalIgnoreCase));
|
||||
if (car?.Ip == null) return BadRequest(new { message = "车辆无 IP" });
|
||||
try
|
||||
{
|
||||
var json = await _wd.GetJsonAsync(car.Ip, app, ct);
|
||||
return Ok(new { carId, app, json });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("config/push")]
|
||||
public ActionResult<OtaJob> ConfigPush([FromBody] CreateConfigPushRequest req)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
try
|
||||
{
|
||||
var job = _jobs.EnqueueConfigPush(req, UserName);
|
||||
Audit("ops.ota.config.push", job.Id, "ok", req.App);
|
||||
return job;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("custom-file")]
|
||||
[RequestSizeLimit(512_000_000)]
|
||||
public async Task<ActionResult<OtaJob>> CustomFile(
|
||||
[FromForm] string carIds,
|
||||
[FromForm] string remotePath,
|
||||
[FromForm] string? restartOps,
|
||||
[FromForm] int? restartOp,
|
||||
[FromForm] List<IFormFile>? files,
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (DenyWrite(out var denied)) return denied;
|
||||
var uploadFiles = new List<IFormFile>();
|
||||
if (files is { Count: > 0 }) uploadFiles.AddRange(files.Where(f => f.Length > 0));
|
||||
if (file is { Length: > 0 }) uploadFiles.Add(file);
|
||||
if (uploadFiles.Count == 0)
|
||||
return BadRequest(new { message = "请至少选择一个文件" });
|
||||
|
||||
var ids = ParseCarIds(carIds);
|
||||
var ops = new List<int>();
|
||||
if (!string.IsNullOrWhiteSpace(restartOps))
|
||||
{
|
||||
try
|
||||
{
|
||||
ops = System.Text.Json.JsonSerializer.Deserialize<List<int>>(restartOps) ?? new();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
if (ops.Count == 0 && restartOp.HasValue) ops.Add(restartOp.Value);
|
||||
if (ops.Count == 0) ops.Add(-1);
|
||||
|
||||
var tmpDir = Path.Combine(_store.Root, "uploads", $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
var items = new List<OtaCustomFileItem>();
|
||||
foreach (var f in uploadFiles)
|
||||
{
|
||||
var safe = Path.GetFileName(f.FileName);
|
||||
var local = Path.Combine(tmpDir, $"{items.Count}_{safe}");
|
||||
await using (var fs = System.IO.File.Create(local))
|
||||
await f.CopyToAsync(fs, ct);
|
||||
items.Add(new OtaCustomFileItem { LocalPath = local, FileName = safe });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var job = _jobs.EnqueueCustomFile(new CreateCustomFileJobRequest
|
||||
{
|
||||
CarIds = ids,
|
||||
RemotePath = remotePath,
|
||||
RestartOps = ops
|
||||
}, items, UserName);
|
||||
Audit("ops.ota.customFile", job.Id, "ok", string.Join(",", items.Select(i => i.FileName)));
|
||||
return job;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolvePublicBase()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_opt.PublicBaseUrl))
|
||||
return _opt.PublicBaseUrl.TrimEnd('/');
|
||||
return $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
private static List<string> ParseCarIds(string carIds)
|
||||
{
|
||||
var trimmed = carIds.Trim();
|
||||
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ids = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||
if (ids != null)
|
||||
return ids.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to comma-separated form data below.
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
void One(string key, string? vehicleHash)
|
||||
{
|
||||
if (!target.Components.TryGetValue(key, out var art))
|
||||
{
|
||||
map[key] = "missing-target";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(vehicleHash)) { map[key] = "unknown"; return; }
|
||||
map[key] = string.Equals(vehicleHash, art.Hash, StringComparison.Ordinal) ? "match" : "mismatch";
|
||||
}
|
||||
One("M.exe", m?.Exe?.Version);
|
||||
One("M.dll", m?.Dll?.Version);
|
||||
One("M.pdb", m?.Pdb?.Version);
|
||||
One("D.exe", d?.Exe?.Version);
|
||||
One("C.exe", c?.Exe?.Version);
|
||||
One("C.dll", c?.Dll?.Version);
|
||||
One("C.pdb", c?.Pdb?.Version);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void NormalizeUploadLayout(string dir)
|
||||
{
|
||||
// 若 zip 根下直接是 M/D/C 或 Medulla.exe,尽量归位
|
||||
var medulla = Directory.GetFiles(dir, "Medulla.exe", SearchOption.AllDirectories).FirstOrDefault();
|
||||
if (medulla != null)
|
||||
{
|
||||
var dest = Path.Combine(dir, "M", "Medulla.exe");
|
||||
if (!string.Equals(medulla, dest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
System.IO.File.Copy(medulla, dest, true);
|
||||
}
|
||||
}
|
||||
var detour = Directory.GetFiles(dir, "Detour.exe", SearchOption.AllDirectories).FirstOrDefault();
|
||||
if (detour != null)
|
||||
{
|
||||
var dest = Path.Combine(dir, "D", "Detour.exe");
|
||||
if (!string.Equals(detour, dest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
System.IO.File.Copy(detour, dest, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GuessDest(string dir, string fileName)
|
||||
{
|
||||
var n = fileName.ToLowerInvariant();
|
||||
if (n.Contains("medulla") && n.EndsWith(".exe")) return Path.Combine(dir, "M", "Medulla.exe");
|
||||
if (n.Contains("cartactivator") && n.EndsWith(".dll")) return Path.Combine(dir, "M", "plugins", "CartActivator.dll");
|
||||
if (n.Contains("detour")) return Path.Combine(dir, "D", "Detour.exe");
|
||||
if (n.Contains("clumsy") && n.EndsWith(".exe")) return Path.Combine(dir, "C", "ClumsyConsole.exe");
|
||||
return Path.Combine(dir, "M", Path.GetFileName(fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Ota;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// WatchDog 回传包接收端。
|
||||
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey},
|
||||
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
|
||||
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class OtaReceiveController : ControllerBase
|
||||
{
|
||||
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
|
||||
|
||||
private readonly OtaStore _store;
|
||||
private readonly ILogger<OtaReceiveController> _log;
|
||||
|
||||
public OtaReceiveController(OtaStore store, ILogger<OtaReceiveController> log)
|
||||
{
|
||||
_store = store;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpGet("/hello")]
|
||||
[HttpGet("/api/ota/receive/hello")]
|
||||
public IActionResult Hello() => Ok("ok");
|
||||
|
||||
/// <summary>WatchDog 官方路径:/upload-mdcs/{Medullaexe|...}</summary>
|
||||
[HttpPost("/upload-mdcs/{routeKey}")]
|
||||
[HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")]
|
||||
[HttpPost("/api/ota/receive/upload-mdcs{routeKey}")]
|
||||
[RequestSizeLimit(512_000_000)]
|
||||
public Task<IActionResult> UploadMdcs(string routeKey, CancellationToken ct)
|
||||
=> SaveAsync(routeKey, ct);
|
||||
|
||||
[HttpPost("/upload-history/{routeKey}")]
|
||||
[HttpPost("/api/ota/receive/upload-history/{routeKey}")]
|
||||
[HttpPost("/api/ota/receive/upload-history{routeKey}")]
|
||||
[RequestSizeLimit(512_000_000)]
|
||||
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
|
||||
{
|
||||
var ip = ResolveTcpRemoteIp();
|
||||
if (!_store.TryGetActivePullId(ip, out _))
|
||||
{
|
||||
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip ?? "unknown");
|
||||
return BadRequest("no active pull session");
|
||||
}
|
||||
|
||||
var day = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip ?? "unknown", "unknown"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var file = await ReadFirstFileAsync(ct);
|
||||
if (file == null || file.Length == 0) return BadRequest("empty");
|
||||
// 净化文件名:routeKey / FileName 都可能含路径分隔符,必须 GetFileName 防穿越。
|
||||
var rawName = string.IsNullOrWhiteSpace(file.FileName) ? routeKey : file.FileName;
|
||||
var safeName = SafeFileName(rawName, "unnamed");
|
||||
var path = Path.Combine(dir, safeName);
|
||||
await using var fs = System.IO.File.Create(path);
|
||||
await file.CopyToAsync(fs, ct);
|
||||
_store.NotePullReceive(ip);
|
||||
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SaveAsync(string routeKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clientIp = ResolveTcpRemoteIp();
|
||||
if (!_store.TryGetActivePullId(clientIp, out _))
|
||||
{
|
||||
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown");
|
||||
return BadRequest("no active pull session");
|
||||
}
|
||||
|
||||
var file = await ReadFirstFileAsync(ct);
|
||||
if (file == null || file.Length == 0) return BadRequest("empty file");
|
||||
|
||||
var dest = _store.ResolveReceivePath(routeKey, clientIp);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
await using (var fs = System.IO.File.Create(dest))
|
||||
await file.CopyToAsync(fs, ct);
|
||||
_store.NotePullReceive(clientIp);
|
||||
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA receive failed {Route}", routeKey);
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>优先取 ForwardedHeaders 之前写入的 TCP 对端 IP,避免 X-Forwarded-For 投毒。</summary>
|
||||
private string? ResolveTcpRemoteIp()
|
||||
{
|
||||
if (HttpContext.Items.TryGetValue(TcpRemoteIpItemKey, out var boxed) && boxed is string s && !string.IsNullOrWhiteSpace(s))
|
||||
return s;
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Request.HasFormContentType) return null;
|
||||
var form = await Request.ReadFormAsync(ct);
|
||||
return form.Files.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string SafeFileName(string raw, string fallback)
|
||||
{
|
||||
var safe = Path.GetFileName(raw);
|
||||
if (string.IsNullOrWhiteSpace(safe)) safe = fallback;
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(ch, '_');
|
||||
return string.IsNullOrWhiteSpace(safe) ? fallback : safe;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
||||
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
||||
/// 投影 API 占位:真实落地时由 YARP 反代到 Simple3 WebAPI 的 /api/projection/* 路径。
|
||||
/// 本地 Mock 数据仅用于无 Simple3 运行时的开发联调。
|
||||
///
|
||||
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||
/// </summary>
|
||||
|
||||
@@ -29,10 +29,13 @@ public class RbacController : ControllerBase
|
||||
new("ops.car.gohome", "车辆 · 回库"),
|
||||
new("ops.car.resetSession", "车辆 · 重置会话"),
|
||||
new("ops.car.manualCharge", "车辆 · 手动充电"),
|
||||
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
|
||||
new("ops.task.pause", "任务 · 暂停"),
|
||||
new("ops.task.cancel", "任务 · 取消"),
|
||||
new("ops.task.reassign", "任务 · 改派"),
|
||||
new("ops.task.boostPriority", "任务 · 提升优先级"),
|
||||
new("ops.ota", "OTA · 运维读写"),
|
||||
new("ops.ota.write", "OTA · 写操作"),
|
||||
new("monitor.note.write", "监控 · 写运营备注"),
|
||||
new("auth.manage", "系统 · 权限与角色管理"),
|
||||
};
|
||||
@@ -59,18 +62,35 @@ public class RbacController : ControllerBase
|
||||
|
||||
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
|
||||
[HttpGet("catalog")]
|
||||
public IActionResult Catalog() => Ok(new
|
||||
public IActionResult Catalog()
|
||||
{
|
||||
pages = PageCatalog.All,
|
||||
ops = KnownOps,
|
||||
widgets = KnownWidgets,
|
||||
scopes = new[]
|
||||
var actorPages = ActorPlatformPages();
|
||||
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
|
||||
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
|
||||
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
|
||||
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
|
||||
}
|
||||
});
|
||||
pages = PageCatalog.All,
|
||||
ops = KnownOps,
|
||||
widgets = KnownWidgets,
|
||||
scopes = new[]
|
||||
{
|
||||
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
|
||||
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
|
||||
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
|
||||
},
|
||||
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
|
||||
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[PageCatalog.ScopePlatform] = grantablePlatform,
|
||||
[PageCatalog.ScopeMonitor] = grantableMonitor,
|
||||
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
|
||||
},
|
||||
platformToMonitor = PageCatalog.PlatformToMonitor
|
||||
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
|
||||
.ToList(),
|
||||
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────── 角色 ─────────────────────────
|
||||
|
||||
@@ -78,10 +98,12 @@ public class RbacController : ControllerBase
|
||||
public IActionResult ListRoles() => Ok(_store.ListRoles());
|
||||
|
||||
[HttpPost("roles")]
|
||||
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
|
||||
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
|
||||
Ok(_store.CreateRole(ClampPages(req))));
|
||||
|
||||
[HttpPut("roles/{id}")]
|
||||
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
|
||||
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
|
||||
Ok(_store.UpdateRole(id, ClampPages(req))));
|
||||
|
||||
[HttpDelete("roles/{id}")]
|
||||
public IActionResult DeleteRole(string id) => Guard(() =>
|
||||
@@ -134,4 +156,43 @@ public class RbacController : ControllerBase
|
||||
|
||||
private string? CurrentUserId() =>
|
||||
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
|
||||
private List<string> ActorPlatformPages()
|
||||
{
|
||||
var id = CurrentUserId();
|
||||
if (string.IsNullOrEmpty(id)) return new();
|
||||
var user = _store.FindUserById(id);
|
||||
if (user is null) return new();
|
||||
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
|
||||
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
|
||||
/// </summary>
|
||||
private SaveRoleRequest ClampPages(SaveRoleRequest req)
|
||||
{
|
||||
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
|
||||
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
|
||||
var pages = req.Pages ?? new List<string>();
|
||||
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var scopeKeys = scope == PageCatalog.Wildcard
|
||||
? PageCatalog.All.Select(p => p.Key).ToList()
|
||||
: PageCatalog.KeysForScope(scope).ToList();
|
||||
if (scopeKeys.All(k => grantable.Contains(k)))
|
||||
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
|
||||
return req with
|
||||
{
|
||||
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
}
|
||||
var clamped = pages
|
||||
.Select(PageCatalog.NormalizeKey)
|
||||
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return req with { Pages = clamped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Signal;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
|
||||
/// 文件落在 Simple3 工作目录 <c>Config/Signal/*.json</c>,不依赖 Simple3 进程是否在跑。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
[Route("api/signal-data")]
|
||||
public sealed class SignalDataController : ControllerBase
|
||||
{
|
||||
private readonly SignalDataStore _store;
|
||||
private readonly ConfigStore _config;
|
||||
|
||||
public SignalDataController(SignalDataStore store, ConfigStore config)
|
||||
{
|
||||
_store = store;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult List([FromQuery] bool summary = false)
|
||||
{
|
||||
var enabled = SignalEnabled();
|
||||
var tables = summary
|
||||
? _store.GetTables().Select(ProjectSummary).ToList()
|
||||
: _store.GetTables().Select(ProjectTable).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
signalEnabled = enabled,
|
||||
workingDirectory = _store.ResolveWorkingDirectory(),
|
||||
tables
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public IActionResult Get(string id)
|
||||
{
|
||||
var table = _store.Find(id);
|
||||
if (table == null)
|
||||
return NotFound(new { message = $"未知数据表:{id}" });
|
||||
return Ok(ProjectTable(table));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public IActionResult Save(string id, [FromBody] JsonElement body)
|
||||
{
|
||||
var table = _store.Find(id);
|
||||
if (table == null)
|
||||
return NotFound(new { message = $"未知数据表:{id}" });
|
||||
|
||||
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
return BadRequest(new { message = "请求体需要 rows 数组" });
|
||||
|
||||
JsonArray rows;
|
||||
try
|
||||
{
|
||||
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_store.SaveRows(table, rows);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
|
||||
}
|
||||
|
||||
return Ok(ProjectTable(table));
|
||||
}
|
||||
|
||||
private object ProjectSummary(SignalTableDef table) => new
|
||||
{
|
||||
id = table.Id,
|
||||
title = table.Title,
|
||||
category = table.Category,
|
||||
fileName = table.FileName
|
||||
};
|
||||
|
||||
private object ProjectTable(SignalTableDef table)
|
||||
{
|
||||
var (path, error) = _store.ResolveFile(table, createDir: false);
|
||||
var exists = path != null && System.IO.File.Exists(path);
|
||||
return new
|
||||
{
|
||||
id = table.Id,
|
||||
title = table.Title,
|
||||
category = table.Category,
|
||||
fileName = table.FileName,
|
||||
exists,
|
||||
error,
|
||||
columns = table.Columns,
|
||||
rows = _store.LoadRows(table)
|
||||
};
|
||||
}
|
||||
|
||||
private bool SignalEnabled() =>
|
||||
_config.GetDeployment().ToLauncherSceneIds()
|
||||
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace MiGu.Server.Controllers;
|
||||
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
|
||||
///
|
||||
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
|
||||
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
||||
/// 前端主流程为「刷新」读库、「默认」从 Simple3 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase
|
||||
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 更新单条字段
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 删除单条字段
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _service.DeleteAsync(id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase
|
||||
/// 返回实际写入条数 <c>{ count }</c>。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
||||
{
|
||||
var count = await _service.SaveBatchAsync(req);
|
||||
|
||||
return Ok(new { count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace MiGu.Server.Controllers;
|
||||
/// </list>
|
||||
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
||||
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
||||
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
|
||||
/// 驱动 Simple3 下次启动选择性加载选定的导航场景插件。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -25,10 +25,10 @@ namespace MiGu.Server.Controllers;
|
||||
public class WizardController : ControllerBase
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly ILogger<WizardController> _log;
|
||||
|
||||
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
|
||||
public WizardController(ConfigStore store, Simple3Launcher launcher, ILogger<WizardController> log)
|
||||
{
|
||||
_store = store;
|
||||
_launcher = launcher;
|
||||
@@ -81,12 +81,11 @@ public class WizardController : ControllerBase
|
||||
|
||||
_store.PutDeployment(profile);
|
||||
|
||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||
// 平台 → 内核联动:把导航选型写入 Simple3 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
||||
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手)。
|
||||
var sceneIds = profile.ToLauncherSceneIds();
|
||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||
|
||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||
@@ -108,7 +107,7 @@ public class WizardController : ControllerBase
|
||||
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
|
||||
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
|
||||
/// </summary>
|
||||
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
|
||||
private static object Project(DeploymentProfile dp, Simple3Launcher.ActiveScenesWriteResult? write = null) => new
|
||||
{
|
||||
configured = dp.Configured,
|
||||
platformType = dp.PlatformType,
|
||||
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
|
||||
navigationKinds = dp.NavigationKinds,
|
||||
scenarios = dp.Scenarios,
|
||||
updatedBy = dp.UpdatedBy,
|
||||
activeSceneIds = dp.ToActiveSceneIds(),
|
||||
activeSceneIds = dp.ToLauncherSceneIds(),
|
||||
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
||||
activeScenesWrite = write == null ? null : new
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using MiGu.DB.Abstractions.Exceptions;
|
||||
using MiGu.Server.Wms;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
@@ -191,10 +192,6 @@ public sealed class WmsController : ControllerBase
|
||||
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q, [FromQuery] Guid? containerId) =>
|
||||
_service.ContainerMaterials(q, containerId);
|
||||
|
||||
[HttpPost("container-materials")]
|
||||
public async Task<IActionResult> SaveContainerMaterial([FromBody] BindMaterialRequest req) =>
|
||||
Ok(await _service.BindMaterial(req, User.ActorName()));
|
||||
|
||||
[HttpPost("container-materials/bind")]
|
||||
public async Task<IActionResult> BindMaterial([FromBody] BindMaterialRequest req) =>
|
||||
Ok(await _service.BindMaterial(req, User.ActorName()));
|
||||
@@ -276,15 +273,31 @@ public sealed class WmsExceptionFilter : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (context.Exception is not InvalidOperationException ex) return;
|
||||
context.Result = new BadRequestObjectResult(new { message = ex.Message });
|
||||
switch (context.Exception)
|
||||
{
|
||||
case ConcurrencyConflictException ex:
|
||||
context.Result = new ObjectResult(new { message = ex.Message }) { StatusCode = StatusCodes.Status409Conflict };
|
||||
break;
|
||||
case EntityLockedException ex:
|
||||
context.Result = new ObjectResult(new { message = ex.Message }) { StatusCode = StatusCodes.Status409Conflict };
|
||||
break;
|
||||
case EntityNotFoundException ex:
|
||||
context.Result = new NotFoundObjectResult(new { message = ex.Message });
|
||||
break;
|
||||
case InvalidOperationException ex:
|
||||
context.Result = new BadRequestObjectResult(new { message = ex.Message });
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TransportRequestQuery
|
||||
{
|
||||
public string TriggerType { get; set; } = WmsTransportTriggerTypes.MaterialCall;
|
||||
public string TriggerType { get; set; } = nameof(WmsTransportTriggerType.MaterialCall);
|
||||
public Guid? RuleId { get; set; }
|
||||
public string? RequestSiteId { get; set; }
|
||||
public Guid? MaterialId { get; set; }
|
||||
|
||||
@@ -17,16 +17,23 @@ public static class DashboardShortcutCatalog
|
||||
["platform-config"] = "admin-map-editor",
|
||||
["mission"] = "admin-task-templates",
|
||||
["cars"] = "admin-cars",
|
||||
["auth"] = "admin-config-system-center",
|
||||
["system"] = "admin-config-system-center",
|
||||
["auth"] = "admin-config-auth",
|
||||
["system"] = "admin-config-system",
|
||||
["admin-config-system-center"] = "admin-config-auth",
|
||||
["ops"] = "admin-config-ops-center",
|
||||
["tasks"] = "admin-config-strategy",
|
||||
["tasks"] = "admin-task-templates",
|
||||
["admin-config-strategy"] = "admin-task-templates",
|
||||
};
|
||||
|
||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||
[
|
||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
||||
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
|
||||
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
|
||||
new("admin-playback", "admin-playback", PageCatalog.ScopePlatform),
|
||||
new("admin-ops-log", "admin-ops-log", PageCatalog.ScopePlatform),
|
||||
new("admin-notes", "admin-notes", PageCatalog.ScopePlatform),
|
||||
new("admin-logs", "admin-logs", PageCatalog.ScopePlatform),
|
||||
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
||||
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
|
||||
@@ -36,17 +43,18 @@ public static class DashboardShortcutCatalog
|
||||
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
||||
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
|
||||
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
|
||||
new("admin-wcs-template-proto", "admin-wcs-template-proto", PageCatalog.ScopePlatform),
|
||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||
new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform),
|
||||
new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-system", "admin-config-system", PageCatalog.ScopePlatform),
|
||||
new("admin-config-auth", "admin-config-auth", PageCatalog.ScopePlatform),
|
||||
];
|
||||
|
||||
private static readonly ShortcutDef[] MonitorShortcuts =
|
||||
[
|
||||
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
|
||||
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
|
||||
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
|
||||
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
|
||||
@@ -78,16 +86,16 @@ public static class DashboardShortcutCatalog
|
||||
"admin-map-monitor",
|
||||
"admin-vehicle-hub",
|
||||
"admin-task-templates",
|
||||
"admin-config-system-center",
|
||||
"admin-config-auth",
|
||||
"admin-config-ops-center",
|
||||
"admin-config-strategy"
|
||||
"admin-config-facility"
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
|
||||
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
|
||||
|
||||
private static readonly HashSet<string> ExcludedKeys =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
|
||||
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard" };
|
||||
|
||||
public static bool IsValidKey(string key) =>
|
||||
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace MiGu.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardShortcutService
|
||||
{
|
||||
private readonly PlatformDbContext _db;
|
||||
private readonly MiGuDbContext _db;
|
||||
private readonly RbacStore _rbac;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
@@ -15,7 +15,7 @@ public sealed class DashboardShortcutService
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public DashboardShortcutService(PlatformDbContext db, RbacStore rbac)
|
||||
public DashboardShortcutService(MiGuDbContext db, RbacStore rbac)
|
||||
{
|
||||
_db = db;
|
||||
_rbac = rbac;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 单例:轮询 Simple3 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
|
||||
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;Simple3 离线仍可查最近记录。
|
||||
/// </summary>
|
||||
public sealed class AlarmCollector
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<AlarmCollector> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
|
||||
public AlarmCollector(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<Simple3Options> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<AlarmCollector> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private sealed class CurrentAlarm
|
||||
{
|
||||
public int CarId;
|
||||
public string CarName = "";
|
||||
public string Info = "";
|
||||
public int Level;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var cars = await FetchCarsAsync(ct);
|
||||
if (cars == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = new Dictionary<int, CurrentAlarm>();
|
||||
await Parallel.ForEachAsync(
|
||||
cars,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
|
||||
async (car, token) =>
|
||||
{
|
||||
var (info, level) = await FetchCarAlarmAsync(car.Id, token);
|
||||
if (string.IsNullOrWhiteSpace(info)) return;
|
||||
lock (current)
|
||||
{
|
||||
current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level };
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
|
||||
await ReconcileAsync(db, current, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm reconcile failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CarRow
|
||||
{
|
||||
public int Id;
|
||||
public string Name = "";
|
||||
}
|
||||
|
||||
private async Task<List<CarRow>?> FetchCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<CarRow>();
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0;
|
||||
if (id <= 0) continue;
|
||||
var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "";
|
||||
list.Add(new CarRow { Id = id, Name = name });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "alarm fetch cars failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return ("", 0);
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
return ("", 0);
|
||||
|
||||
string info = "";
|
||||
var level = 0;
|
||||
foreach (var kv in data.EnumerateArray())
|
||||
{
|
||||
var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null;
|
||||
var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null;
|
||||
if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? "";
|
||||
else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level);
|
||||
}
|
||||
|
||||
info = info.Trim();
|
||||
if (info is "0" or "/" or "-") info = "";
|
||||
return (info, level);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ("", 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReconcileAsync(MiGuDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
|
||||
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
|
||||
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
|
||||
|
||||
// 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
|
||||
foreach (var cur in current.Values)
|
||||
{
|
||||
if (activeByCar.TryGetValue(cur.CarId, out var rec))
|
||||
{
|
||||
var same =
|
||||
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
|
||||
rec.Level == cur.Level;
|
||||
if (same)
|
||||
{
|
||||
rec.CarName = cur.CarName;
|
||||
rec.LastAt = now;
|
||||
continue;
|
||||
}
|
||||
|
||||
rec.Status = "cleared";
|
||||
rec.ResolvedAt = now;
|
||||
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
|
||||
}
|
||||
|
||||
db.VehicleAlarms.Add(new VehicleAlarmRecord
|
||||
{
|
||||
CarId = cur.CarId,
|
||||
CarName = cur.CarName,
|
||||
Info = cur.Info,
|
||||
Level = cur.Level,
|
||||
Status = "active",
|
||||
FirstAt = now,
|
||||
LastAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// 消失 → 恢复
|
||||
foreach (var rec in active)
|
||||
{
|
||||
if (current.ContainsKey(rec.CarId)) continue;
|
||||
rec.Status = "cleared";
|
||||
rec.ResolvedAt = now;
|
||||
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var c = _httpFactory.CreateClient();
|
||||
c.Timeout = TimeSpan.FromSeconds(10);
|
||||
return c;
|
||||
}
|
||||
|
||||
private HttpRequestMessage Req(string url)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
return req;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
|
||||
public sealed class AlarmCollectorService : BackgroundService
|
||||
{
|
||||
private readonly AlarmCollector _collector;
|
||||
private readonly ILogger<AlarmCollectorService> _log;
|
||||
|
||||
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> log)
|
||||
{
|
||||
_collector = collector;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _collector.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>投影 /projection/deliveries 返回的单行(camelCase)。</summary>
|
||||
public sealed class CdmTaskDto
|
||||
{
|
||||
public string id { get; set; } = "";
|
||||
public string? taskId { get; set; }
|
||||
public int missionId { get; set; }
|
||||
public string missionName { get; set; } = "";
|
||||
public string missionTypeName { get; set; } = "";
|
||||
public int srcSiteId { get; set; }
|
||||
public string srcLabel { get; set; } = "";
|
||||
public int dstSiteId { get; set; }
|
||||
public string dstLabel { get; set; } = "";
|
||||
public string status { get; set; } = "";
|
||||
public string statusCode { get; set; } = "";
|
||||
public int? carId { get; set; }
|
||||
public string? carName { get; set; }
|
||||
public int priority { get; set; }
|
||||
public string? createTime { get; set; }
|
||||
public string? startTime { get; set; }
|
||||
public string? finishTime { get; set; }
|
||||
public string? stuckReason { get; set; }
|
||||
public bool overdue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单例:从 Simple3 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
|
||||
/// 同时维护「Simple3 是否在线 / 最近同步时间」,供任务页离线降级展示。
|
||||
/// </summary>
|
||||
public sealed class CdmTaskSyncer
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly ILogger<CdmTaskSyncer> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public volatile bool Online;
|
||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||
public int LastCount { get; private set; }
|
||||
|
||||
public CdmTaskSyncer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<Simple3Options> sl,
|
||||
InternalTokenStore token,
|
||||
ILogger<CdmTaskSyncer> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。</summary>
|
||||
public async Task<bool> SyncOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct)) return Online;
|
||||
try
|
||||
{
|
||||
var dtos = await FetchAsync(ct);
|
||||
if (dtos == null)
|
||||
{
|
||||
Online = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
|
||||
await UpsertAsync(db, dtos, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 拉取成功即视为在线;落库失败只记日志,不影响在线判定
|
||||
_log.LogDebug(ex, "cdm upsert failed");
|
||||
}
|
||||
|
||||
Online = true;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
LastCount = dtos.Count;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<CdmTaskDto>?> FetchAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
using var req = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"http://127.0.0.1:{port}/projection/deliveries?includeFinished=true&includeAborted=true");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<List<CdmTaskDto>>(text, JsonOpt) ?? new List<CdmTaskDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "cdm fetch failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertAsync(MiGuDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
|
||||
{
|
||||
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
|
||||
if (valid.Count == 0) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var ids = valid.Select(d => d.id).ToList();
|
||||
var existing = await db.CdmTasks.Where(t => ids.Contains(t.Id)).ToDictionaryAsync(t => t.Id, ct);
|
||||
|
||||
foreach (var d in valid)
|
||||
{
|
||||
if (existing.TryGetValue(d.id, out var rec))
|
||||
{
|
||||
Map(d, rec);
|
||||
rec.LastSeenAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = new CdmTaskRecord { Id = d.id, FirstSeenAt = now, LastSeenAt = now };
|
||||
Map(d, created);
|
||||
db.CdmTasks.Add(created);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static void Map(CdmTaskDto d, CdmTaskRecord rec)
|
||||
{
|
||||
rec.TaskId = string.IsNullOrWhiteSpace(d.taskId) ? null : d.taskId;
|
||||
rec.MissionId = d.missionId;
|
||||
rec.MissionName = d.missionName ?? "";
|
||||
rec.MissionTypeName = d.missionTypeName ?? "";
|
||||
rec.SrcSiteId = d.srcSiteId;
|
||||
rec.SrcLabel = d.srcLabel ?? "";
|
||||
rec.DstSiteId = d.dstSiteId;
|
||||
rec.DstLabel = d.dstLabel ?? "";
|
||||
rec.Status = d.status ?? "";
|
||||
rec.StatusCode = d.statusCode ?? "";
|
||||
rec.CarId = d.carId;
|
||||
rec.CarName = d.carName;
|
||||
rec.Priority = d.priority;
|
||||
rec.CreateTime = d.createTime;
|
||||
rec.StartTime = d.startTime;
|
||||
rec.FinishTime = d.finishTime;
|
||||
rec.StuckReason = d.stuckReason;
|
||||
rec.Overdue = d.overdue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。</summary>
|
||||
public sealed class CdmTaskSyncService : BackgroundService
|
||||
{
|
||||
private readonly CdmTaskSyncer _syncer;
|
||||
private readonly ILogger<CdmTaskSyncService> _log;
|
||||
|
||||
public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger<CdmTaskSyncService> log)
|
||||
{
|
||||
_syncer = syncer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); }
|
||||
catch { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await _syncer.SyncOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "cdm sync loop error"); }
|
||||
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); }
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>与 Simple3 GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
|
||||
public sealed class FleetHealthRowDto
|
||||
{
|
||||
public int CarId { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public string? OnboardUrl { get; set; }
|
||||
public int? LatencyMs { get; set; }
|
||||
public bool? Reachable { get; set; }
|
||||
public string? ProbedAt { get; set; }
|
||||
public double? UptimeSecs { get; set; }
|
||||
public double? AlarmActiveSecs { get; set; }
|
||||
public double? FaultRatePercent { get; set; }
|
||||
public bool? IsAlarmActive { get; set; }
|
||||
public double? CpuPercent { get; set; }
|
||||
public double? MemPercent { get; set; }
|
||||
/// <summary>latency 探测通道:watchdog | onboard | none</summary>
|
||||
public string? LatencySource { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Ota;
|
||||
|
||||
namespace MiGu.Server.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
|
||||
/// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
|
||||
/// </summary>
|
||||
public sealed class FleetHealthService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly InternalTokenStore _token;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly ILogger<FleetHealthService> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public FleetHealthService(
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<Simple3Options> sl,
|
||||
InternalTokenStore token,
|
||||
WatchDogClient wd,
|
||||
ILogger<FleetHealthService> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_wd = wd;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await FetchSimple3HealthAsync(ct);
|
||||
if (rows.Count == 0)
|
||||
rows = await BuildRowsFromCarsAsync(ct);
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
|
||||
async (row, token) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Ip))
|
||||
{
|
||||
// 无 IP 时保留 Simple3 原探测结果
|
||||
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// 取两次 TCP 连接的较小值,降低偶发握手抖动
|
||||
var a = await _wd.MeasureRttMsAsync(row.Ip, token);
|
||||
var b = await _wd.MeasureRttMsAsync(row.Ip, token);
|
||||
int? rtt = (a, b) switch
|
||||
{
|
||||
(null, null) => null,
|
||||
(int x, null) => x,
|
||||
(null, int y) => y,
|
||||
(int x, int y) => Math.Min(x, y)
|
||||
};
|
||||
|
||||
if (rtt != null)
|
||||
{
|
||||
row.LatencyMs = rtt;
|
||||
row.Reachable = true;
|
||||
row.LatencySource = "watchdog";
|
||||
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
}
|
||||
else
|
||||
{
|
||||
// WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达
|
||||
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
|
||||
{
|
||||
row.LatencySource = "onboard";
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Reachable = false;
|
||||
row.LatencyMs = null;
|
||||
row.LatencySource = "watchdog";
|
||||
row.ProbedAt = DateTimeOffset.UtcNow.ToString("O");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<List<FleetHealthRowDto>> FetchSimple3HealthAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/fleet/health");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
var list = JsonSerializer.Deserialize<List<FleetHealthRowDto>>(text, JsonOpt);
|
||||
return list ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "fleet/health from Simple3 failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FleetHealthRowDto>> BuildRowsFromCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
||||
|
||||
var list = new List<FleetHealthRowDto>();
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var rawId = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var id)
|
||||
? id
|
||||
: 0;
|
||||
if (rawId <= 0) continue;
|
||||
var ip = el.TryGetProperty("ip", out var ipEl) ? ipEl.GetString() : null;
|
||||
var name = el.TryGetProperty("name", out var nEl) ? nEl.GetString() : null;
|
||||
var onboard = el.TryGetProperty("onboardUrl", out var oEl) ? oEl.GetString() : null;
|
||||
if (string.IsNullOrEmpty(onboard) && !string.IsNullOrEmpty(ip))
|
||||
onboard = $"http://{ip}:8081";
|
||||
list.Add(new FleetHealthRowDto
|
||||
{
|
||||
CarId = rawId,
|
||||
CarName = name,
|
||||
Ip = ip,
|
||||
OnboardUrl = onboard
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "projection/cars fallback for fleet health failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
global using MiGu.DB.Kernel.Context;
|
||||
global using MiGu.DB.Kernel.Entities;
|
||||
global using SimpleField = MiGu.DB.Domains.SimpleFields.SimpleField;
|
||||
global using UserDashboardShortcut = MiGu.DB.Domains.Dashboard.UserDashboardShortcut;
|
||||
global using Warehouse = MiGu.DB.Domains.Wms.Warehouse;
|
||||
global using WarehouseArea = MiGu.DB.Domains.Wms.WarehouseArea;
|
||||
global using Storage = MiGu.DB.Domains.Wms.Storage;
|
||||
global using Container = MiGu.DB.Domains.Wms.Container;
|
||||
global using MaterialType = MiGu.DB.Domains.Wms.MaterialType;
|
||||
global using Material = MiGu.DB.Domains.Wms.Material;
|
||||
global using ContainerLocation = MiGu.DB.Domains.Wms.ContainerLocation;
|
||||
global using ContainerMaterial = MiGu.DB.Domains.Wms.ContainerMaterial;
|
||||
global using StockEvent = MiGu.DB.Domains.Wms.StockEvent;
|
||||
global using ContainerLocationHistory = MiGu.DB.Domains.Wms.ContainerLocationHistory;
|
||||
global using ContainerMaterialHistory = MiGu.DB.Domains.Wms.ContainerMaterialHistory;
|
||||
global using WmsTransportRule = MiGu.DB.Domains.Transport.WmsTransportRule;
|
||||
global using WmsTransportTask = MiGu.DB.Domains.Transport.WmsTransportTask;
|
||||
global using WmsTransportReservation = MiGu.DB.Domains.Transport.WmsTransportReservation;
|
||||
global using WmsTransportTaskHistory = MiGu.DB.Domains.Transport.WmsTransportTaskHistory;
|
||||
global using CdmTaskRecord = MiGu.DB.Domains.Fleet.CdmTaskRecord;
|
||||
global using VehicleAlarmRecord = MiGu.DB.Domains.Fleet.VehicleAlarmRecord;
|
||||
+19
-17
@@ -4,45 +4,46 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
|
||||
/// 将 <c>obj/Debug</c> 下最新编译的 Simple3 同步到 <c>bin/Debug</c>(仅当 Simple3 未运行时)。
|
||||
/// </summary>
|
||||
public static class SimpleLiteBuildSync
|
||||
public static class Simple3BuildSync
|
||||
{
|
||||
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
|
||||
{
|
||||
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
|
||||
return false;
|
||||
|
||||
var binDll = Path.Combine(binDir, "SimpleLite.dll");
|
||||
var binExe = Path.Combine(binDir, "SimpleLite.exe");
|
||||
var binDll = Path.Combine(binDir, "Simple3.dll");
|
||||
var binExe = Path.Combine(binDir, "Simple3.exe");
|
||||
|
||||
if (!File.Exists(objDll))
|
||||
{
|
||||
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
|
||||
log?.LogDebug("[Simple3BuildSync] obj DLL 不存在: {Path}", objDll);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
|
||||
if (Process.GetProcessesByName("Simple3").Any(p => !p.HasExited)
|
||||
|| Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
|
||||
{
|
||||
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
|
||||
log?.LogWarning("[Simple3BuildSync] Simple3 仍在运行,跳过 DLL 同步。请先关闭内核窗口。");
|
||||
return false;
|
||||
}
|
||||
|
||||
var objTime = File.GetLastWriteTimeUtc(objDll);
|
||||
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
|
||||
{
|
||||
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
|
||||
log?.LogDebug("[Simple3BuildSync] bin 已是最新,无需同步");
|
||||
return false;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(binDir);
|
||||
File.Copy(objDll, binDll, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
|
||||
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
|
||||
|
||||
if (File.Exists(objExe))
|
||||
{
|
||||
File.Copy(objExe, binExe, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
|
||||
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
|
||||
}
|
||||
|
||||
SyncRuntimeDeps(objDll, binDir, log);
|
||||
@@ -51,7 +52,7 @@ public static class SimpleLiteBuildSync
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
|
||||
/// 探测 Simple3 是否带「前往站点」路由(区分新旧 DLL)。
|
||||
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
|
||||
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
|
||||
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
|
||||
@@ -96,7 +97,7 @@ public static class SimpleLiteBuildSync
|
||||
|
||||
var dst = Path.Combine(binDir, name);
|
||||
File.Copy(src, dst, true);
|
||||
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
|
||||
log?.LogInformation("[Simple3BuildSync] 已同步依赖 {Name}", name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +107,11 @@ public static class SimpleLiteBuildSync
|
||||
binDir = "";
|
||||
var repo = FindRepoRoot(contentRoot);
|
||||
if (repo == null) return false;
|
||||
var sl = Path.Combine(repo, "Simple", "SimpleLite");
|
||||
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
|
||||
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
|
||||
var sl = Path.Combine(repo, "Simple", "Simple3");
|
||||
objDll = Path.Combine(sl, "obj", "Debug", "Simple3.dll");
|
||||
objExe = Path.Combine(sl, "obj", "Debug", "Simple3.exe");
|
||||
binDir = Path.Combine(sl, "bin", "Debug");
|
||||
return true;
|
||||
return Directory.Exists(sl);
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot(string contentRoot)
|
||||
@@ -118,7 +119,8 @@ public static class SimpleLiteBuildSync
|
||||
var dir = new DirectoryInfo(contentRoot);
|
||||
while (dir != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "Simple3"))
|
||||
|| Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
+147
-111
@@ -7,21 +7,21 @@ using Microsoft.Extensions.Options;
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||||
/// 平台登录成功后按 LaunchMode 把 Simple3.exe 作为子进程拉起。
|
||||
///
|
||||
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||||
/// 设计要点(与原 <c>Simple3/Platform/PlatformLauncher.cs</c> 镜像):
|
||||
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||||
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||||
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
|
||||
/// - 默认独立(<see cref="Simple3Options.FollowParent"/> = false):Simple3 与 MiGu.Server 互不影响,
|
||||
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
|
||||
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
|
||||
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 Simple3。
|
||||
/// - 命令行透传:把 LaunchMode 翻成 Simple3 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||
/// - 启动后阻塞等待 Simple3 Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteLauncher : IDisposable
|
||||
public sealed class Simple3Launcher : IDisposable
|
||||
{
|
||||
private readonly SimpleLiteOptions _opts;
|
||||
private readonly ILogger<SimpleLiteLauncher> _log;
|
||||
private readonly Simple3Options _opts;
|
||||
private readonly ILogger<Simple3Launcher> _log;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly object _sync = new();
|
||||
|
||||
@@ -30,13 +30,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
private string? _lastLaunchMode;
|
||||
private bool _disposed;
|
||||
|
||||
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
|
||||
public Simple3Launcher(IOptions<Simple3Options> opts, ILogger<Simple3Launcher> log, IHostEnvironment env)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_log = log;
|
||||
_env = env;
|
||||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
||||
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— Simple3 是「独立程序」,MiGu.Server 关掉
|
||||
// 不应该带走 Simple3。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||
if (_opts.FollowParent)
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||||
@@ -53,11 +53,11 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||||
|
||||
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||
/// <summary>外部已存在的 Simple3(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||
internal const string ExternalReuseLaunchMode = "external";
|
||||
|
||||
/// <summary>
|
||||
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
||||
/// 按 launchMode 拉起 Simple3(已运行则跳过)。
|
||||
/// </summary>
|
||||
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||||
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||||
@@ -67,20 +67,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
if (!_opts.Enabled)
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||||
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
|
||||
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
|
||||
_log.LogInformation("[Simple3] auto-start disabled (appsettings: Simple3.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||||
return new LaunchResult(false, "Disabled", "appsettings:Simple3:Enabled=false", DisplayMode: null,
|
||||
Warning: "Simple3 自动拉起已被 appsettings:Simple3:Enabled=false 关闭;登录已成功但 Simple3 未启动,/api/sl/* 反代请求会返回 502。");
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
|
||||
_log.LogInformation("[Simple3] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
|
||||
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
|
||||
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
||||
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
|
||||
? $"Simple3 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 Simple3 后重新登录。"
|
||||
: null;
|
||||
return new LaunchResult(true, "AlreadyRunning",
|
||||
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||||
@@ -88,44 +88,44 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
Warning: warning);
|
||||
}
|
||||
|
||||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 Simple3 可能仍在跑(因为
|
||||
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||||
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||
// 避免「allowMultiple=false 时新 Simple3 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||
//
|
||||
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
||||
// 注意:探测仅判断「有 Simple3 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 Simple3 再登录。
|
||||
//
|
||||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
||||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 Simple3」,避免被某个偶然占
|
||||
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
|
||||
{
|
||||
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
|
||||
var probeOk = ProbeSimple3Http("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
|
||||
if (!probeOk)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
|
||||
"[Simple3] projection port :{Port} is occupied, but Simple3 HTTP probe failed. Skip launch to avoid port conflict.",
|
||||
_opts.ProjectionPort);
|
||||
return new LaunchResult(false, "PortOccupied",
|
||||
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
||||
DisplayMode: null,
|
||||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
|
||||
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
|
||||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 Simple3 Projection 服务。" +
|
||||
"请关闭占用该端口的进程,或调整 Simple3:ProjectionPort / Simple3 配置后重试。");
|
||||
}
|
||||
|
||||
_log.LogInformation(
|
||||
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
|
||||
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
|
||||
"[Simple3] projection :{Port} already reachable (httpProbe=ok); assume an existing Simple3 is running. " +
|
||||
"Skip launch. If user picked a different LaunchMode this session, please close the existing Simple3 window and login again.",
|
||||
_opts.ProjectionPort);
|
||||
|
||||
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||||
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||||
_lastLaunchMode = ExternalReuseLaunchMode;
|
||||
|
||||
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
|
||||
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
|
||||
var reuseWarning = $"检测到 Simple3 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 Simple3 窗口后重新登录。";
|
||||
if (Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
|
||||
{
|
||||
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
|
||||
reuseWarning += " 当前 Simple3 版本过旧,缺少「前往站点」API;请关闭 Simple3 窗口后调用 POST /api/health/simple3/restart-for-update,或运行 scripts/redeploy-simple3.ps1。";
|
||||
}
|
||||
|
||||
return new LaunchResult(true, "ReusingExisting",
|
||||
@@ -134,18 +134,18 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
Warning: reuseWarning);
|
||||
}
|
||||
|
||||
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
if (resolved == null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
|
||||
"[Simple3] auto-start skipped: Simple3.exe not found. Configure appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server. ContentRoot={Root}",
|
||||
_env.ContentRootPath);
|
||||
return new LaunchResult(false, "ExecutableNotFound",
|
||||
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
|
||||
"Set appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server.",
|
||||
DisplayMode: null,
|
||||
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
|
||||
Warning: "找不到 Simple3.exe。请在 appsettings:Simple3:ExecutablePath 显式配置,或者把 Simple3.exe 放到 MiGu.Server 同目录。");
|
||||
}
|
||||
|
||||
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||||
@@ -156,12 +156,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
|
||||
var proc = StartSimple3Process(resolved, arguments, workdir);
|
||||
if (proc == null)
|
||||
{
|
||||
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
|
||||
_log.LogError("[Simple3] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
|
||||
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
|
||||
Warning: $"启动 Simple3 进程失败;exe={resolved}");
|
||||
}
|
||||
|
||||
WireProcessExitHandler(proc);
|
||||
@@ -171,20 +171,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
if (_opts.FollowParent) AttachToJobObject(proc);
|
||||
|
||||
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
|
||||
_log.LogInformation("[Simple3] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
|
||||
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[SimpleLite] auto-start failed");
|
||||
_log.LogError(ex, "[Simple3] auto-start failed");
|
||||
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||||
Warning: $"启动 Simple3 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
|
||||
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
|
||||
// GET /api/health/simplelite 获知就绪状态。
|
||||
// GET /api/health/simple3 获知就绪状态。
|
||||
if (!waitForReady)
|
||||
{
|
||||
return new LaunchResult(true, "Starting",
|
||||
@@ -200,12 +200,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
DisplayMode: displayMode,
|
||||
Warning: ready
|
||||
? null
|
||||
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||
: $"Simple3 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动关闭 SimpleLite 子进程。
|
||||
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
||||
/// 手动关闭 Simple3 子进程。
|
||||
/// 会话 N+2 起默认不调(FollowParent=false)—— Simple3 是独立程序,MiGu.Server 关闭不带走它。
|
||||
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
@@ -227,7 +227,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
|
||||
_log.LogInformation("[Simple3] standalone mode: MiGu.Server stopping — Simple3 pid={Pid} keeps running (FollowParent=false)", proc.Id);
|
||||
}
|
||||
try { proc?.Dispose(); } catch { /* ignore */ }
|
||||
return;
|
||||
@@ -236,7 +236,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
|
||||
catch (Exception ex) { _log.LogWarning("[Simple3] kill failed: {Msg}", ex.Message); }
|
||||
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -248,21 +248,21 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
private static string NormalizeDisplayMode(string launchMode)
|
||||
{
|
||||
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 SimpleLite「内部语言」(web / web+local)。
|
||||
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 Simple3「内部语言」(web / web+local)。
|
||||
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
|
||||
return launchMode?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web" or "web-only" => "web",
|
||||
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
||||
_ => "web+local",
|
||||
"desktopandweb" or "web+local" or "weblocal" or "local" => "web+local",
|
||||
// Simple3 无本地桌面端:默认 / 未知一律 web
|
||||
_ => "web",
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildArguments(string displayMode, string extra)
|
||||
{
|
||||
var args = $"--display-mode={displayMode}";
|
||||
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||||
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
|
||||
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 Simple3 WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||||
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:Simple3:EmbeddedCanvas=false 关闭。
|
||||
if (_opts.EmbeddedCanvas) args += " --migu";
|
||||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||||
var sceneArg = ReadActiveScenesArg();
|
||||
@@ -271,7 +271,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
return args;
|
||||
}
|
||||
|
||||
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>,
|
||||
/// <summary>解析 Simple3 工作目录(与拉起时一致):优先显式 <see cref="Simple3Options.WorkingDirectory"/>,
|
||||
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
|
||||
public string? ResolveWorkingDirectory()
|
||||
{
|
||||
@@ -281,7 +281,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
|
||||
}
|
||||
|
||||
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
||||
/// <summary>Simple3 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
||||
public string? ResolvePluginsDir()
|
||||
{
|
||||
var wd = ResolveWorkingDirectory();
|
||||
@@ -289,16 +289,16 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
|
||||
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
|
||||
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite 端
|
||||
/// 把「配置向导选定的导航场景」写入 Simple3 的 <c>plugins/active-scenes.json</c> ——
|
||||
/// 这是「平台 → 内核」选择性加载的主通道。下次 Simple3 启动即据此只加载选定导航场景插件;
|
||||
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 Simple3 端
|
||||
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
|
||||
/// </summary>
|
||||
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
|
||||
{
|
||||
var pluginsDir = ResolvePluginsDir();
|
||||
if (pluginsDir == null)
|
||||
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
|
||||
return new ActiveScenesWriteResult(false, null, "未找到 Simple3 工作目录/可执行文件,无法定位 plugins 目录");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(pluginsDir);
|
||||
@@ -313,13 +313,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
updatedAt = DateTime.UtcNow
|
||||
};
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
||||
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
|
||||
_log.LogInformation("[Simple3] active-scenes.json 写入 {Path}: active=[{Scenes}]",
|
||||
path, string.Join(",", payload.activeScenes));
|
||||
return new ActiveScenesWriteResult(true, path, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
|
||||
_log.LogWarning(ex, "[Simple3] 写 active-scenes.json 失败");
|
||||
return new ActiveScenesWriteResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -349,24 +349,25 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||
/// </summary>
|
||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||
/// <summary>终止本机全部 Simple3(及旧名 SimpleLite)进程并清理 MiGu.Server 侧托管引用。</summary>
|
||||
public int StopAll()
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
||||
var killed = 0;
|
||||
foreach (var name in new[] { "Simple3", "SimpleLite" })
|
||||
foreach (var proc in Process.GetProcessesByName(name))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!proc.HasExited)
|
||||
{
|
||||
proc.Kill(entireProcessTree: true);
|
||||
_log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id);
|
||||
killed++;
|
||||
_log.LogInformation("[Simple3] stop: killed {Name} pid={Pid}", name, proc.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] restart-for-update: 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
|
||||
{
|
||||
@@ -374,7 +375,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(1500);
|
||||
if (killed > 0) Thread.Sleep(1500);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
@@ -382,13 +383,30 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
_lastLaunchMode = null;
|
||||
}
|
||||
|
||||
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
return killed;
|
||||
}
|
||||
|
||||
/// <summary>关闭全部 Simple3 后按 launchMode 重新拉起(不同步 DLL)。</summary>
|
||||
public LaunchResult Restart(string launchMode = "webonly")
|
||||
{
|
||||
StopAll();
|
||||
return MaybeStart(launchMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭所有 Simple3 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||||
/// </summary>
|
||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||||
{
|
||||
StopAll();
|
||||
|
||||
var synced = Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||
var result = MaybeStart(launchMode);
|
||||
if (!synced && result.Warning == null)
|
||||
{
|
||||
return result with
|
||||
{
|
||||
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
|
||||
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 Simple3 仍占用文件)。请先运行 scripts/redeploy-simple3.ps1。"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,12 +417,12 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||
public SimpleLiteDiagnostics GetDiagnostics()
|
||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simple3 与启动日志)。</summary>
|
||||
public Simple3Diagnostics GetDiagnostics()
|
||||
{
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
|
||||
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 Simple3 发探测请求。
|
||||
bool? gotoSite = null;
|
||||
if (projectionUp)
|
||||
{
|
||||
@@ -414,7 +432,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||||
gotoSite = Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||||
if (gotoSite is not null)
|
||||
{
|
||||
_gotoSiteProbeCache = gotoSite;
|
||||
@@ -423,9 +441,9 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
}
|
||||
var deployHint = gotoSite == false
|
||||
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
||||
? "关闭 Simple3 后:运行 Migu2.0/scripts/redeploy-simple3.ps1,或 POST /api/health/simple3/restart-for-update"
|
||||
: null;
|
||||
return new SimpleLiteDiagnostics(
|
||||
return new Simple3Diagnostics(
|
||||
Enabled: _opts.Enabled,
|
||||
FollowParent: _opts.FollowParent,
|
||||
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||||
@@ -439,26 +457,26 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
ProjectionPortReachable: projectionUp,
|
||||
GotoSiteApiAvailable: gotoSite,
|
||||
DeployHint: deployHint,
|
||||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
||||
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
|
||||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 Simple3 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
||||
" FollowParent=false 时关闭 MiGu.Server 不会结束 Simple3。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite。
|
||||
/// 拉起 Simple3。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 Simple3。
|
||||
/// </summary>
|
||||
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
|
||||
private Process? StartSimple3Process(string resolved, string arguments, string workdir)
|
||||
{
|
||||
if (_opts.FollowParent)
|
||||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
|
||||
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
return StartSimple3DetachedWindows(resolved, arguments, workdir)
|
||||
?? StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
|
||||
|
||||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||||
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
|
||||
}
|
||||
|
||||
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
|
||||
private static Process? StartSimple3Direct(string resolved, string arguments, string workdir, bool useShellExecute)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -476,14 +494,15 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
/// <summary>
|
||||
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
|
||||
/// </summary>
|
||||
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
|
||||
private Process? StartSimple3DetachedWindows(string resolved, string arguments, string workdir)
|
||||
{
|
||||
var exeName = Path.GetFileNameWithoutExtension(resolved);
|
||||
var beforeIds = new HashSet<int>(
|
||||
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
|
||||
.Where(id => id > 0));
|
||||
|
||||
var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
|
||||
// cmd start 的第一个引号参数是「窗口标题」,不是 exe;写死 Simple3 会让 Simple3 控制台标题错名。
|
||||
var cmdArgs = $"/c start \"{exeName}\" /D \"{workdir}\" \"{resolved}\" {arguments}";
|
||||
var shimPsi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
@@ -504,7 +523,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
if (p.HasExited) continue;
|
||||
if (beforeIds.Contains(p.Id)) continue;
|
||||
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
|
||||
_log.LogInformation("[Simple3] detached start via cmd.exe; new pid={Pid}", p.Id);
|
||||
return AttachToExistingProcess(p.Id);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
@@ -512,7 +531,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
|
||||
_log.LogWarning("[Simple3] detached start: no new {Name} process observed after cmd.exe start", exeName);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -526,7 +545,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
|
||||
_log.LogWarning("[Simple3] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -537,7 +556,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
int exit;
|
||||
try { exit = proc.ExitCode; } catch { exit = -1; }
|
||||
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
|
||||
_log.LogInformation("[Simple3] process exited code={Code}; next login will relaunch if needed", exit);
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_proc, proc))
|
||||
@@ -560,16 +579,26 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
var cwd = _env.ContentRootPath;
|
||||
var staticCandidates = new[]
|
||||
{
|
||||
Path.Combine(cwd, "Simple3.exe"),
|
||||
Path.Combine(cwd, "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "Simple3", "Simple3.exe"),
|
||||
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple3.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
|
||||
// 发布布局(本机常见):D:\MDCS\Release\Simple\Simple3
|
||||
@"D:\MDCS\Release\Simple\Simple3\Simple3.exe",
|
||||
// 源码并列:Migu2.0/MiGu.Server → ../../Simple/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", "Release", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
};
|
||||
foreach (var c in staticCandidates)
|
||||
{
|
||||
@@ -582,6 +611,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
foreach (var sub in new[]
|
||||
{
|
||||
"Simple3/Simple3.exe",
|
||||
"Simple3/bin/Debug/Simple3.exe",
|
||||
"Simple3/bin/Release/Simple3.exe",
|
||||
"Simple/Simple3/Simple3.exe",
|
||||
"Simple/Simple3/bin/Debug/Simple3.exe",
|
||||
"Simple/Simple3/bin/Release/Simple3.exe",
|
||||
"SimpleLite/SimpleLite.exe",
|
||||
"SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
"SimpleLite/bin/Release/SimpleLite.exe",
|
||||
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
@@ -651,14 +687,14 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
|
||||
_log.LogInformation("[Simple3] projection :{Port} ready", _opts.ProjectionPort);
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(interval);
|
||||
}
|
||||
|
||||
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||||
_log.LogWarning("[Simple3] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -677,10 +713,10 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
||||
/// 在 TCP 通的基础上读取 Simple3 Projection 的强类型 JSON 端点,判别「真的是 Simple3」。
|
||||
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||||
/// </summary>
|
||||
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
||||
private static bool ProbeSimple3Http(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -701,20 +737,20 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉起 SimpleLite 的结果摘要。
|
||||
/// 拉起 Simple3 的结果摘要。
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
||||
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
||||
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
||||
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
||||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
|
||||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 Simple3,对应模式未知。</item>
|
||||
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
||||
string? DisplayMode = null, string? Warning = null);
|
||||
|
||||
public sealed record SimpleLiteDiagnostics(
|
||||
public sealed record Simple3Diagnostics(
|
||||
bool Enabled,
|
||||
bool FollowParent,
|
||||
string ConfiguredExecutablePath,
|
||||
@@ -790,7 +826,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||||
_log.LogInformation("[Simple3] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -801,7 +837,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
_job = CreateJobObject(IntPtr.Zero, null);
|
||||
if (_job == IntPtr.Zero)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
|
||||
_log.LogWarning("[Simple3] CreateJobObject failed; child may outlive MiGu.Server");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -814,17 +850,17 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
Marshal.StructureToPtr(info, ptr, false);
|
||||
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
||||
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
|
||||
_log.LogWarning("[Simple3] SetInformationJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(ptr); }
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(_job, child.Handle))
|
||||
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
|
||||
_log.LogWarning("[Simple3] AssignProcessToJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||||
_log.LogWarning("[Simple3] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-23
@@ -1,31 +1,30 @@
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
|
||||
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 Simple3 子进程配置。
|
||||
///
|
||||
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
|
||||
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe,
|
||||
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
|
||||
/// 会话 N+1(启动反转):原架构是 Simple3 启动 → 拉 MiGu.Server;现在反过来:
|
||||
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 Simple3.exe,
|
||||
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 Simple3 的 Configuration。
|
||||
///
|
||||
/// 绑定 <c>appsettings.json:SimpleLite</c>。
|
||||
/// 绑定 <c>appsettings.json:Simple3</c>。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteOptions
|
||||
public sealed class Simple3Options
|
||||
{
|
||||
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
||||
/// 1) <c>./SimpleLite.exe</c>(合并发布布局:SimpleLite 与 MiGu.Server 同目录)
|
||||
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
|
||||
/// 3) <c>../SimpleLite.exe</c>(MiGu.Server 部署到子目录的常见布局)
|
||||
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出)
|
||||
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
|
||||
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
|
||||
/// Simple3 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
||||
/// 1) <c>./Simple3.exe</c>(合并发布布局;仍兼容探测旧名 <c>./SimpleLite.exe</c>)
|
||||
/// 2) <c>./Simple3/Simple3.exe</c>
|
||||
/// 3) 开发态:兄弟仓库 <c>../Simple/Simple3/</c> 产出目录或 <c>bin/Debug|Release</c>
|
||||
/// 4) 发布目录常见布局:<c>D:\MDCS\Release\Simple\Simple3\Simple3.exe</c>(仅当该绝对路径存在)
|
||||
/// 5) 沿父目录上行寻找 Simple3 产出
|
||||
/// </summary>
|
||||
public string ExecutablePath { get; set; } = "";
|
||||
|
||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||
public string WorkingDirectory { get; set; } = "";
|
||||
|
||||
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
||||
@@ -33,7 +32,7 @@ public sealed class SimpleLiteOptions
|
||||
|
||||
/// <summary>
|
||||
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
||||
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
|
||||
/// 0 = 不等待(登录立即返回,前端可能还连不上 Simple3 WebApi);
|
||||
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
||||
/// </summary>
|
||||
public int ReadinessTimeoutMs { get; set; } = 8000;
|
||||
@@ -41,17 +40,17 @@ public sealed class SimpleLiteOptions
|
||||
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
||||
public int ReadinessPollIntervalMs { get; set; } = 250;
|
||||
|
||||
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
|
||||
/// <summary>Simple3 Projection WebApi 监听端口。默认与 Simple3 Configuration 的 `port` 一致。用于就绪检测。</summary>
|
||||
public int ProjectionPort { get; set; } = 8222;
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||
/// 是否把 Simple3 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||
///
|
||||
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite;
|
||||
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false:
|
||||
/// 设计原则:Simple3 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 Simple3;
|
||||
/// MiGu.Server 关闭不应该带走 Simple3,反之亦然。所以 FollowParent 默认 false:
|
||||
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
||||
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
||||
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||
/// - Simple3 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||
///
|
||||
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||
@@ -59,11 +58,11 @@ public sealed class SimpleLiteOptions
|
||||
public bool FollowParent { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
|
||||
/// 平台拉起 Simple3 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
|
||||
///
|
||||
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel,
|
||||
/// true:平台 iframe 嵌入场景,Simple3 WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel,
|
||||
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
|
||||
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
||||
/// false:平台拉起的 Simple3 web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
||||
///
|
||||
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
||||
/// </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);
|
||||
}
|
||||
@@ -16,17 +16,20 @@
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||
<Content Include="OpenApi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simplelite-projection.json')" />
|
||||
<Content Include="..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json')" />
|
||||
<Content Include="OpenApi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simple3-projection.json')" />
|
||||
<Content Include="..\..\Simple\Simple3\Docs\openapi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\Simple3\Docs\openapi\simple3-projection.json')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+8
-5
@@ -5,10 +5,10 @@ using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
namespace MiGu.Server.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
||||
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
|
||||
/// 将 Simple3 EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
||||
/// 源文件:Simple/Simple3/Docs/openapi/simple3-projection.json
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
||||
public sealed class Simple3OpenApiDocumentFilter : IDocumentFilter
|
||||
{
|
||||
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -175,9 +175,12 @@ public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simple3-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
|
||||
// 兼容旧文件名 / 旧目录
|
||||
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simplelite-projection.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public static class OtaHash
|
||||
{
|
||||
/// <summary>与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。</summary>
|
||||
public static string OfFile(string path)
|
||||
{
|
||||
using var fs = File.OpenRead(path);
|
||||
var hash = MD5.HashData(fs);
|
||||
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string OfBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var hash = MD5.HashData(bytes);
|
||||
return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string Short(string? hash, int len = 8)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "—";
|
||||
return hash.Length <= len ? hash : hash[..len];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaJobRunner
|
||||
{
|
||||
private readonly OtaStore _store;
|
||||
private readonly WatchDogClient _wd;
|
||||
private readonly OtaVehicleSource _vehicles;
|
||||
private readonly ILogger<OtaJobRunner> _log;
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new();
|
||||
|
||||
public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger<OtaJobRunner> log)
|
||||
{
|
||||
_store = store;
|
||||
_wd = wd;
|
||||
_vehicles = vehicles;
|
||||
_log = log;
|
||||
RecoverInterruptedJobs();
|
||||
}
|
||||
|
||||
/// <summary>进程重启后把落盘中非终态 job 标为 failed,避免 UI 永久显示 running。</summary>
|
||||
private void RecoverInterruptedJobs()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var job in _store.ListJobs(500))
|
||||
{
|
||||
if (job.Status is not ("running" or "pending")) continue;
|
||||
job.Status = "failed";
|
||||
job.Message = string.IsNullOrWhiteSpace(job.Message)
|
||||
? "进程重启,任务中断"
|
||||
: job.Message;
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
foreach (var step in job.Steps.Where(s => s.Status is "pending" or "running"))
|
||||
{
|
||||
step.Status = "failed";
|
||||
step.Error ??= "进程重启,任务中断";
|
||||
}
|
||||
_store.SaveJob(job);
|
||||
_log.LogWarning("OTA job {Id} marked failed after process restart", job.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA interrupted job recovery failed");
|
||||
}
|
||||
}
|
||||
|
||||
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
|
||||
{
|
||||
var target = _store.GetTarget() ?? throw new InvalidOperationException("未设置目标版本,请先在版本库激活");
|
||||
var components = (req.Components is { Count: > 0 } ? req.Components : OtaPathMap.ComponentKeys.ToList())
|
||||
.Where(c => target.Components.ContainsKey(c))
|
||||
.ToList();
|
||||
if (components.Count == 0) throw new InvalidOperationException("目标版本中无选定组件");
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
|
||||
var settings = _store.GetSettings();
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "sync",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
PackageId = target.PackageId,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
Components = components,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
foreach (var comp in components)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = comp, Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public OtaJob EnqueueCustomFile(CreateCustomFileJobRequest req, IReadOnlyList<OtaCustomFileItem> files, string? user)
|
||||
{
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
if (files.Count == 0) throw new InvalidOperationException("请至少添加一个本地文件");
|
||||
if (string.IsNullOrWhiteSpace(req.RemotePath)) throw new InvalidOperationException("请填写小车内目标路径");
|
||||
var settings = _store.GetSettings();
|
||||
var ops = req.RestartOps is { Count: > 0 }
|
||||
? req.RestartOps.Distinct().ToList()
|
||||
: new List<int> { req.RestartOp };
|
||||
if (ops.Count == 0) ops.Add(-1);
|
||||
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "customFile",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
CustomFileName = files[0].FileName,
|
||||
CustomRemotePath = req.RemotePath,
|
||||
CustomRestartOp = ops[0],
|
||||
CustomLocalPath = files[0].LocalPath,
|
||||
CustomFiles = files.ToList(),
|
||||
CustomRestartOps = ops,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||||
Components = new List<string> { "custom" }
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"custom×{files.Count}", Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public OtaJob EnqueueConfigPush(CreateConfigPushRequest req, string? user)
|
||||
{
|
||||
if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆");
|
||||
var app = req.App.Trim().ToLowerInvariant();
|
||||
if (app is not ("medulla" or "detour" or "clumsy"))
|
||||
throw new InvalidOperationException("app 须为 medulla|detour|clumsy");
|
||||
var settings = _store.GetSettings();
|
||||
var job = new OtaJob
|
||||
{
|
||||
Id = _store.NextJobId(),
|
||||
Kind = "configPush",
|
||||
Status = "pending",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = user,
|
||||
CarIds = req.CarIds.ToList(),
|
||||
ConfigApp = app,
|
||||
ConfigJson = req.Json,
|
||||
RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled,
|
||||
Components = new List<string> { $"config:{app}" }
|
||||
};
|
||||
foreach (var carId in job.CarIds)
|
||||
job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"config:{app}", Status = "pending" });
|
||||
job.TotalSteps = job.Steps.Count;
|
||||
_store.SaveJob(job);
|
||||
_ = Task.Run(() => RunAsync(job.Id));
|
||||
return job;
|
||||
}
|
||||
|
||||
public bool Cancel(string jobId)
|
||||
{
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job == null) return false;
|
||||
if (job.Status is "succeeded" or "failed" or "partial" or "cancelled") return false;
|
||||
// 正在运行:只发取消信号,由 RunAsync 统一收尾,避免与运行线程并发写同一 job 文件。
|
||||
if (_running.TryGetValue(jobId, out var cts))
|
||||
{
|
||||
cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
// 尚未开始(Task.Run 排队中):直接落盘取消;RunAsync 启动时有 status 守卫会跳过执行。
|
||||
foreach (var step in job.Steps.Where(s => s.Status == "pending"))
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = "已取消";
|
||||
}
|
||||
job.Status = "cancelled";
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||||
_store.SaveJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
public OtaJob? RetryFailed(string jobId, string? user)
|
||||
{
|
||||
var old = _store.GetJob(jobId);
|
||||
if (old == null) return null;
|
||||
var failedCars = old.Steps.Where(s => s.Status == "failed").Select(s => s.CarId).Distinct().ToList();
|
||||
if (failedCars.Count == 0) throw new InvalidOperationException("没有失败项可重试");
|
||||
|
||||
return old.Kind switch
|
||||
{
|
||||
"sync" => EnqueueSync(new CreateSyncJobRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
Components = old.Components,
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, user),
|
||||
"customFile" when ResolveCustomFiles(old).Count > 0 =>
|
||||
EnqueueCustomFile(new CreateCustomFileJobRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
RemotePath = old.CustomRemotePath ?? "",
|
||||
RestartOps = old.CustomRestartOps is { Count: > 0 }
|
||||
? old.CustomRestartOps
|
||||
: new List<int> { old.CustomRestartOp ?? -1 },
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, ResolveCustomFiles(old), user),
|
||||
"configPush" => EnqueueConfigPush(new CreateConfigPushRequest
|
||||
{
|
||||
CarIds = failedCars,
|
||||
App = old.ConfigApp ?? "",
|
||||
Json = old.ConfigJson ?? "",
|
||||
RequireLatencyCheck = old.RequireLatencyCheck
|
||||
}, user),
|
||||
_ => throw new InvalidOperationException("无法重试该任务类型或文件已丢失")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RunAsync(string jobId)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
if (!_running.TryAdd(jobId, cts)) return;
|
||||
// 单一内存实例贯穿全程;所有「改状态 + 落盘」都在 jobLock 下串行,杜绝并发车批次的丢更新。
|
||||
var jobLock = new object();
|
||||
try
|
||||
{
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job == null) return;
|
||||
// 守卫:排队期间被取消 / 已终结的任务不再执行。
|
||||
if (job.Status is "cancelled" or "succeeded" or "failed" or "partial") return;
|
||||
var settings = _store.GetSettings();
|
||||
var cars = await _vehicles.ListCarsAsync(cts.Token);
|
||||
var byId = cars.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
job.Status = job.RequireLatencyCheck ? "probing" : "running";
|
||||
_store.SaveJob(job);
|
||||
|
||||
// 解析 IP
|
||||
foreach (var step in job.Steps)
|
||||
{
|
||||
if (byId.TryGetValue(step.CarId, out var car))
|
||||
step.Ip = car.Ip;
|
||||
}
|
||||
|
||||
if (job.RequireLatencyCheck)
|
||||
{
|
||||
var threshold = settings.RttThresholdMs;
|
||||
var overMode = settings.OverThreshold;
|
||||
foreach (var carId in job.CarIds.Distinct())
|
||||
{
|
||||
var ip = job.Steps.FirstOrDefault(s => s.CarId == carId)?.Ip;
|
||||
if (string.IsNullOrEmpty(ip))
|
||||
{
|
||||
SkipCar(job, carId, "无 IP");
|
||||
continue;
|
||||
}
|
||||
var rtt = await _wd.MeasureRttMsAsync(ip, cts.Token);
|
||||
if (rtt == null || rtt > threshold)
|
||||
{
|
||||
if (string.Equals(overMode, "skip", StringComparison.OrdinalIgnoreCase) || rtt == null)
|
||||
SkipCar(job, carId, rtt == null ? "延迟探测失败" : $"RTT {rtt}ms > {threshold}ms");
|
||||
}
|
||||
}
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
|
||||
job.Status = "running";
|
||||
_store.SaveJob(job);
|
||||
|
||||
var maxCar = Math.Max(1, settings.MaxCar);
|
||||
var carGroups = job.CarIds.Distinct()
|
||||
.Where(id => job.Steps.Any(s => s.CarId == id && s.Status == "pending"))
|
||||
.Chunk(maxCar);
|
||||
|
||||
foreach (var batch in carGroups)
|
||||
{
|
||||
if (cts.IsCancellationRequested) break;
|
||||
var tasks = batch.Select(carId => RunCarAsync(job, jobLock, carId, settings.BandwidthKbps, cts.Token));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
lock (jobLock)
|
||||
{
|
||||
if (cts.IsCancellationRequested)
|
||||
{
|
||||
foreach (var s in job.Steps.Where(s => s.Status is "pending" or "running"))
|
||||
{
|
||||
s.Status = "skipped";
|
||||
s.Error ??= "已取消";
|
||||
}
|
||||
job.Status = "cancelled";
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
Recalc(job);
|
||||
}
|
||||
else
|
||||
{
|
||||
Finalize(job);
|
||||
}
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "OTA job {Id} crashed", jobId);
|
||||
var job = _store.GetJob(jobId);
|
||||
if (job != null)
|
||||
{
|
||||
job.Status = "failed";
|
||||
job.Message = ex.Message;
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running.TryRemove(jobId, out _);
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipCar(OtaJob job, string carId, string reason)
|
||||
{
|
||||
foreach (var step in job.Steps.Where(s => s.CarId == carId && s.Status == "pending"))
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OtaCustomFileItem> ResolveCustomFiles(OtaJob job)
|
||||
{
|
||||
if (job.CustomFiles is { Count: > 0 })
|
||||
return job.CustomFiles.Where(f => File.Exists(f.LocalPath)).ToList();
|
||||
if (!string.IsNullOrEmpty(job.CustomLocalPath) && File.Exists(job.CustomLocalPath))
|
||||
{
|
||||
return new List<OtaCustomFileItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
LocalPath = job.CustomLocalPath,
|
||||
FileName = job.CustomFileName ?? Path.GetFileName(job.CustomLocalPath)
|
||||
}
|
||||
};
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
private async Task UploadCustomFilesForCarAsync(OtaJob job, string ip, int bandwidth, CancellationToken ct)
|
||||
{
|
||||
var files = ResolveCustomFiles(job);
|
||||
if (files.Count == 0) throw new InvalidOperationException("自定义文件已丢失");
|
||||
var remote = job.CustomRemotePath ?? "";
|
||||
var ops = job.CustomRestartOps is { Count: > 0 }
|
||||
? job.CustomRestartOps
|
||||
: new List<int> { job.CustomRestartOp ?? -1 };
|
||||
|
||||
// 非末文件一律 -1;末文件按所选重启项依次再传(对齐 CarOTA.App)
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
var f = files[i];
|
||||
var isLast = i == files.Count - 1;
|
||||
if (!isLast)
|
||||
{
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ops.Count == 1 && ops[0] == -1)
|
||||
{
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var op in ops.Where(o => o != -1).DefaultIfEmpty(-1))
|
||||
await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, op, bandwidth, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCarAsync(OtaJob job, object jobLock, string carId, int bandwidth, CancellationToken ct)
|
||||
{
|
||||
// 只处理本车 step;同批其他车任务并行修改各自 step,共享同一 job 实例,写盘统一在 jobLock 下串行。
|
||||
var steps = job.Steps.Where(s => s.CarId == carId && s.Status == "pending").ToList();
|
||||
if (steps.Count == 0) return;
|
||||
var ip = steps[0].Ip;
|
||||
if (string.IsNullOrEmpty(ip))
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
foreach (var s in steps) { s.Status = "failed"; s.Error = "无 IP"; }
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "skipped";
|
||||
step.Error = "已取消";
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "running";
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
try
|
||||
{
|
||||
switch (job.Kind)
|
||||
{
|
||||
case "sync":
|
||||
{
|
||||
var target = _store.GetTarget() ?? throw new InvalidOperationException("目标版本丢失");
|
||||
if (!target.Components.TryGetValue(step.Component, out var art))
|
||||
throw new InvalidOperationException($"组件 {step.Component} 不在目标中");
|
||||
await _wd.UploadComponentAsync(ip, step.Component, art.Path, art.FileName, bandwidth, ct);
|
||||
break;
|
||||
}
|
||||
case "customFile":
|
||||
await UploadCustomFilesForCarAsync(job, ip, bandwidth, ct);
|
||||
break;
|
||||
case "configPush":
|
||||
await _wd.PutJsonAsync(ip, job.ConfigApp!, job.ConfigJson!, ct);
|
||||
break;
|
||||
}
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = "succeeded";
|
||||
step.Error = null;
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (jobLock)
|
||||
{
|
||||
step.Status = ct.IsCancellationRequested ? "skipped" : "failed";
|
||||
step.Error = ct.IsCancellationRequested ? "已取消" : ex.Message;
|
||||
Recalc(job);
|
||||
_store.SaveJob(job);
|
||||
}
|
||||
_log.LogWarning(ex, "OTA step fail {Job} {Car} {Comp}", job.Id, carId, step.Component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Recalc(OtaJob job)
|
||||
{
|
||||
job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped");
|
||||
}
|
||||
|
||||
private static void Finalize(OtaJob job)
|
||||
{
|
||||
Recalc(job);
|
||||
job.FinishedAt = DateTimeOffset.UtcNow;
|
||||
var anyFail = job.Steps.Any(s => s.Status == "failed");
|
||||
var anyOk = job.Steps.Any(s => s.Status == "succeeded");
|
||||
var anyPending = job.Steps.Any(s => s.Status is "pending" or "running");
|
||||
if (job.Status == "cancelled") return;
|
||||
if (anyPending) job.Status = "partial";
|
||||
else if (anyFail && anyOk) job.Status = "partial";
|
||||
else if (anyFail) job.Status = "failed";
|
||||
else if (anyOk) job.Status = "succeeded";
|
||||
else job.Status = "cancelled";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaSettings
|
||||
{
|
||||
public int BandwidthKbps { get; set; }
|
||||
public int MaxCar { get; set; } = 2;
|
||||
public bool LatencyEnabled { get; set; }
|
||||
public int RttThresholdMs { get; set; } = 200;
|
||||
/// <summary>skip | allow(历史值 confirm 视为 allow)</summary>
|
||||
public string OverThreshold { get; set; } = "skip";
|
||||
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
|
||||
public int BackupPeriodMinutes { get; set; } = 60;
|
||||
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
|
||||
public bool BackupExe { get; set; }
|
||||
public string? NewVersionName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaFileArtifact
|
||||
{
|
||||
public string Hash { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
public string? Time { get; set; }
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaTarget
|
||||
{
|
||||
public string PackageId { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public DateTimeOffset ActivatedAt { get; set; }
|
||||
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class OtaPackageInfo
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? SourceIp { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public long TotalBytes { get; set; }
|
||||
public bool IsTarget { get; set; }
|
||||
public Dictionary<string, OtaFileArtifact> Components { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class OtaComponentVersion
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? Time { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaAppVersions
|
||||
{
|
||||
public OtaComponentVersion? Exe { get; set; }
|
||||
public OtaComponentVersion? Dll { get; set; }
|
||||
public OtaComponentVersion? Pdb { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaVehicleRow
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Ip { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public bool Reachable { get; set; }
|
||||
public int? RttMs { get; set; }
|
||||
public OtaAppVersions? Medulla { get; set; }
|
||||
public OtaAppVersions? Detour { get; set; }
|
||||
public OtaAppVersions? Clumsy { get; set; }
|
||||
public Dictionary<string, string>? Match { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaJobStep
|
||||
{
|
||||
public string CarId { get; set; } = "";
|
||||
public string? Ip { get; set; }
|
||||
public string Component { get; set; } = "";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaJob
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
/// <summary>sync | customFile | configPush</summary>
|
||||
public string Kind { get; set; } = "sync";
|
||||
public string Status { get; set; } = "pending";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? FinishedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public string? PackageId { get; set; }
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public List<string> Components { get; set; } = new();
|
||||
public bool RequireLatencyCheck { get; set; }
|
||||
public int DoneSteps { get; set; }
|
||||
public int TotalSteps { get; set; }
|
||||
public List<OtaJobStep> Steps { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
// customFile
|
||||
public string? CustomFileName { get; set; }
|
||||
public string? CustomRemotePath { get; set; }
|
||||
public int? CustomRestartOp { get; set; }
|
||||
public string? CustomLocalPath { get; set; }
|
||||
public List<OtaCustomFileItem> CustomFiles { get; set; } = new();
|
||||
public List<int> CustomRestartOps { get; set; } = new() { -1 };
|
||||
// configPush
|
||||
public string? ConfigApp { get; set; }
|
||||
public string? ConfigJson { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateSyncJobRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public List<string>? Components { get; set; }
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PullPackageRequest
|
||||
{
|
||||
public string CarId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateCustomFileJobRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public string RemotePath { get; set; } = "";
|
||||
/// <summary>兼容旧单值;优先用 RestartOps。</summary>
|
||||
public int RestartOp { get; set; } = -1;
|
||||
/// <summary>-1 不重启;0 Medulla;1 Clumsy;2 Detour;3 WatchDog。可多选。</summary>
|
||||
public List<int>? RestartOps { get; set; }
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtaCustomFileItem
|
||||
{
|
||||
public string LocalPath { get; set; } = "";
|
||||
public string FileName { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateConfigPushRequest
|
||||
{
|
||||
public List<string> CarIds { get; set; } = new();
|
||||
public string App { get; set; } = "";
|
||||
public string Json { get; set; } = "";
|
||||
public bool? RequireLatencyCheck { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaOptions
|
||||
{
|
||||
/// <summary>相对 ContentRoot 或绝对路径;默认 data/ota</summary>
|
||||
public string DataRoot { get; set; } = "data/ota";
|
||||
|
||||
public int WatchDogPort { get; set; } = 9776;
|
||||
|
||||
public int RequestTimeoutMs { get; set; } = 60_000;
|
||||
|
||||
public int UploadTimeoutMs { get; set; } = 600_000;
|
||||
|
||||
/// <summary>
|
||||
/// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。
|
||||
/// </summary>
|
||||
public int ReceivePort { get; set; } = 8000;
|
||||
|
||||
/// <summary>
|
||||
/// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。
|
||||
/// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。
|
||||
/// </summary>
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
/// <summary>MDC 组件文件映射(对齐参考工具 MDCPath.json)。</summary>
|
||||
public static class OtaPathMap
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string[]> Default = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["M"] = new[] { "Medulla.exe", "plugins\\CartActivator.dll", "plugins\\CartActivator.pdb" },
|
||||
["D"] = new[] { "Detour.exe" },
|
||||
["C"] = new[] { "ClumsyConsole.exe", "FG2305014_C.dll", "FG2305014_C.pdb" }
|
||||
};
|
||||
|
||||
public static readonly string[] ComponentKeys =
|
||||
{
|
||||
"M.exe", "M.dll", "M.pdb", "D.exe", "C.exe", "C.dll", "C.pdb"
|
||||
};
|
||||
|
||||
public static string? RelPathFor(string componentKey)
|
||||
{
|
||||
return componentKey switch
|
||||
{
|
||||
"M.exe" => "M/Medulla.exe",
|
||||
"M.dll" => "M/plugins/CartActivator.dll",
|
||||
"M.pdb" => "M/plugins/CartActivator.pdb",
|
||||
"D.exe" => "D/Detour.exe",
|
||||
"C.exe" => "C/ClumsyConsole.exe",
|
||||
"C.dll" => "C/FG2305014_C.dll",
|
||||
"C.pdb" => "C/FG2305014_C.pdb",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static string? WatchDogUpdatePath(string componentKey) => componentKey switch
|
||||
{
|
||||
"M.exe" => "updateMedullaExecutable",
|
||||
"M.dll" => "updateMedullaDll",
|
||||
"M.pdb" => "UpdateMedullaPdb",
|
||||
"D.exe" => "updateDetourExecutable",
|
||||
"C.exe" => "updateClumsyExecutable",
|
||||
"C.dll" => "updateClumsyDll",
|
||||
"C.pdb" => "UpdateClumsyPdb",
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static string AppFolder(string componentKey) => componentKey.StartsWith('M') ? "M"
|
||||
: componentKey.StartsWith('D') ? "D" : "C";
|
||||
|
||||
public static string ExtKey(string componentKey)
|
||||
{
|
||||
var i = componentKey.IndexOf('.');
|
||||
return i >= 0 ? componentKey[(i + 1)..] : componentKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly ILogger<OtaStore> _log;
|
||||
private readonly JsonSerializerOptions _json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public string Root { get; }
|
||||
public string PackagesDir { get; }
|
||||
public string JobsDir { get; }
|
||||
public string HistoryDir { get; }
|
||||
public string TargetFile { get; }
|
||||
public string SettingsFile { get; }
|
||||
|
||||
private string? _lastPullId;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _jobSeq;
|
||||
|
||||
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
|
||||
{
|
||||
_log = log;
|
||||
var cfg = options.Value.DataRoot;
|
||||
Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg);
|
||||
PackagesDir = Path.Combine(Root, "packages");
|
||||
JobsDir = Path.Combine(Root, "jobs");
|
||||
HistoryDir = Path.Combine(Root, "history");
|
||||
TargetFile = Path.Combine(Root, "target.json");
|
||||
SettingsFile = Path.Combine(Root, "settings.json");
|
||||
Directory.CreateDirectory(PackagesDir);
|
||||
Directory.CreateDirectory(JobsDir);
|
||||
Directory.CreateDirectory(HistoryDir);
|
||||
}
|
||||
|
||||
public OtaSettings GetSettings()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA settings load failed");
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public OtaSettings SaveSettings(OtaSettings settings)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget? GetTarget()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(TargetFile)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaTarget>(File.ReadAllText(TargetFile), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA target load failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTarget(OtaTarget target)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json));
|
||||
var settings = GetSettingsUnlocked();
|
||||
if (!string.IsNullOrWhiteSpace(target.Name))
|
||||
{
|
||||
settings.NewVersionName = target.Name;
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OtaSettings GetSettingsUnlocked()
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public string BeginPullPackage(string? sourceIp)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})";
|
||||
var id = baseId;
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
for (var i = 1; Directory.Exists(dir); i++)
|
||||
{
|
||||
id = $"{baseId}-{i:D2}";
|
||||
dir = Path.Combine(PackagesDir, id);
|
||||
}
|
||||
Directory.CreateDirectory(dir);
|
||||
foreach (var app in new[] { "M", "D", "C" })
|
||||
Directory.CreateDirectory(Path.Combine(dir, app));
|
||||
Directory.CreateDirectory(Path.Combine(dir, "M", "plugins"));
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null) _pullByIp[ip] = id;
|
||||
_lastPullId = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ActivePullId
|
||||
{
|
||||
get { lock (_gate) return _lastPullId; }
|
||||
}
|
||||
|
||||
public bool TryGetActivePullId(string? sourceIp, out string? id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
|
||||
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
|
||||
{
|
||||
id = byIp;
|
||||
return true;
|
||||
}
|
||||
|
||||
id = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void NotePullReceive(string? sourceIp)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip == null) return;
|
||||
_pullLastReceiveUtc[ip] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc))
|
||||
return true;
|
||||
utc = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearActivePull(string? sourceIp = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null)
|
||||
{
|
||||
_pullByIp.TryRemove(ip, out _);
|
||||
_pullLastReceiveUtc.TryRemove(ip, out _);
|
||||
}
|
||||
if (_pullByIp.IsEmpty) _lastPullId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
|
||||
private static string? NormalizeIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return null;
|
||||
if (System.Net.IPAddress.TryParse(ip, out var addr))
|
||||
return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString();
|
||||
return ip.Trim();
|
||||
}
|
||||
|
||||
public string ResolveReceivePath(string routeKey, string? clientIp = null)
|
||||
{
|
||||
// routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll
|
||||
// Do not fall back to the latest session for a known-but-unregistered client.
|
||||
if (!TryGetActivePullId(clientIp, out var id) || id == null)
|
||||
throw new InvalidOperationException("无进行中的拉取会话");
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
return routeKey.ToLowerInvariant() switch
|
||||
{
|
||||
"medullaexe" => Path.Combine(dir, "M", "Medulla.exe"),
|
||||
"medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"),
|
||||
"medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"),
|
||||
"detourexe" => Path.Combine(dir, "D", "Detour.exe"),
|
||||
"clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"),
|
||||
"clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"),
|
||||
"clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"),
|
||||
_ => throw new ArgumentException($"未知接收路由: {routeKey}")
|
||||
};
|
||||
}
|
||||
|
||||
public OtaPackageInfo ScanPackage(string id)
|
||||
{
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id);
|
||||
var info = new OtaPackageInfo
|
||||
{
|
||||
Id = id,
|
||||
CreatedAt = Directory.GetCreationTimeUtc(dir),
|
||||
SourceIp = ExtractSourceIp(id)
|
||||
};
|
||||
long total = 0;
|
||||
foreach (var key in OtaPathMap.ComponentKeys)
|
||||
{
|
||||
var rel = OtaPathMap.RelPathFor(key);
|
||||
if (rel == null) continue;
|
||||
var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path)) continue;
|
||||
var fi = new FileInfo(path);
|
||||
total += fi.Length;
|
||||
info.Components[key] = new OtaFileArtifact
|
||||
{
|
||||
Hash = OtaHash.OfFile(path),
|
||||
Path = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
Size = fi.Length
|
||||
};
|
||||
}
|
||||
info.TotalBytes = total;
|
||||
var target = GetTarget();
|
||||
info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal);
|
||||
return info;
|
||||
}
|
||||
|
||||
public List<OtaPackageInfo> ListPackages()
|
||||
{
|
||||
if (!Directory.Exists(PackagesDir)) return new();
|
||||
var list = new List<OtaPackageInfo>();
|
||||
foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d))
|
||||
{
|
||||
try { list.Add(ScanPackage(Path.GetFileName(dir))); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public void DeletePackage(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var target = GetTarget();
|
||||
if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("不能删除当前目标版本");
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget ActivatePackage(string id, string? name)
|
||||
{
|
||||
var pkg = ScanPackage(id);
|
||||
if (pkg.Components.Count == 0)
|
||||
throw new InvalidOperationException("包内无有效组件文件");
|
||||
var safeId = RequireSafeId(id);
|
||||
var target = new OtaTarget
|
||||
{
|
||||
PackageId = safeId,
|
||||
Name = name ?? safeId,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
Components = pkg.Components
|
||||
};
|
||||
SetTarget(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
public string PackageDir(string id) => ResolveUnder(PackagesDir, id);
|
||||
|
||||
public void SaveJob(OtaJob job)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json");
|
||||
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json));
|
||||
}
|
||||
}
|
||||
|
||||
public OtaJob? GetJob(string id)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(path), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "job load failed {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public List<OtaJob> ListJobs(int take = 100)
|
||||
{
|
||||
if (!Directory.Exists(JobsDir)) return new();
|
||||
return Directory.GetFiles(JobsDir, "*.json")
|
||||
.Select(f =>
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
|
||||
catch { return null; }
|
||||
})
|
||||
.Where(j => j != null)
|
||||
.Cast<OtaJob>()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string NextJobId()
|
||||
{
|
||||
// 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。
|
||||
var seq = System.Threading.Interlocked.Increment(ref _jobSeq);
|
||||
return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}";
|
||||
}
|
||||
|
||||
private static string? ExtractSourceIp(string id)
|
||||
{
|
||||
var open = id.IndexOf('(');
|
||||
var close = id.IndexOf(')');
|
||||
if (open >= 0 && close > open) return id[(open + 1)..close];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SafeIdPart(string raw)
|
||||
{
|
||||
var safe = raw.Trim();
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(ch, '_');
|
||||
return string.IsNullOrWhiteSpace(safe) ? "upload" : safe;
|
||||
}
|
||||
|
||||
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
|
||||
private static string RequireSafeId(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("无效标识");
|
||||
var trimmed = id.Trim();
|
||||
if (trimmed is "." or ".."
|
||||
|| trimmed.Contains('/') || trimmed.Contains('\\')
|
||||
|| trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new ArgumentException("无效标识");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private string ResolveUnder(string root, string id)
|
||||
{
|
||||
var safe = RequireSafeId(id);
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var full = Path.GetFullPath(Path.Combine(fullRoot, safe));
|
||||
var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("无效标识");
|
||||
return full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaVehicleSource
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Simple3Options _sl;
|
||||
private readonly InternalTokenStoreAccessor _token;
|
||||
private readonly ILogger<OtaVehicleSource> _log;
|
||||
|
||||
public OtaVehicleSource(
|
||||
IHttpClientFactory httpFactory,
|
||||
IOptions<Simple3Options> sl,
|
||||
InternalTokenStoreAccessor token,
|
||||
ILogger<OtaVehicleSource> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_sl = sl.Value;
|
||||
_token = token;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<List<OtaVehicleRow>> ListCarsAsync(CancellationToken ct)
|
||||
{
|
||||
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
|
||||
var cars = await TryProjectionAsync(port, ct);
|
||||
if (cars.Count == 0)
|
||||
cars = await TryAgvListAsync(port, ct);
|
||||
return cars;
|
||||
}
|
||||
|
||||
private async Task<List<OtaVehicleRow>> TryProjectionAsync(int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars");
|
||||
var token = _token.Token;
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token);
|
||||
using var resp = await client.SendAsync(req, ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
return ParseCars(text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "projection/cars failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<OtaVehicleRow>> TryAgvListAsync(int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = _httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
using var resp = await client.GetAsync($"http://127.0.0.1:{port}/api/agv/list", ct);
|
||||
if (!resp.IsSuccessStatusCode) return new();
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
var arr = root.ValueKind == JsonValueKind.Array ? root
|
||||
: root.TryGetProperty("data", out var d) ? d
|
||||
: root.TryGetProperty("items", out var i) ? i
|
||||
: default;
|
||||
if (arr.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<OtaVehicleRow>();
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
var id = GetStr(el, "agv_id", "id", "carId") ?? "";
|
||||
var name = GetStr(el, "agv_name", "name") ?? id;
|
||||
var ip = GetStr(el, "agv_ip", "ip");
|
||||
var state = GetStr(el, "status", "state");
|
||||
if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(ip)) continue;
|
||||
list.Add(new OtaVehicleRow
|
||||
{
|
||||
Id = string.IsNullOrEmpty(id) ? ip! : id,
|
||||
Name = name,
|
||||
Ip = ip,
|
||||
State = state
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "agv/list failed");
|
||||
return new();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OtaVehicleRow> ParseCars(string text)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
var arr = root.ValueKind == JsonValueKind.Array ? root
|
||||
: root.TryGetProperty("cars", out var c) ? c
|
||||
: root.TryGetProperty("items", out var i) ? i
|
||||
: root.TryGetProperty("data", out var d) ? d
|
||||
: default;
|
||||
if (arr.ValueKind != JsonValueKind.Array) return new();
|
||||
var list = new List<OtaVehicleRow>();
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
var id = GetStr(el, "id", "carId", "rawId") ?? "";
|
||||
var name = GetStr(el, "name") ?? id;
|
||||
var ip = GetStr(el, "ip");
|
||||
var state = GetStr(el, "state", "status");
|
||||
var group = GetStr(el, "group");
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
list.Add(new OtaVehicleRow { Id = id, Name = name, Ip = ip, State = state, Group = group });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string? GetStr(JsonElement el, params string[] names)
|
||||
{
|
||||
foreach (var n in names)
|
||||
{
|
||||
if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String)
|
||||
return p.GetString();
|
||||
if (el.TryGetProperty(n, out p) && p.ValueKind is JsonValueKind.Number)
|
||||
return p.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。</summary>
|
||||
public sealed class InternalTokenStoreAccessor
|
||||
{
|
||||
private readonly Auth.InternalTokenStore _store;
|
||||
public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store;
|
||||
public string Token => _store.Token;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class WatchDogClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly OtaOptions _opt;
|
||||
private readonly ILogger<WatchDogClient> _log;
|
||||
private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public WatchDogClient(IHttpClientFactory httpFactory, IOptions<OtaOptions> opt, ILogger<WatchDogClient> log)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_opt = opt.Value;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(int? timeoutMs = null)
|
||||
{
|
||||
var c = _httpFactory.CreateClient(nameof(WatchDogClient));
|
||||
c.Timeout = TimeSpan.FromMilliseconds(timeoutMs ?? _opt.RequestTimeoutMs);
|
||||
return c;
|
||||
}
|
||||
|
||||
private string Base(string ip) => $"http://{ip}:{_opt.WatchDogPort}";
|
||||
|
||||
public async Task<(bool Ok, OtaAppVersions? M, OtaAppVersions? D, OtaAppVersions? C, string? Error)> GetMdcInfoAsync(string ip, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.GetAsync($"{Base(ip)}/getMDCInfo", ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return (false, null, null, null, $"HTTP {(int)resp.StatusCode}");
|
||||
var text = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
return (true, ParseApp(root, "Medulla"), ParseApp(root, "Detour"), ParseApp(root, "Clumsy"), null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, null, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static OtaAppVersions? ParseApp(JsonElement root, string name)
|
||||
{
|
||||
if (!root.TryGetProperty(name, out var app) && !root.TryGetProperty(name.ToLowerInvariant(), out app))
|
||||
return null;
|
||||
return new OtaAppVersions
|
||||
{
|
||||
Exe = ParseComp(app, "exe"),
|
||||
Dll = ParseComp(app, "dll"),
|
||||
Pdb = ParseComp(app, "pdb")
|
||||
};
|
||||
}
|
||||
|
||||
private static OtaComponentVersion? ParseComp(JsonElement app, string key)
|
||||
{
|
||||
if (!app.TryGetProperty(key, out var c)) return null;
|
||||
string? ver = null;
|
||||
string? time = null;
|
||||
if (c.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (c.TryGetProperty("version", out var v))
|
||||
ver = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString();
|
||||
if (c.TryGetProperty("time", out var t))
|
||||
time = t.GetString();
|
||||
}
|
||||
else if (c.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
ver = c.GetString();
|
||||
}
|
||||
return new OtaComponentVersion { Version = ver, Time = time };
|
||||
}
|
||||
|
||||
public async Task<int?> MeasureRttMsAsync(string ip, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var tcp = new TcpClient();
|
||||
using var reg = ct.Register(() => { try { tcp.Close(); } catch { /* ignore */ } });
|
||||
var connectTask = tcp.ConnectAsync(ip, _opt.WatchDogPort);
|
||||
var done = await Task.WhenAny(connectTask, Task.Delay(Math.Min(3000, _opt.RequestTimeoutMs), ct));
|
||||
if (done != connectTask || !tcp.Connected) return null;
|
||||
await connectTask;
|
||||
sw.Stop();
|
||||
return (int)sw.ElapsedMilliseconds;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task TriggerPullAsync(string ip, string serverBaseUrl, string time, CancellationToken ct)
|
||||
{
|
||||
// 现网 WatchDog 忽略 server 查询参数,固定 POST 到 http://{config.serverIP}:8000/upload-mdcs/{key}。
|
||||
// serverBaseUrl 仅作日志/未来兼容;真正要通必须:车上 serverIP=本机局域网 IP,且本机监听 ReceivePort。
|
||||
using var client = CreateClient(_opt.UploadTimeoutMs);
|
||||
var url = $"{Base(ip)}/getmdcsexe?time={Uri.EscapeDataString(time)}&server={Uri.EscapeDataString(serverBaseUrl.TrimEnd('/'))}";
|
||||
try
|
||||
{
|
||||
using var resp = await client.GetAsync(url, ct);
|
||||
var body = (await resp.Content.ReadAsStringAsync(ct)).Trim();
|
||||
_log.LogInformation("getmdcsexe {Ip} -> {Code} body={Body} (WatchDog will POST to its config.serverIP:{Port}/upload-mdcs/*; expect receiver {Base})",
|
||||
ip, (int)resp.StatusCode, body.Length > 200 ? body[..200] : body, _opt.ReceivePort, serverBaseUrl);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"WatchDog getmdcsexe HTTP {(int)resp.StatusCode}: {body}");
|
||||
if (body.Contains("请配置", StringComparison.Ordinal)
|
||||
|| body.Equals("false", StringComparison.OrdinalIgnoreCase)
|
||||
|| body.Equals("\"false\"", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WatchDog 拒绝拉包或回传失败。请确认:1) 车已配置 Medulla/Detour/Clumsy 路径;" +
|
||||
$"2) watch_dog.json 的 serverIP 指向本机局域网 IP(车将 POST 到 serverIP:{_opt.ReceivePort}/upload-mdcs/*);" +
|
||||
$"3) 本机已监听 :{_opt.ReceivePort}。WatchDog 返回:{body}");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "getmdcsexe failed {Ip}", ip);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UploadComponentAsync(string ip, string componentKey, string localPath, string fileName, int bandwidthKbps, CancellationToken ct)
|
||||
{
|
||||
var endpoint = OtaPathMap.WatchDogUpdatePath(componentKey)
|
||||
?? throw new ArgumentException($"未知组件 {componentKey}");
|
||||
await UploadFileAsync($"{Base(ip)}/{endpoint}", localPath, fileName, bandwidthKbps, null, ct);
|
||||
}
|
||||
|
||||
public async Task UploadCustomFileAsync(string ip, string localPath, string fileName, string remotePath, int restartOp, int bandwidthKbps, CancellationToken ct)
|
||||
{
|
||||
var url = $"{Base(ip)}/updateFile/{Uri.EscapeDataString(fileName)}/{restartOp}/";
|
||||
await UploadFileAsync(url, localPath, fileName, bandwidthKbps, new Dictionary<string, string> { ["path"] = remotePath }, ct);
|
||||
}
|
||||
|
||||
private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary<string, string>? extraFields, CancellationToken ct)
|
||||
{
|
||||
using var client = CreateClient(_opt.UploadTimeoutMs);
|
||||
await using var fs = File.OpenRead(localPath);
|
||||
Stream contentStream = fs;
|
||||
if (bandwidthKbps > 0)
|
||||
contentStream = new ThrottledStream(fs, bandwidthKbps * 1024L);
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
if (extraFields != null)
|
||||
{
|
||||
foreach (var (k, v) in extraFields)
|
||||
form.Add(new StringContent(v, Encoding.UTF8), k);
|
||||
}
|
||||
var streamContent = new StreamContent(contentStream);
|
||||
form.Add(streamContent, "file", fileName);
|
||||
|
||||
using var resp = await client.PostAsync(url, form, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"上传失败 HTTP {(int)resp.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetJsonAsync(string ip, string app, CancellationToken ct)
|
||||
{
|
||||
var path = app.ToLowerInvariant() switch
|
||||
{
|
||||
"medulla" => "getMedullajson",
|
||||
"detour" => "getDetourjson",
|
||||
"clumsy" => "getClumsyjson",
|
||||
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
|
||||
};
|
||||
using var client = CreateClient();
|
||||
using var resp = await client.GetAsync($"{Base(ip)}/{path}", ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadAsStringAsync(ct);
|
||||
}
|
||||
|
||||
public async Task PutJsonAsync(string ip, string app, string json, CancellationToken ct)
|
||||
{
|
||||
var path = app.ToLowerInvariant() switch
|
||||
{
|
||||
"medulla" => "updateMedullajson",
|
||||
"detour" => "updateDetourjson",
|
||||
"clumsy" => "updateClumsyjson",
|
||||
_ => throw new ArgumentException("app 须为 medulla|detour|clumsy")
|
||||
};
|
||||
using var client = CreateClient();
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var resp = await client.PostAsync($"{Base(ip)}/{path}", content, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
throw new InvalidOperationException($"更新 JSON 失败 HTTP {(int)resp.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>简易限速流:按字节/秒节流读取。</summary>
|
||||
private sealed class ThrottledStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly long _bytesPerSecond;
|
||||
private long _windowBytes;
|
||||
private long _windowStart = Environment.TickCount64;
|
||||
|
||||
public ThrottledStream(Stream inner, long bytesPerSecond)
|
||||
{
|
||||
_inner = inner;
|
||||
_bytesPerSecond = Math.Max(1024, bytesPerSecond);
|
||||
}
|
||||
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _inner.Length;
|
||||
public override long Position { get => _inner.Position; set => throw new NotSupportedException(); }
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var n = _inner.Read(buffer, offset, count);
|
||||
if (n > 0) Throttle(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||
if (n > 0) await ThrottleAsync(n, cancellationToken);
|
||||
return n;
|
||||
}
|
||||
|
||||
private void Throttle(int n)
|
||||
{
|
||||
_windowBytes += n;
|
||||
var elapsed = Environment.TickCount64 - _windowStart;
|
||||
if (elapsed < 1) elapsed = 1;
|
||||
var allowed = _bytesPerSecond * elapsed / 1000;
|
||||
if (_windowBytes > allowed)
|
||||
{
|
||||
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
|
||||
if (wait > 0) Thread.Sleep(Math.Min(wait, 2000));
|
||||
}
|
||||
if (elapsed >= 1000)
|
||||
{
|
||||
_windowBytes = 0;
|
||||
_windowStart = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrottleAsync(int n, CancellationToken ct)
|
||||
{
|
||||
_windowBytes += n;
|
||||
var elapsed = Environment.TickCount64 - _windowStart;
|
||||
if (elapsed < 1) elapsed = 1;
|
||||
var allowed = _bytesPerSecond * elapsed / 1000;
|
||||
if (_windowBytes > allowed)
|
||||
{
|
||||
var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond);
|
||||
if (wait > 0) await Task.Delay(Math.Min(wait, 2000), ct);
|
||||
}
|
||||
if (elapsed >= 1000)
|
||||
{
|
||||
_windowBytes = 0;
|
||||
_windowStart = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
// 不释放 inner(由调用方 using FileStream)
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
public abstract class EntityBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
public string DeletedBy { get; set; } = "";
|
||||
public long Version { get; set; }
|
||||
public bool IsLock { get; set; }
|
||||
public string CreatedBy { get; set; } = "";
|
||||
public string UpdatedBy { get; set; } = "";
|
||||
public string Remark { get; set; } = "";
|
||||
public string Extend { get; set; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using MiGu.DB.Abstractions.Runtime;
|
||||
using MiGu.DB.Kernel.Conventions;
|
||||
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
/// <summary>从 JWT Claims 解析当前操作者,写入 IActorContextAccessor。</summary>
|
||||
public sealed class HttpActorContextMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public HttpActorContextMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext http, IActorContextAccessor actors)
|
||||
{
|
||||
var name = http.User?.Identity?.Name
|
||||
?? http.User?.FindFirst("sub")?.Value
|
||||
?? http.User?.FindFirst("unique_name")?.Value
|
||||
?? "system";
|
||||
actors.Current = new HttpActorContext(name);
|
||||
try
|
||||
{
|
||||
await _next(http);
|
||||
}
|
||||
finally
|
||||
{
|
||||
actors.Current = SystemActorContext.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HttpActorContext : IActorContext
|
||||
{
|
||||
public HttpActorContext(string name) => Name = name;
|
||||
public string Name { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.Server.Dashboard;
|
||||
using MiGu.Server.Wms;
|
||||
using MiGu.Server.SimpleFields;
|
||||
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
public sealed class PlatformDbContext : DbContext
|
||||
{
|
||||
public PlatformDbContext(DbContextOptions<PlatformDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
|
||||
public DbSet<Storage> Storages => Set<Storage>();
|
||||
public DbSet<Container> Containers => Set<Container>();
|
||||
public DbSet<MaterialType> MaterialTypes => Set<MaterialType>();
|
||||
public DbSet<Material> Materials => Set<Material>();
|
||||
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
|
||||
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
||||
public DbSet<StockEvent> StockEvents => Set<StockEvent>();
|
||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||
public DbSet<WmsTransportRule> WmsTransportRules => Set<WmsTransportRule>();
|
||||
public DbSet<WmsTransportTask> WmsTransportTasks => Set<WmsTransportTask>();
|
||||
public DbSet<WmsTransportReservation> WmsTransportReservations => Set<WmsTransportReservation>();
|
||||
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var guid = new ValueConverter<Guid, string>(
|
||||
v => v.ToString("D"),
|
||||
v => Guid.Parse(v));
|
||||
var nullableGuid = new ValueConverter<Guid?, string?>(
|
||||
v => v.HasValue ? v.Value.ToString("D") : null,
|
||||
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
|
||||
var dateTimeOffset = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
var nullableDateTimeOffset = new ValueConverter<DateTimeOffset?, string?>(
|
||||
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
|
||||
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
|
||||
|
||||
foreach (var entity in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
|
||||
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36);
|
||||
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
|
||||
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
|
||||
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
|
||||
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(dateTimeOffset).HasMaxLength(40);
|
||||
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
|
||||
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableDateTimeOffset).HasMaxLength(40);
|
||||
}
|
||||
|
||||
ConfigureEntityBase<Warehouse>(modelBuilder, "wms_warehouses");
|
||||
ConfigureEntityBase<WarehouseArea>(modelBuilder, "wms_areas");
|
||||
ConfigureEntityBase<Storage>(modelBuilder, "wms_storages");
|
||||
ConfigureEntityBase<Container>(modelBuilder, "wms_containers");
|
||||
ConfigureEntityBase<MaterialType>(modelBuilder, "wms_material_types");
|
||||
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
|
||||
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
|
||||
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
|
||||
ConfigureEntityBase<WmsTransportRule>(modelBuilder, "wms_transport_rules");
|
||||
ConfigureEntityBase<WmsTransportTask>(modelBuilder, "wms_transport_tasks");
|
||||
ConfigureEntityBase<WmsTransportReservation>(modelBuilder, "wms_transport_reservations");
|
||||
|
||||
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
|
||||
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
|
||||
ConfigureStockEvent(modelBuilder);
|
||||
ConfigureTransportTaskHistory(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Warehouse>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.WarehouseId);
|
||||
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<Storage>().HasIndex(x => x.AreaId);
|
||||
modelBuilder.Entity<Storage>().HasIndex(x => x.Barcode);
|
||||
modelBuilder.Entity<Container>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<MaterialType>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<Material>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<Material>().HasIndex(x => x.Barcode);
|
||||
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
|
||||
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
|
||||
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
|
||||
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
|
||||
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
|
||||
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
|
||||
|
||||
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => x.Code).IsUnique();
|
||||
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
|
||||
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.Status);
|
||||
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.ContainerId);
|
||||
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.TargetStorageId);
|
||||
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.ContainerId, x.Status });
|
||||
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.TargetStorageId, x.Status });
|
||||
modelBuilder.Entity<WmsTransportTask>().Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
|
||||
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||
|
||||
ConfigureSimpleField(modelBuilder);
|
||||
ConfigureUserDashboardShortcut(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<UserDashboardShortcut>();
|
||||
e.ToTable("user_dashboard_shortcuts");
|
||||
e.HasKey(x => new { x.UserId, x.Scope });
|
||||
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
|
||||
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
|
||||
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
|
||||
}
|
||||
|
||||
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<SimpleField>();
|
||||
e.ToTable("simple_fields");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).HasColumnName("id");
|
||||
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
|
||||
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
|
||||
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
|
||||
e.Property(x => x.Value).HasColumnName("value");
|
||||
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
|
||||
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
|
||||
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
|
||||
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
|
||||
e.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => SimpleFieldDateTime.ToStorage(v),
|
||||
v => SimpleFieldDateTime.FromStorage(v));
|
||||
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
StampEntities();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
StampEntities();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
private void StampEntities()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var e in ChangeTracker.Entries<EntityBase>())
|
||||
{
|
||||
if (e.State == EntityState.Added)
|
||||
{
|
||||
if (e.Entity.Id == Guid.Empty) e.Entity.Id = Guid.NewGuid();
|
||||
e.Entity.CreatedAt = now;
|
||||
e.Entity.UpdatedAt = now;
|
||||
e.Entity.Version = Math.Max(1, e.Entity.Version);
|
||||
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
|
||||
}
|
||||
else if (e.State == EntityState.Modified)
|
||||
{
|
||||
e.Entity.UpdatedAt = now;
|
||||
e.Entity.Version += 1;
|
||||
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureEntityBase<T>(ModelBuilder modelBuilder, string table) where T : EntityBase
|
||||
{
|
||||
var e = modelBuilder.Entity<T>();
|
||||
e.ToTable(table);
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.UpdatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.DeletedBy).HasMaxLength(128);
|
||||
e.Property(x => x.Remark).HasMaxLength(1000);
|
||||
e.Property(x => x.Extend).HasColumnType("text");
|
||||
e.HasQueryFilter(x => !x.IsDeleted);
|
||||
}
|
||||
|
||||
private static void ConfigureHistory<T>(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase
|
||||
{
|
||||
var e = modelBuilder.Entity<T>();
|
||||
e.ToTable(table);
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.EventType).HasMaxLength(64);
|
||||
e.Property(x => x.BeforeJson).HasColumnType("text");
|
||||
e.Property(x => x.AfterJson).HasColumnType("text");
|
||||
e.Property(x => x.Operator).HasMaxLength(128);
|
||||
e.Property(x => x.Source).HasMaxLength(32);
|
||||
e.Property(x => x.Reason).HasMaxLength(500);
|
||||
e.Property(x => x.Remark).HasMaxLength(1000);
|
||||
e.Property(x => x.Extend).HasColumnType("text");
|
||||
e.HasIndex(x => x.ContainerId);
|
||||
e.HasIndex(x => x.OperatedAt);
|
||||
e.HasIndex(x => x.EventType);
|
||||
}
|
||||
|
||||
private static void ConfigureStockEvent(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<StockEvent>();
|
||||
e.ToTable("wms_stock_events");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.EventType).HasMaxLength(32);
|
||||
e.Property(x => x.MaterialCode).HasMaxLength(64);
|
||||
e.Property(x => x.MaterialName).HasMaxLength(128);
|
||||
e.Property(x => x.MaterialBarcode).HasMaxLength(128);
|
||||
e.Property(x => x.MaterialTypeCode).HasMaxLength(64);
|
||||
e.Property(x => x.ContainerCode).HasMaxLength(64);
|
||||
e.Property(x => x.ContainerName).HasMaxLength(128);
|
||||
e.Property(x => x.StorageCode).HasMaxLength(64);
|
||||
e.Property(x => x.StorageName).HasMaxLength(128);
|
||||
e.Property(x => x.AreaCode).HasMaxLength(64);
|
||||
e.Property(x => x.FromStorageCode).HasMaxLength(64);
|
||||
e.Property(x => x.FromStorageName).HasMaxLength(128);
|
||||
e.Property(x => x.ToStorageCode).HasMaxLength(64);
|
||||
e.Property(x => x.ToStorageName).HasMaxLength(128);
|
||||
e.Property(x => x.RefType).HasMaxLength(64);
|
||||
e.Property(x => x.RefCode).HasMaxLength(64);
|
||||
e.Property(x => x.Operator).HasMaxLength(128);
|
||||
e.Property(x => x.Reason).HasMaxLength(500);
|
||||
e.HasIndex(x => x.OperatedAt);
|
||||
e.HasIndex(x => x.EventType);
|
||||
e.HasIndex(x => x.MaterialId);
|
||||
e.HasIndex(x => x.ContainerId);
|
||||
}
|
||||
|
||||
private static void ConfigureTransportTaskHistory(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<WmsTransportTaskHistory>();
|
||||
e.ToTable("wms_transport_task_history");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.FromStatus).HasMaxLength(32);
|
||||
e.Property(x => x.ToStatus).HasMaxLength(32);
|
||||
e.Property(x => x.Operator).HasMaxLength(128);
|
||||
e.Property(x => x.Reason).HasMaxLength(500);
|
||||
e.Property(x => x.ErrorMessage).HasMaxLength(1000);
|
||||
e.Property(x => x.SnapshotJson).HasColumnType("text");
|
||||
e.HasIndex(x => x.TaskId);
|
||||
e.HasIndex(x => x.OperatedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,464 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MiGu.Server.Wms;
|
||||
using MiGu.Server.SimpleFields;
|
||||
using MiGu.DB.Kernel.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
/// <summary>兼容入口:转调 MiGu.DB Hosting,并注册本进程业务服务。</summary>
|
||||
public static class PlatformPersistence
|
||||
{
|
||||
public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<PlatformDbContext>((sp, options) =>
|
||||
{
|
||||
var env = sp.GetRequiredService<IWebHostEnvironment>();
|
||||
var provider = configuration["Database:Provider"] ?? "sqlite";
|
||||
var connection = ResolveConnectionString(configuration, env, provider);
|
||||
services.AddMiGuDb(configuration);
|
||||
|
||||
switch (provider.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "sqlite":
|
||||
options.UseSqlite(connection);
|
||||
break;
|
||||
case "mysql":
|
||||
options.UseMySql(connection, ServerVersion.AutoDetect(connection));
|
||||
break;
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
case "npgsql":
|
||||
options.UseNpgsql(connection);
|
||||
break;
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
options.UseSqlServer(connection);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"未知数据库 Provider: {provider}");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddScoped<WmsReferenceValidator>();
|
||||
services.AddScoped<WmsService>();
|
||||
services.AddScoped<WmsTransportRuleService>();
|
||||
services.AddScoped<WmsTransportPlanner>();
|
||||
services.AddScoped<WmsTransportTaskService>();
|
||||
services.AddScoped<IWmsDispatchAdapter, NoopWmsDispatchAdapter>();
|
||||
services.AddScoped<SimpleFieldService>();
|
||||
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
|
||||
services.AddScoped<Wms.WmsReferenceValidator>();
|
||||
services.AddScoped<Wms.WmsService>();
|
||||
services.AddScoped<Wms.WmsTransportRuleService>();
|
||||
services.AddScoped<Wms.WmsTransportPlanner>();
|
||||
services.AddScoped<Wms.WmsTransportTaskService>();
|
||||
services.AddScoped<Wms.IWmsDispatchAdapter, Wms.NoopWmsDispatchAdapter>();
|
||||
services.AddScoped<SimpleFields.SimpleFieldService>();
|
||||
services.AddScoped<Dashboard.DashboardShortcutService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
|
||||
await EnsureSimpleFieldsTableAsync(db);
|
||||
await EnsureUserDashboardShortcutsTableAsync(db);
|
||||
await EnsureWmsTransportSchemaAsync(db);
|
||||
await EnsureWmsStructureSchemaAsync(db);
|
||||
await MigrateWmsLegacyAsync(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
private static async Task MigrateWmsLegacyAsync(IServiceProvider sp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wms = sp.GetRequiredService<WmsService>();
|
||||
await wms.MigrateLegacyAsync("system");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 首次建库或缺列时忽略,后续请求可再触发
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>补建仓库/物料类型/库存事件及结构扩展列(幂等,SQLite)。</summary>
|
||||
private static async Task EnsureWmsStructureSchemaAsync(PlatformDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_warehouses (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_warehouses PRIMARY KEY,
|
||||
Code TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
Type TEXT NOT NULL,
|
||||
Enabled INTEGER NOT NULL,
|
||||
SortOrder INTEGER NOT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
IsDeleted INTEGER NOT NULL,
|
||||
DeletedAt TEXT,
|
||||
DeletedBy TEXT NOT NULL DEFAULT '',
|
||||
Version INTEGER NOT NULL,
|
||||
IsLock INTEGER NOT NULL,
|
||||
CreatedBy TEXT NOT NULL DEFAULT '',
|
||||
UpdatedBy TEXT NOT NULL DEFAULT '',
|
||||
Remark TEXT NOT NULL DEFAULT '',
|
||||
Extend TEXT NOT NULL DEFAULT '{{}}'
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_warehouses_Code ON wms_warehouses (Code);");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_material_types (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_material_types PRIMARY KEY,
|
||||
Code TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
Spec TEXT NOT NULL DEFAULT '',
|
||||
Unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
Category TEXT NOT NULL DEFAULT '',
|
||||
BarcodePrefix TEXT NOT NULL DEFAULT '',
|
||||
Enabled INTEGER NOT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
IsDeleted INTEGER NOT NULL,
|
||||
DeletedAt TEXT,
|
||||
DeletedBy TEXT NOT NULL DEFAULT '',
|
||||
Version INTEGER NOT NULL,
|
||||
IsLock INTEGER NOT NULL,
|
||||
CreatedBy TEXT NOT NULL DEFAULT '',
|
||||
UpdatedBy TEXT NOT NULL DEFAULT '',
|
||||
Remark TEXT NOT NULL DEFAULT '',
|
||||
Extend TEXT NOT NULL DEFAULT '{{}}'
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_material_types_Code ON wms_material_types (Code);");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_stock_events (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_stock_events PRIMARY KEY,
|
||||
EventType TEXT NOT NULL,
|
||||
MaterialId TEXT,
|
||||
MaterialCode TEXT NOT NULL DEFAULT '',
|
||||
MaterialName TEXT NOT NULL DEFAULT '',
|
||||
MaterialBarcode TEXT NOT NULL DEFAULT '',
|
||||
MaterialTypeCode TEXT NOT NULL DEFAULT '',
|
||||
ContainerId TEXT,
|
||||
ContainerCode TEXT NOT NULL DEFAULT '',
|
||||
ContainerName TEXT NOT NULL DEFAULT '',
|
||||
StorageId TEXT,
|
||||
StorageCode TEXT NOT NULL DEFAULT '',
|
||||
StorageName TEXT NOT NULL DEFAULT '',
|
||||
AreaCode TEXT NOT NULL DEFAULT '',
|
||||
FromStorageId TEXT,
|
||||
FromStorageCode TEXT NOT NULL DEFAULT '',
|
||||
FromStorageName TEXT NOT NULL DEFAULT '',
|
||||
ToStorageId TEXT,
|
||||
ToStorageCode TEXT NOT NULL DEFAULT '',
|
||||
ToStorageName TEXT NOT NULL DEFAULT '',
|
||||
RefType TEXT NOT NULL DEFAULT 'Manual',
|
||||
RefId TEXT,
|
||||
RefCode TEXT NOT NULL DEFAULT '',
|
||||
Operator TEXT NOT NULL DEFAULT '',
|
||||
OperatedAt TEXT NOT NULL,
|
||||
Reason TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_wms_stock_events_OperatedAt ON wms_stock_events (OperatedAt);");
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_areas", "WarehouseId", "TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000'");
|
||||
await EnsureSqliteColumnAsync(db, "wms_areas", "LayoutMode", "TEXT NOT NULL DEFAULT 'Flat'");
|
||||
await EnsureSqliteColumnAsync(db, "wms_areas", "State", "TEXT NOT NULL DEFAULT 'Default'");
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "LocationKind", "TEXT NOT NULL DEFAULT 'Station'");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "ColumnNo", "INTEGER NOT NULL DEFAULT 0");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "LevelNo", "INTEGER NOT NULL DEFAULT 1");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "DepthNo", "INTEGER NOT NULL DEFAULT 1");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "SiteCode", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "Barcode", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_containers", "AreaId", "TEXT");
|
||||
await EnsureSqliteColumnAsync(db, "wms_containers", "Barcode", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_containers", "Length", "REAL NOT NULL DEFAULT 0");
|
||||
await EnsureSqliteColumnAsync(db, "wms_containers", "Width", "REAL NOT NULL DEFAULT 0");
|
||||
await EnsureSqliteColumnAsync(db, "wms_containers", "Height", "REAL NOT NULL DEFAULT 0");
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_materials", "TypeCode", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_materials", "Barcode", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_materials", "LifecycleStatus", "TEXT NOT NULL DEFAULT 'Active'");
|
||||
await EnsureSqliteColumnAsync(db, "wms_materials", "UnboundAt", "TEXT");
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS simple_fields (
|
||||
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
|
||||
car_type TEXT NOT NULL DEFAULT '',
|
||||
field_type TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
data_type TEXT NOT NULL DEFAULT '',
|
||||
chinese TEXT,
|
||||
english TEXT,
|
||||
other TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
|
||||
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE simple_fields SET car_type = other
|
||||
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE simple_fields SET other = ''
|
||||
WHERE other <> '' AND other = car_type;
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
|
||||
ON simple_fields (car_type, field_type, "key");
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
|
||||
if (!await TableExistsAsync(db, "simple_fields"))
|
||||
{
|
||||
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||
await creator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 WMS 搬运规则/任务相关表及库位扩展列(幂等)。</summary>
|
||||
private static async Task EnsureWmsTransportSchemaAsync(PlatformDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "Status", "TEXT NOT NULL DEFAULT 'Available'");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "Usage", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "Priority", "INTEGER NOT NULL DEFAULT 0");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "ZoneCode", "TEXT NOT NULL DEFAULT ''");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowInbound", "INTEGER NOT NULL DEFAULT 1");
|
||||
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowOutbound", "INTEGER NOT NULL DEFAULT 1");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_transport_rules (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_rules PRIMARY KEY,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
IsDeleted INTEGER NOT NULL,
|
||||
DeletedAt TEXT,
|
||||
DeletedBy TEXT,
|
||||
Version INTEGER NOT NULL,
|
||||
IsLock INTEGER NOT NULL,
|
||||
CreatedBy TEXT NOT NULL,
|
||||
UpdatedBy TEXT NOT NULL,
|
||||
Remark TEXT NOT NULL,
|
||||
Extend TEXT NOT NULL,
|
||||
Code TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
TriggerType TEXT NOT NULL,
|
||||
Enabled INTEGER NOT NULL,
|
||||
Priority INTEGER NOT NULL,
|
||||
SourceSelectorJson TEXT NOT NULL,
|
||||
TargetSelectorJson TEXT NOT NULL,
|
||||
TaskOptionsJson TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_transport_rules_Code ON wms_transport_rules (Code);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_transport_tasks (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_tasks PRIMARY KEY,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
IsDeleted INTEGER NOT NULL,
|
||||
DeletedAt TEXT,
|
||||
DeletedBy TEXT,
|
||||
Version INTEGER NOT NULL,
|
||||
IsLock INTEGER NOT NULL,
|
||||
CreatedBy TEXT NOT NULL,
|
||||
UpdatedBy TEXT NOT NULL,
|
||||
Remark TEXT NOT NULL,
|
||||
Extend TEXT NOT NULL,
|
||||
BusinessType TEXT NOT NULL,
|
||||
RuleId TEXT,
|
||||
SourceStorageId TEXT NOT NULL,
|
||||
TargetStorageId TEXT NOT NULL,
|
||||
ContainerId TEXT NOT NULL,
|
||||
MaterialId TEXT,
|
||||
Quantity REAL,
|
||||
Status TEXT NOT NULL,
|
||||
DispatchMissionId TEXT NOT NULL DEFAULT '',
|
||||
DeliveryId TEXT NOT NULL DEFAULT '',
|
||||
DispatchStatus TEXT NOT NULL DEFAULT '',
|
||||
Reason TEXT NOT NULL DEFAULT '',
|
||||
SnapshotJson TEXT NOT NULL,
|
||||
ErrorMessage TEXT NOT NULL DEFAULT '',
|
||||
TaskPriority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_transport_reservations (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_reservations PRIMARY KEY,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL,
|
||||
IsDeleted INTEGER NOT NULL,
|
||||
DeletedAt TEXT,
|
||||
DeletedBy TEXT,
|
||||
Version INTEGER NOT NULL,
|
||||
IsLock INTEGER NOT NULL,
|
||||
CreatedBy TEXT NOT NULL,
|
||||
UpdatedBy TEXT NOT NULL,
|
||||
Remark TEXT NOT NULL,
|
||||
Extend TEXT NOT NULL,
|
||||
TaskId TEXT NOT NULL,
|
||||
ContainerId TEXT NOT NULL,
|
||||
SourceStorageId TEXT NOT NULL,
|
||||
TargetStorageId TEXT NOT NULL,
|
||||
Status TEXT NOT NULL,
|
||||
ExpiresAt TEXT
|
||||
);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS wms_transport_task_history (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_task_history PRIMARY KEY,
|
||||
TaskId TEXT NOT NULL,
|
||||
FromStatus TEXT NOT NULL,
|
||||
ToStatus TEXT NOT NULL,
|
||||
Operator TEXT NOT NULL,
|
||||
OperatedAt TEXT NOT NULL,
|
||||
Reason TEXT NOT NULL,
|
||||
ErrorMessage TEXT NOT NULL,
|
||||
SnapshotJson TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info({table});";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var name = reader.GetString(1);
|
||||
if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
}
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {definition};");
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
|
||||
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
|
||||
user_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
keys_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
|
||||
);
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
|
||||
{
|
||||
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||
await creator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查表是否存在
|
||||
/// </summary>
|
||||
/// <param name="db">数据库上下文</param>
|
||||
/// <param name="table">表名</param>
|
||||
/// <returns>表是否存在</returns>
|
||||
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
await conn.OpenAsync();
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
if (db.Database.IsSqlServer())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else if (db.Database.IsNpgsql())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else if (db.Database.IsMySql())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result != null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (conn.State == System.Data.ConnectionState.Open)
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
||||
{
|
||||
var key = provider.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"postgres" or "postgresql" or "npgsql" => "PostgreSQL",
|
||||
"mssql" => "SqlServer",
|
||||
_ => provider
|
||||
};
|
||||
var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured;
|
||||
}
|
||||
|
||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
|
||||
}
|
||||
|
||||
private static bool IsSqlite(string provider) =>
|
||||
string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env)
|
||||
{
|
||||
var builder = new SqliteConnectionStringBuilder(connection);
|
||||
if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection;
|
||||
if (builder.DataSource is ":memory:") return connection;
|
||||
if (!Path.IsPathRooted(builder.DataSource))
|
||||
{
|
||||
builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource);
|
||||
}
|
||||
var dir = Path.GetDirectoryName(builder.DataSource);
|
||||
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
+93
-28
@@ -7,6 +7,9 @@ using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.OpenApi;
|
||||
using MiGu.Server.Ota;
|
||||
using MiGu.Server.Signal;
|
||||
using MiGu.DB.Kernel.Hosting;
|
||||
using MiGu.Server.Persistence;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
@@ -31,12 +34,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS")))
|
||||
// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。
|
||||
{
|
||||
// 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
||||
var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000);
|
||||
var urls = builder.Configuration["urls"]
|
||||
?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_URLS");
|
||||
if (string.IsNullOrWhiteSpace(urls))
|
||||
urls = "http://0.0.0.0:8080";
|
||||
|
||||
var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var hasReceive = parts.Any(u =>
|
||||
u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase)
|
||||
|| u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasReceive)
|
||||
{
|
||||
urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}"));
|
||||
// 安全提示:该端口挂匿名 upload-mdcs/upload-history(WatchDog 写死回传)。
|
||||
// upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。
|
||||
Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。");
|
||||
}
|
||||
builder.WebHost.UseUrls(urls);
|
||||
}
|
||||
|
||||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||
@@ -95,17 +113,22 @@ builder.Services.AddControllers()
|
||||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
opt.JsonSerializerOptions.WriteIndented = false;
|
||||
});
|
||||
// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||
{
|
||||
o.MultipartBodyLengthLimit = 512_000_000;
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "MiGu.Server + SimpleLite",
|
||||
Title = "MiGu.Server + Simple3",
|
||||
Version = "v1",
|
||||
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
|
||||
Description = "咪咕平台后端 API,以及经 YARP 反代的 Simple3 数据 WebApi(标签 Simple3/*)。详见 Simple/Simple3/Docs/MIGU-API.md。"
|
||||
});
|
||||
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
|
||||
c.DocumentFilter<Simple3OpenApiDocumentFilter>();
|
||||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
@@ -139,7 +162,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
||||
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||||
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||||
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
|
||||
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||||
// - InternalTokenStore 管理 Simple3 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||||
// 共享密钥(YARP transform 自动追加)。
|
||||
builder.Services.AddSingleton<RbacStore>();
|
||||
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||||
@@ -194,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()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(tctx =>
|
||||
@@ -207,60 +230,101 @@ builder.Services.AddReverseProxy()
|
||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||||
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
|
||||
|
||||
var user = rt.HttpContext.User;
|
||||
var username = user.FindFirst("unique_name")?.Value
|
||||
?? user.Identity?.Name
|
||||
?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst("sub")?.Value;
|
||||
var userId = user.FindFirst("sub")?.Value
|
||||
?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
var scope = user.FindFirst("scope")?.Value;
|
||||
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-User");
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-User-Id");
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-Scope");
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User", username);
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User-Id", userId);
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-Scope", scope);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||||
builder.Services.AddSingleton<ConfigStore>();
|
||||
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
|
||||
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
|
||||
builder.Services.AddSingleton<OpsAuditStore>();
|
||||
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
|
||||
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddPlatformPersistence(builder.Configuration);
|
||||
|
||||
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
|
||||
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
|
||||
builder.Services.AddSingleton<Simple3Launcher>();
|
||||
builder.Services.AddSingleton<SignalDataStore>();
|
||||
|
||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
||||
builder.Services.AddSingleton<OtaStore>();
|
||||
builder.Services.AddSingleton<InternalTokenStoreAccessor>();
|
||||
builder.Services.AddSingleton<OtaVehicleSource>();
|
||||
builder.Services.AddSingleton<WatchDogClient>();
|
||||
builder.Services.AddSingleton<OtaJobRunner>();
|
||||
builder.Services.AddSingleton<MiGu.Server.Fleet.FleetHealthService>();
|
||||
builder.Services.AddSingleton<MiGu.Server.Fleet.CdmTaskSyncer>();
|
||||
builder.Services.AddHostedService<MiGu.Server.Fleet.CdmTaskSyncService>();
|
||||
builder.Services.AddSingleton<MiGu.Server.Fleet.AlarmCollector>();
|
||||
builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
|
||||
builder.Services.AddHttpClient(nameof(WatchDogClient));
|
||||
|
||||
var app = builder.Build();
|
||||
await app.Services.EnsurePlatformDatabaseAsync();
|
||||
await app.Services.MigrateMiGuDbAsync();
|
||||
|
||||
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||||
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
|
||||
_ = app.Services.GetRequiredService<RbacStore>();
|
||||
// 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
||||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||||
// 主动实例化 Simple3Launcher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
||||
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
|
||||
{
|
||||
var sl = simpleLiteLauncher.GetDiagnostics();
|
||||
var sl = simple3Launcher.GetDiagnostics();
|
||||
app.Logger.LogInformation(
|
||||
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
|
||||
"[MiGu.Server] Simple3: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → Simple3",
|
||||
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||||
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||||
}
|
||||
|
||||
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
|
||||
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
|
||||
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
|
||||
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
|
||||
{
|
||||
app.Lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
||||
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
|
||||
catch { /* shutdown best-effort */ }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
|
||||
app.Logger.LogInformation("[MiGu.Server] Simple3: FollowParent=false — MiGu.Server 退出不会结束 Simple3");
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
|
||||
ctx.Connection.RemoteIpAddress?.ToString();
|
||||
await next();
|
||||
});
|
||||
|
||||
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
|
||||
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
|
||||
@@ -303,6 +367,7 @@ app.UseStaticFiles();
|
||||
|
||||
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<HttpActorContextMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://0.0.0.0:8080",
|
||||
"applicationUrl": "http://0.0.0.0:8080;http://0.0.0.0:8000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
+119
-35
@@ -5,8 +5,8 @@
|
||||
>
|
||||
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
||||
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
||||
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`,
|
||||
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
|
||||
> 后端 `AuthController.Login` 调用 `Simple3Launcher.MaybeStart(launchMode)` 按所选模式拉起 `Simple3.exe`,
|
||||
> 通过 `--display-mode=web|web+local` 透传给 Simple3 的 `Configuration.displayMode`。
|
||||
>
|
||||
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
||||
|
||||
@@ -19,83 +19,165 @@
|
||||
cd MiGu.Server
|
||||
dotnet build MiGu.Server.csproj -c Debug
|
||||
|
||||
# 2) 启动(登录后按所选模式拉起 SimpleLite)
|
||||
# 2) 启动(登录后按所选模式拉起 Simple3)
|
||||
dotnet run
|
||||
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
||||
```
|
||||
|
||||
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
||||
|
||||
## 配置 SimpleLite 路径(必读)
|
||||
## 配置 Simple3 路径(必读)
|
||||
|
||||
> **配置文件位置(无 Web 界面):**
|
||||
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
|
||||
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
|
||||
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
|
||||
> **`MiGu.Server/appsettings.json`** → 搜索 **`"Simple3"`** 节点。
|
||||
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `Simple3.exe`。
|
||||
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simple3`** 查看路径是否解析成功。
|
||||
|
||||
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
|
||||
登录成功后,MiGu.Server 会按所选启动模式拉起 **Simple3.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
|
||||
|
||||
### 本机开发(与 Simple 仓库并列)
|
||||
|
||||
仓库默认已在 `appsettings.Development.json` 中写好:
|
||||
|
||||
```json
|
||||
"SimpleLite": {
|
||||
"Simple3": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
}
|
||||
```
|
||||
|
||||
请先编译 SimpleLite(在 Simple 仓库):
|
||||
请先编译 Simple3(在 Simple 仓库):
|
||||
|
||||
```pwsh
|
||||
cd ..\Simple
|
||||
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
||||
dotnet build Simple3\Simple3.csproj -c Debug
|
||||
```
|
||||
|
||||
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
||||
|
||||
```json
|
||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
|
||||
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug"
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 键 | 含义 |
|
||||
|----|------|
|
||||
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI) |
|
||||
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs`) |
|
||||
| `Enabled` | `false` 时永不拉起 Simple3(只调试平台 UI) |
|
||||
| `ExecutablePath` | `Simple3.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/Simple3Options.cs`) |
|
||||
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
||||
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
||||
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
|
||||
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
|
||||
| `ReadinessTimeoutMs` | 登录后等待 Simple3 WebAPI 就绪的最长时间(毫秒) |
|
||||
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 Simple3;默认 `false`(两进程独立) |
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
```pwsh
|
||||
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
|
||||
$env:SimpleLite__WorkingDirectory = "D:\apps"
|
||||
$env:SimpleLite__Enabled = "true"
|
||||
$env:Simple3__ExecutablePath = "D:\apps\Simple3.exe"
|
||||
$env:Simple3__WorkingDirectory = "D:\apps"
|
||||
$env:Simple3__Enabled = "true"
|
||||
```
|
||||
|
||||
### 生产 / 合并发布
|
||||
|
||||
将 `SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
|
||||
将 `Simple3.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./Simple3.exe`),或在 `appsettings.Production.json` 写死部署路径。
|
||||
|
||||
浏览器访问 `http://localhost:8080/login`:
|
||||
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
||||
2. 选 scope(管理员 / 运营);
|
||||
3. 选 **SimpleLite 启动模式**:
|
||||
3. 选 **Simple3 启动模式**:
|
||||
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
||||
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
|
||||
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
|
||||
4. 点登录。`AuthController.Login` 调用 `Simple3Launcher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
|
||||
|
||||
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
|
||||
> 如果只想跑 MiGu.Server 单进程调试(不拉 Simple3),把 `appsettings.json:Simple3.Enabled` 改为 `false` 即可。
|
||||
|
||||
## 数据库启动流程
|
||||
|
||||
持久层在独立工程 **[MiGu.DB](../MiGu.DB/README.md)**;Server 通过 `PlatformPersistence` 接入。
|
||||
|
||||
### 启动顺序(`Program.cs`)
|
||||
|
||||
```text
|
||||
AddPlatformPersistence(configuration) # 注册 AddMiGuDb + Wms/SimpleFields 等业务服务
|
||||
│
|
||||
▼
|
||||
builder.Build()
|
||||
│
|
||||
▼
|
||||
MigrateMiGuDbAsync() # Schema 初始化 + 可选 IDataMigrator
|
||||
│
|
||||
▼
|
||||
… 其余中间件 …
|
||||
UseMiddleware<HttpActorContextMiddleware> # 请求级写入 IActorContext(审计戳)
|
||||
```
|
||||
|
||||
### 配置(`appsettings*.json`)
|
||||
|
||||
```json
|
||||
"Database": {
|
||||
"Provider": "sqlite",
|
||||
"SchemaMode": "Migrate",
|
||||
"ApplyDataMigratorsOnStartup": true
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Platform": "Data Source=data/platform.db"
|
||||
}
|
||||
```
|
||||
|
||||
| 配置项 | 含义 | 取值 |
|
||||
|--------|------|------|
|
||||
| `Provider` | 数据库种类 | `sqlite`(默认)/ `mysql` / `npgsql` / `sqlserver` |
|
||||
| `SchemaMode` | 启动时如何初始化 Schema | 见下表 |
|
||||
| `ApplyDataMigratorsOnStartup` | Schema 完成后是否跑数据修补 | `true`(默认)/ `false` |
|
||||
|
||||
当前仓库约定:
|
||||
|
||||
- `appsettings.json` → `"SchemaMode": "Migrate"`(发版/默认)
|
||||
- `appsettings.Development.json` → `"SchemaMode": "EnsureCreated"`(本地开发)
|
||||
|
||||
也可用环境变量覆盖,例如:`$env:Database__SchemaMode = "Migrate"`。
|
||||
|
||||
### SchemaMode 行为
|
||||
|
||||
| 值 | 行为 | 适用 |
|
||||
|----|------|------|
|
||||
| **`Migrate`** | 旧库无 `__EFMigrationsHistory` 时先写 Initial 基线 → `MigrateAsync` →(可选)DataMigrator | 测试 / 预发 / 生产 |
|
||||
| **`EnsureCreated`** | 仅 `EnsureCreated`(按**当前模型**建空库),**不**跑 Migrations →(可选)DataMigrator | 开发期改实体、暂不生成迁移 |
|
||||
|
||||
流程图:
|
||||
|
||||
```text
|
||||
SchemaMode = EnsureCreated
|
||||
→ EnsureCreatedAsync(库已存在则不改结构)
|
||||
→ IDataMigrator(ApplyDataMigratorsOnStartup=true 时)
|
||||
|
||||
SchemaMode = Migrate
|
||||
→ 程序集无 Migration → EnsureCreated 兜底
|
||||
→ 否则:基线(如需)→ MigrateAsync
|
||||
→ IDataMigrator(可选)
|
||||
```
|
||||
|
||||
**开发注意(EnsureCreated):**
|
||||
|
||||
- 改实体后结构不会自动升级;请删除 `MiGu.Server/data/platform.db*` 再启动。
|
||||
- 不会写入迁移历史;以后改回 `Migrate` 时,对已有库会走基线再应用 Migrations。
|
||||
|
||||
**发版注意:**
|
||||
|
||||
- 现场务必使用 `Migrate`,并随包带上 `MiGu.DB/Migrations/Sqlite/`。
|
||||
- 开发期若长期 `EnsureCreated` 改模型,发版前必须对**当前模型**执行 `dotnet ef migrations add`,再切回 `Migrate` 验证;否则空库只会落到旧 Migration,结构落后于代码。
|
||||
- 从 EnsureCreated 库切到 `Migrate`:若无 History,启动会把**全部** pending Migration 写入基线(假定库已对齐模型 tip)。结构不对齐时应删库重建或手工处理。
|
||||
|
||||
### 相关文档
|
||||
|
||||
- 持久层总览与 SchemaMode 摘要:[MiGu.DB/README.md](../MiGu.DB/README.md)
|
||||
- 如何生成/管理 Migration:[MiGu.DB/MIGRATIONS.md](../MiGu.DB/MIGRATIONS.md)
|
||||
|
||||
## 安全须知(生产部署必读)
|
||||
|
||||
@@ -116,7 +198,7 @@ $env:SimpleLite__Enabled = "true"
|
||||
>
|
||||
> 详见代码审核 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。
|
||||
|
||||
## 能力清单(本轮)
|
||||
|
||||
@@ -126,17 +208,17 @@ $env:SimpleLite__Enabled = "true"
|
||||
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
|
||||
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
|
||||
- YARP 反向代理:
|
||||
- `/api/sl/{**catch-all}` → `http://127.0.0.1:8222/`(SimpleLite WebAPI;当前 SimpleLite 未启 WebAPI 时返回 502);
|
||||
- `/api/sl/{**catch-all}` → `http://127.0.0.1:8222/`(Simple3 WebAPI;当前 Simple3 未启 WebAPI 时返回 502);
|
||||
- `/vr/{**catch-all}` → `http://127.0.0.1:8223/`(webVRender;可用于同源 iframe 解决 X-Frame-Options 限制);
|
||||
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
|
||||
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `SimpleLiteLauncher` 据此拉起子进程;
|
||||
- SimpleLite 子进程编排:`Launcher/SimpleLiteLauncher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:SimpleLite`;
|
||||
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `Simple3Launcher` 据此拉起子进程;
|
||||
- Simple3 子进程编排:`Launcher/Simple3Launcher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:Simple3`;
|
||||
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
||||
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
||||
- 健康检查:`/api/health`;
|
||||
- Swagger:开发环境下 `/swagger`。
|
||||
|
||||
> 不在本轮范围:真实 SimpleLite WebAPI、SystemMission 拉起、真实 JWT/RBAC、SQLite/EF Core 持久层。
|
||||
> 能力补充:平台库由 **MiGu.DB**(EF Core)承载,启动流程见「[数据库启动流程](#数据库启动流程)」。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -173,7 +255,7 @@ MiGu.Server/
|
||||
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
||||
```
|
||||
|
||||
## 独立启动(仅当你不通过 SimpleLite 自启时)
|
||||
## 独立启动(仅当你不通过 Simple3 自启时)
|
||||
|
||||
```pwsh
|
||||
# 1) 还原 + 编译 + 运行
|
||||
@@ -196,7 +278,7 @@ dotnet run
|
||||
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...` 或 `WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
|
||||
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`。
|
||||
> 仓库根 `build-platform-frontend.bat` 仍可用于「打部署包」场景(把 dist robocopy /MIR 同步到 wwwroot)。
|
||||
> SimpleLite 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist;
|
||||
> Simple3 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist;
|
||||
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
|
||||
|
||||
## 与前端联调
|
||||
@@ -210,7 +292,7 @@ dotnet run
|
||||
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
||||
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
||||
|
||||
## YARP 与 SimpleLite
|
||||
## YARP 与 Simple3
|
||||
|
||||
YARP 路由配置见 `appsettings.json`:
|
||||
|
||||
@@ -230,7 +312,7 @@ YARP 路由配置见 `appsettings.json`:
|
||||
```
|
||||
|
||||
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
||||
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`,SimpleLite 未启时会得到 502/连接被拒。
|
||||
- 其他 `/api/sl/*` 由 YARP 透传到 Simple3 `:8222`,Simple3 未启时会得到 502/连接被拒。
|
||||
|
||||
## iframe 嵌入 webVRender 的两种方式
|
||||
|
||||
@@ -242,3 +324,5 @@ YARP 路由配置见 `appsettings.json`:
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
|
||||
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
|
||||
- [MiGu.DB/README.md](../MiGu.DB/README.md) — 持久层框架;[MIGRATIONS.md](../MiGu.DB/MIGRATIONS.md) — 生成 Migration 手册
|
||||
- 本文「[数据库启动流程](#数据库启动流程)」— SchemaMode / EnsureCreated / Migrate
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MiGu.Server.SimpleFields;
|
||||
|
||||
public static class SimpleFieldDateTime
|
||||
{
|
||||
public const string StorageFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
public static DateTimeOffset Now => DateTimeOffset.Now;
|
||||
|
||||
public static string ToStorage(DateTimeOffset value) =>
|
||||
value.LocalDateTime.ToString(StorageFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
public static DateTimeOffset FromStorage(string value)
|
||||
{
|
||||
if (DateTime.TryParseExact(value, StorageFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var local))
|
||||
return new DateTimeOffset(local);
|
||||
|
||||
return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user